Skip to content

Commit

Permalink
Merge pull request #70 from ansopedia/be-60-create-profile-api
Browse files Browse the repository at this point in the history
Add PUT /profile API to handle upsert profile request
  • Loading branch information
Ansopedia authored Sep 22, 2024
2 parents 8fb74d8 + db85e2f commit bef247d
Show file tree
Hide file tree
Showing 17 changed files with 278 additions and 29 deletions.
5 changes: 0 additions & 5 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@
## Role

- should protect get Roles, Update, & delete role
- Default roles can't be deleted
- Only the role created by user can be deleted
- Add a flag `canBeDeleted`. If it is true then only alow to deleted
- Even admin can't deleted if the role is `isSystemRole`
- Add proper description about the role

## Permission

Expand Down
31 changes: 8 additions & 23 deletions src/api/v1/auth/__test__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { sign } from 'jsonwebtoken';
import { envConstants, errorMap, ErrorTypeEnum, STATUS_CODES } from '@/constants';
import { expectSignUpSuccess, login, renewToken, signUp } from '@/utils/test';
import {
expectSignUpSuccess,
expectUnauthorizedResponseForInvalidToken,
login,
renewToken,
signUp,
} from '@/utils/test';
import { Login, loginSchema } from '@/api/v1/auth/auth.validation';
import { ZodError, ZodIssue } from 'zod';

Expand Down Expand Up @@ -61,29 +67,8 @@ describe('Auth Test', () => {
});

it('should respond with 401 for invalid token', async () => {
const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN];

const response = await renewToken('Bearer invalidToken');

expect(response.statusCode).toBe(STATUS_CODES.UNAUTHORIZED);
expect(response.body).toMatchObject({
message: errorObject.body.message,
code: errorObject.body.code,
status: 'failed',
});
});

it('should throw an error if the token is invalid', async () => {
const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN];

const response = await renewToken(`Bearer invalidToken`);

expect(response.status).toBe(STATUS_CODES.UNAUTHORIZED);
expect(response.body).toMatchObject({
message: errorObject.body.message,
code: errorObject.body.code,
status: 'failed',
});
expectUnauthorizedResponseForInvalidToken(response);
});

it('should throw an error if the token is expired', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/api/v1/auth/auth.dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class AuthDAL {
}

static async upsertAuthTokens({ userId, refreshToken }: Auth): Promise<Auth | null> {
return await AuthModel.findOneAndUpdate({ userId }, { refreshToken: refreshToken }, { upsert: true, new: true });
return await AuthModel.findOneAndUpdate({ userId }, { refreshToken }, { upsert: true, new: true });
}

static async deleteAuth(userId: string): Promise<Auth | null> {
Expand Down
53 changes: 53 additions & 0 deletions src/api/v1/profile/__test__/profle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
expectBadRequestResponseForValidationError,
expectLoginSuccess,
expectProfileData,
expectUnauthorizedResponseForInvalidAuthorizationHeader,
expectUnauthorizedResponseForInvalidToken,
expectUnauthorizedResponseForMissingAuthorizationHeader,
login,
upSertProfileData,
} from '@/utils/test';
import { defaultUsers } from '@/constants';
import { CreateProfileData } from '../profile.validation';

const profileData: CreateProfileData = { avatar: 'http://avatar.com', bio: 'bio', phoneNumber: 'phoneNumber' };

describe('Profile Service', () => {
let authorizationHeader: string;
let loggedInUserId: string;

beforeAll(async () => {
const loginResponse = await login(defaultUsers);
expectLoginSuccess(loginResponse);
loggedInUserId = loginResponse.body.userId;
authorizationHeader = `Bearer ${loginResponse.header['authorization']}`;
});

describe('upSertProfileData', () => {
it('should throw error if access token is not provided', async () => {
const response = await upSertProfileData({}, '');
expectUnauthorizedResponseForMissingAuthorizationHeader(response);
});

it('should return 401 for invalid authorization header', async () => {
const response = await upSertProfileData({}, 'invalid');
expectUnauthorizedResponseForInvalidAuthorizationHeader(response);
});

it('should throw an error if invalid access token is provided', async () => {
const response = await upSertProfileData({}, 'Bearer invalid-access-token');
expectUnauthorizedResponseForInvalidToken(response);
});

it('should throw error if body is not provided', async () => {
const response = await upSertProfileData({}, authorizationHeader);
expectBadRequestResponseForValidationError(response);
});

it('should update profile data', async () => {
const response = await upSertProfileData(profileData, authorizationHeader);
expectProfileData(response, { userId: loggedInUserId, ...profileData });
});
});
});
3 changes: 3 additions & 0 deletions src/api/v1/profile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './profile.service';
export * from './profile.validation';
export * from './profile.constant';
3 changes: 3 additions & 0 deletions src/api/v1/profile/profile.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const success = {
PROFILE_UPDATED_SUCCESSFULLY: 'Profile updated successfully',
};
23 changes: 23 additions & 0 deletions src/api/v1/profile/profile.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { sendResponse } from '@/utils';
import { NextFunction, Request, Response } from 'express';
import { ProfileService } from './profile.service';
import { success } from './profile.constant';

export class ProfileController {
static upSertProfile = async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await new ProfileService().upSertProfileData({
userId: req.body.loggedInUser.userId,
...req.body,
});
sendResponse({
response: res,
message: success.PROFILE_UPDATED_SUCCESSFULLY,
payload: profile,
statusCode: 200,
});
} catch (error) {
next(error);
}
};
}
15 changes: 15 additions & 0 deletions src/api/v1/profile/profile.dal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ProfileDataModel } from './profile.modal';
import { ProfileData } from './profile.validation';

interface IProfileDataDal {
upSertProfileData(payload: ProfileData): Promise<ProfileData>;
}

export class ProfileDataDAL implements IProfileDataDal {
async upSertProfileData(payload: ProfileData): Promise<ProfileData> {
return await ProfileDataModel.findOneAndUpdate({ userId: payload.userId }, payload, {
upsert: true,
new: true,
});
}
}
14 changes: 14 additions & 0 deletions src/api/v1/profile/profile.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ProfileData } from './profile.validation';

export const ProfileDto = (profile: ProfileData) => ({
getProfile: () => {
return {
userId: profile.userId,
avatar: profile.avatar,
bio: profile.bio,
address: profile.address,
phoneNumber: profile.phoneNumber,
socialLinks: profile.socialLinks,
};
},
});
47 changes: 47 additions & 0 deletions src/api/v1/profile/profile.modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { model, Schema, Types } from 'mongoose';
import { ProfileData } from './profile.validation';

const ProfileData = new Schema<ProfileData>(
{
userId: {
type: String,
required: true,
validate: {
validator: (v: string) => Types.ObjectId.isValid(v),
message: 'userId must be a valid MongoDB ObjectId string',
},
ref: 'User',
},
avatar: {
type: String,
trim: true,
},
bio: {
type: String,
trim: true,
maxlength: 500,
},
address: {
type: {
street: { type: String },
city: { type: String },
country: { type: String },
zipCode: { type: String },
},
},
phoneNumber: {
type: String,
trim: true,
},
socialLinks: {
type: {
twitter: { type: String },
linkedin: { type: String },
github: { type: String },
},
},
},
{ timestamps: true },
);

export const ProfileDataModel = model<ProfileData>('Profile', ProfileData);
9 changes: 9 additions & 0 deletions src/api/v1/profile/profile.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from 'express';
import { validateAccessToken } from '@/middlewares';
import { ProfileController } from './profile.controller';

const router = Router();

router.put('/profile', validateAccessToken, ProfileController.upSertProfile);

export { router as profileRoutes };
18 changes: 18 additions & 0 deletions src/api/v1/profile/profile.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ProfileDataDAL } from './profile.dal';
import { ProfileDto } from './profile.dto';
import { ProfileData, validateProfileSchema } from './profile.validation';

export class ProfileService {
private profileDataDal: ProfileDataDAL;

constructor() {
this.profileDataDal = new ProfileDataDAL();
}

upSertProfileData = async (payload: ProfileData) => {
const profileData = validateProfileSchema(payload);

const updateProfileData = await this.profileDataDal.upSertProfileData(profileData);
return ProfileDto(updateProfileData).getProfile();
};
}
46 changes: 46 additions & 0 deletions src/api/v1/profile/profile.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { objectIdSchema } from '@/utils';

export const profileSchema = z.object({
userId: objectIdSchema,
avatar: z.string().url().optional(),
bio: z.string().max(500).optional(),
address: z
.object({
street: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
zipCode: z.string().optional(),
})
.optional(),
phoneNumber: z.string().optional(),
socialLinks: z
.object({
twitter: z.string().url().optional(),
linkedin: z.string().url().optional(),
github: z.string().url().optional(),
})
.optional(),
});

export const validateProfileSchema = (data: ProfileData) => {
// Check if at least one key from profileSchema is present in the data, excluding userId
const hasAnyKey = Object.keys(profileSchema.shape)
.filter((key) => key !== 'userId')
.some((key) => key in data && data[key as keyof ProfileData] !== undefined);

if (!hasAnyKey) {
throw new z.ZodError([
{
code: z.ZodIssueCode.custom,
path: Object.keys(profileSchema.shape).filter((key) => key !== 'userId'),
message: 'At least one field from the profile schema must be provided',
},
]);
}

return profileSchema.parse(data);
};

export type ProfileData = z.infer<typeof profileSchema>;
export type CreateProfileData = Omit<ProfileData, 'userId'>;
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { rolePermissionRoutes } from '@/api/v1/rolePermission/role-permission.ro
import { authRoutes } from '@/api/v1/auth/auth.route';
import { userRoleRoutes } from '@/api/v1/userRole/user-role.route';
import { otpRoutes } from '@/api/v1/otp/otp.route';
import { profileRoutes } from '@/api/v1/profile/profile.route';

export const routes = Router();

Expand All @@ -18,4 +19,5 @@ routes.use(
userRoleRoutes,
authRoutes,
otpRoutes,
profileRoutes,
);
8 changes: 8 additions & 0 deletions src/utils/test/authorize.middleware.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export const expectUnauthorizedResponseForInvalidAuthorizationHeader = async (re
expect(response.body.code).toBe(errorObject.body.code);
};

export const expectUnauthorizedResponseForInvalidToken = async (response: Response) => {
const errorObject = errorMap[ErrorTypeEnum.enum.INVALID_TOKEN];

expect(response.statusCode).toBe(STATUS_CODES.UNAUTHORIZED);
expect(response.body.message).toBe(errorObject.body.message);
expect(response.body.code).toBe(errorObject.body.code);
};

export const expectUnauthorizedResponseWhenUserHasInsufficientPermission = async (response: Response) => {
const errorObject = errorMap[ErrorTypeEnum.enum.NOT_ENOUGH_PERMISSION];

Expand Down
1 change: 1 addition & 0 deletions src/utils/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './role.utils';
export * from './authorize.middleware.utils';
export * from './role-permission.utils';
export * from './user-role.utils';
export * from './profile.utils';
27 changes: 27 additions & 0 deletions src/utils/test/profile.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import supertest, { Response } from 'supertest';
import { CreateProfileData, ProfileData, success } from '@/api/v1/profile';
import { app } from '@/app';
import { STATUS_CODES } from '@/constants';

export const upSertProfileData = async (payload: CreateProfileData, authorizationHeader: string) => {
return await supertest(app).put('/api/v1/profile').set('authorization', authorizationHeader).send(payload);
};

export const expectProfileData = (response: Response, payload: ProfileData) => {
expect(response).toBeDefined();
expect(response.statusCode).toBe(STATUS_CODES.OK);

const expectedBody: Partial<ProfileData> & { message: string; status: string } = {
message: success.PROFILE_UPDATED_SUCCESSFULLY,
status: 'success',
userId: payload.userId,
};

if (payload.avatar != null) expectedBody.avatar = payload.avatar;
if (payload.bio != null) expectedBody.bio = payload.bio;
if (payload.phoneNumber != null) expectedBody.phoneNumber = payload.phoneNumber;
if (payload.address) expectedBody.address = payload.address;
if (payload.socialLinks) expectedBody.socialLinks = payload.socialLinks;

expect(response.body).toMatchObject(expectedBody);
};

0 comments on commit bef247d

Please sign in to comment.