Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/api/v1/posts/posts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import {
handleDBKnownErrors,
fieldsToIncludeWithPost,
fieldsToIncludeWithComment,
} from '../../../lib/helpers';
import { Prisma } from '../../../../prisma/generated/client';
import db from '../../../lib/db';
Expand Down Expand Up @@ -162,6 +163,7 @@ export const findPostCommentByCompoundIdOrThrow = async (
: { post: { published: true } },
},
},
include: fieldsToIncludeWithComment,
});
const comment = await handleDBKnownErrors(dbQuery);
if (!comment) throw new AppNotFoundError('Post/Comment Not Found');
Expand Down
28 changes: 17 additions & 11 deletions src/api/v1/users/user.schema.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import { z } from 'zod';
import { ADMIN_SECRET } from '../../../lib/config';

export const bioSchema = z
.string({ invalid_type_error: 'Use bio must be a string' })
.trim()
.optional();

export const usernameSchema = z
.string({
required_error: 'Username is required',
invalid_type_error: 'Username must be a string',
})
.trim()
.min(3, 'Username must contain at least 3 character(s)')
.max(48, 'Username must contain at most 48 character(s)');
.regex(
/^\w+$/,
'Username Can only have letters, numbers, and underscores (_)'
)
.min(3, 'Username must contain at least 3 characters')
.max(48, 'Username must contain at most 48 characters');

export const fullnameSchema = z
.string({
required_error: 'Fullname is required',
invalid_type_error: 'Fullname must be a string',
})
.trim()
.min(3, 'Fullname must contain at least 3 character(s)')
.max(96, 'Fullname must contain at most 96 character(s)');
.min(3, 'Fullname must contain at least 3 characters')
.max(96, 'Fullname must contain at most 96 characters');

export const passwordSchema = z
.object({
Expand Down Expand Up @@ -60,15 +69,12 @@ export const userSchema = passwordSchema
username: usernameSchema,
fullname: fullnameSchema,
secret: secretSchema,
bio: bioSchema,
})
)
.transform((data) => {
return {
isAdmin: Boolean(data.secret),
username: data.username,
fullname: data.fullname,
password: data.password,
};
.transform(({ secret, username, fullname, password, bio }) => {
const isAdmin = Boolean(secret);
return { isAdmin, username, fullname, password, bio };
});

export default userSchema;
24 changes: 13 additions & 11 deletions src/api/v1/users/users.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { AuthResponse, NewUserInput } from '../../../types';
import { Prisma } from '../../../../prisma/generated/client';
import {
authValidator,
adminValidator,
optionalAuthValidator,
createAdminOrOwnerValidator,
} from '../../../middlewares/validators';
Expand All @@ -26,20 +25,23 @@ import usersService from './users.service';

export const usersRouter = Router();

usersRouter.get('/', authValidator, adminValidator, async (req, res) => {
/*
* NOTE: There are no restriction on any GET method,
* because the API responding on a limited set of origins.
* See the CORS settings in the app's entry point.
*/

usersRouter.get('/', async (req, res) => {
const users = await usersService.getAllUsers();
res.json(users);
});

usersRouter.get(
'/:id',
authValidator,
createAdminOrOwnerValidator((req) => req.params.id),
async (req, res) => {
const user = await usersService.findUserByIdOrThrow(req.params.id);
res.json(user);
}
);
usersRouter.get('/:idOrUsername', async (req, res) => {
const user = await usersService.findUserByIdOrByUsernameOrThrow(
req.params.idOrUsername
);
res.json(user);
});

usersRouter.get('/:id/posts', optionalAuthValidator, async (req, res) => {
const authorId = req.params.id;
Expand Down
32 changes: 28 additions & 4 deletions src/api/v1/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AppInvalidIdError, AppNotFoundError } from '../../../lib/app-error';
import { Prisma } from '../../../../prisma/generated/client';
import { NewUserOutput, PublicUser } from '../../../types';
import { handleDBKnownErrors } from '../../../lib/helpers';
import { AppNotFoundError } from '../../../lib/app-error';
import { SALT } from '../../../lib/config';
import db from '../../../lib/db';
import bcrypt from 'bcryptjs';
Expand All @@ -27,8 +27,30 @@ export const findUserById = async (id: string): Promise<PublicUser | null> => {
return user;
};

export const findUserByIdOrThrow = async (id: string) => {
const user = await findUserById(id);
export const findUserByUsername = async (
username: string
): Promise<PublicUser | null> => {
const dbQuery = db.user.findUnique({ where: { username } });
const user = await handleDBKnownErrors(dbQuery);
return user;
};

export const findUserByIdOrUsername = async (
idOrUsername: string
): Promise<PublicUser | null> => {
try {
return await findUserById(idOrUsername);
} catch (error) {
if (error instanceof AppInvalidIdError) {
// So, the id was not a valid UUID, and could be a username
return await findUserByUsername(idOrUsername);
}
throw error;
}
};

export const findUserByIdOrByUsernameOrThrow = async (idOrUsername: string) => {
const user = await findUserByIdOrUsername(idOrUsername);
if (!user) throw new AppNotFoundError('User not found');
return user;
};
Expand All @@ -55,7 +77,9 @@ export const deleteUser = async (id: string): Promise<void> => {
};

export default {
findUserByIdOrThrow,
findUserByIdOrByUsernameOrThrow,
findUserByIdOrUsername,
findUserByUsername,
findUserById,
getAllUsers,
createUser,
Expand Down
10 changes: 8 additions & 2 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export const fieldsToIncludeWithPost = {
author: true,
};

export const fieldsToIncludeWithComment = { post: true, author: true };

export const fieldsToIncludeWithVote = { post: true, user: true };

export const getTextFilterFromReqQuery = (req: Request) => {
let text;
if (typeof req.query.q === 'string') {
Expand Down Expand Up @@ -188,7 +192,7 @@ export const findFilteredComments = async (
? { content: { contains: options.text, mode: 'insensitive' } }
: {}),
},
include: { post: { include: fieldsToIncludeWithPost } },
include: fieldsToIncludeWithComment,
});
const comments = await handleDBKnownErrors(dbQuery);
return comments;
Expand All @@ -208,7 +212,7 @@ export const findFilteredVotes = async (
: { post: { published: true } }),
...(typeof isUpvote === 'boolean' ? { isUpvote } : {}),
},
include: { post: { include: fieldsToIncludeWithPost } },
include: fieldsToIncludeWithVote,
});
const comments = await handleDBKnownErrors(dbQuery);
return comments;
Expand All @@ -222,7 +226,9 @@ export default {
handleDBKnownErrors,
findFilteredComments,
fieldsToIncludeWithPost,
fieldsToIncludeWithVote,
getTextFilterFromReqQuery,
fieldsToIncludeWithComment,
getSignedInUserIdFromReqQuery,
getVoteTypeFilterFromReqQuery,
getCategoriesFilterFromReqQuery,
Expand Down
1 change: 1 addition & 0 deletions src/tests/api/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const setup = async (signinUrl: string) => {
};

const userData = {
bio: 'Coming from krypton with super power.',
fullname: 'Clark Kent/Kal-El',
username: 'superman',
password: 'Ss@12312',
Expand Down
107 changes: 32 additions & 75 deletions src/tests/api/v1/users.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('Users endpoint', async () => {
afterAll(resetDB);

describe(`POST ${USERS_URL}`, () => {
for (const field of Object.keys(newUserData)) {
for (const field of Object.keys(newUserData).filter((k) => k !== 'bio')) {
it(`should not create a user without ${field}`, async () => {
const res = await api
.post(USERS_URL)
Expand All @@ -68,6 +68,17 @@ describe('Users endpoint', async () => {
expect(await db.user.findMany()).toHaveLength(0);
});

const invalidUsernames = ['user-x', 'user x', 'user@x', 'user(x)'];
for (const username of invalidUsernames) {
it(`should not create a user while the username having a space`, async () => {
const res = await api
.post(USERS_URL)
.send({ ...newUserData, username });
assertResponseWithValidationError(res, 'username');
expect(await db.user.findMany()).toHaveLength(0);
});
}

it('should not create a user if the username is already exist', async () => {
const { id } = await createUser(userData);
const res = await api.post(USERS_URL).send(newUserData);
Expand Down Expand Up @@ -121,6 +132,8 @@ describe('Users endpoint', async () => {
expect(resJwtPayload.fullname).toBeUndefined();
expect(dbUser.password).toMatch(/^\$2[a|b|x|y]\$.{56}/);
expect(dbUser.isAdmin).toBe(isAdmin);
expect(dbUser.bio).toBe(newUserData.bio);
expect(resUser.bio).toBe(newUserData.bio);
};
};

Expand All @@ -130,18 +143,6 @@ describe('Users endpoint', async () => {
});

describe(`GET ${USERS_URL}`, () => {
it('should respond with 401 on request without JWT', async () => {
const res = await api.get(USERS_URL);
assertUnauthorizedErrorRes(res);
});

it('should respond with 401 on request with non-admin JWT', async () => {
await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(userData);
const res = await authorizedApi.get(USERS_URL);
assertUnauthorizedErrorRes(res);
});

it('should respond with users list, on request with admin JWT', async () => {
await createUser(adminData);
const dbUser = await createUser(userData);
Expand All @@ -157,79 +158,35 @@ describe('Users endpoint', async () => {
});
});

describe(`GET ${USERS_URL}/:id`, () => {
it('should respond with 401 on request without JWT', async () => {
const dbUser = await createUser(userData);
const res = await api.get(`${USERS_URL}/${dbUser.id}`);
assertUnauthorizedErrorRes(res);
});

it('should respond with 401 on request with non-admin/owner JWT', async () => {
await createUser(xUserData);
const dbUser = await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(xUserData);
const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`);
assertUnauthorizedErrorRes(res);
});

it('should respond with 401, on request with owner JWT', async () => {
await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(userData);
const res = await authorizedApi.get(`${USERS_URL}/foo`);
assertUnauthorizedErrorRes(res);
});

it('should respond with 400 if given an invalid id, on request with admin JWT', async () => {
describe(`GET ${USERS_URL}/:idOrUsername`, () => {
it('should respond with 404 on request with id, if user does not exit', async () => {
await createUser(adminData);
const { authorizedApi } = await prepForAuthorizedTest(adminData);
const res = await authorizedApi.get(`${USERS_URL}/foo`);
assertInvalidIdErrorRes(res);
});

it('should respond with 401 if user does not exit, on request with owner JWT', async () => {
const dbUser = await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(userData);
await db.user.delete({ where: { id: dbUser.id } });
const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`);
assertUnauthorizedErrorRes(res);
const res = await api.get(`${USERS_URL}/${dbUser.id}`);
assertNotFoundErrorRes(res);
});

it('should respond with 404 if user does not exit, on request with admin JWT', async () => {
it('should respond with 404 on request with username, if user does not exit', async () => {
await createUser(adminData);
const dbUser = await createUser(userData);
await db.user.delete({ where: { id: dbUser.id } });
const { authorizedApi } = await prepForAuthorizedTest(adminData);
const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`);
const res = await api.get(`${USERS_URL}/not_user`);
assertNotFoundErrorRes(res);
});

it('should respond with the found user, on request with owner JWT', async () => {
const dbUser = await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(userData);
const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`);
const resUser = res.body as User;
expect(res.type).toMatch(/json/);
expect(res.statusCode).toBe(200);
expect(resUser.id).toBe(dbUser.id);
expect(resUser.isAdmin).toStrictEqual(false);
expect(resUser.username).toBe(dbUser.username);
expect(resUser.fullname).toBe(dbUser.fullname);
expect(resUser.password).toBeUndefined();
});

it('should respond with the found user, on request with admin JWT', async () => {
it('should respond with the found user on request with id or username', async () => {
await createUser(adminData);
const dbUser = await createUser(userData);
const { authorizedApi } = await prepForAuthorizedTest(adminData);
const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`);
const resUser = res.body as User;
expect(res.type).toMatch(/json/);
expect(res.statusCode).toBe(200);
expect(resUser.id).toBe(dbUser.id);
expect(resUser.isAdmin).toStrictEqual(false);
expect(resUser.username).toBe(dbUser.username);
expect(resUser.fullname).toBe(dbUser.fullname);
expect(resUser.password).toBeUndefined();
for (const param of [dbUser.id, dbUser.username]) {
const res = await api.get(`${USERS_URL}/${param}`);
const resUser = res.body as User;
expect(res.type).toMatch(/json/);
expect(res.statusCode).toBe(200);
expect(resUser.id).toBe(dbUser.id);
expect(resUser.isAdmin).toStrictEqual(false);
expect(resUser.username).toBe(dbUser.username);
expect(resUser.fullname).toBe(dbUser.fullname);
expect(resUser.password).toBeUndefined();
}
});
});

Expand Down