From 00a6ac2450227dacbb3f89045d6b78a263dccc63 Mon Sep 17 00:00:00 2001 From: Hussein Kandil <101815486+hussein-m-kandil@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:13:27 +0300 Subject: [PATCH 1/4] Edit `username` schema & accept `bio` in `/users` --- src/api/v1/users/user.schema.ts | 28 +++++++++++++++++----------- src/tests/api/setup.ts | 1 + src/tests/api/v1/users.int.test.ts | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/api/v1/users/user.schema.ts b/src/api/v1/users/user.schema.ts index 431f81a..1ab4193 100644 --- a/src/api/v1/users/user.schema.ts +++ b/src/api/v1/users/user.schema.ts @@ -1,14 +1,23 @@ 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({ @@ -16,8 +25,8 @@ export const fullnameSchema = z 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({ @@ -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; diff --git a/src/tests/api/setup.ts b/src/tests/api/setup.ts index 928157d..5cb2ac9 100644 --- a/src/tests/api/setup.ts +++ b/src/tests/api/setup.ts @@ -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', diff --git a/src/tests/api/v1/users.int.test.ts b/src/tests/api/v1/users.int.test.ts index 18e2d8e..854362e 100644 --- a/src/tests/api/v1/users.int.test.ts +++ b/src/tests/api/v1/users.int.test.ts @@ -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) @@ -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); @@ -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); }; }; From 52c1a2cec03b8f24358c26168333b292cfc2d7f2 Mon Sep 17 00:00:00 2001 From: Hussein Kandil <101815486+hussein-m-kandil@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:40:30 +0300 Subject: [PATCH 2/4] Remove restrictions on getting users There are no restriction on any GET method, because the API is responding on a limited set of origins. See the CORS settings in the app's entry point. --- src/api/v1/users/users.router.ts | 22 +++++----- src/tests/api/v1/users.int.test.ts | 70 +++--------------------------- 2 files changed, 17 insertions(+), 75 deletions(-) diff --git a/src/api/v1/users/users.router.ts b/src/api/v1/users/users.router.ts index 3e704a0..8f31960 100644 --- a/src/api/v1/users/users.router.ts +++ b/src/api/v1/users/users.router.ts @@ -12,7 +12,6 @@ import { AuthResponse, NewUserInput } from '../../../types'; import { Prisma } from '../../../../prisma/generated/client'; import { authValidator, - adminValidator, optionalAuthValidator, createAdminOrOwnerValidator, } from '../../../middlewares/validators'; @@ -26,20 +25,21 @@ 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('/:id', async (req, res) => { + const user = await usersService.findUserByIdOrThrow(req.params.id); + res.json(user); +}); usersRouter.get('/:id/posts', optionalAuthValidator, async (req, res) => { const authorId = req.params.id; diff --git a/src/tests/api/v1/users.int.test.ts b/src/tests/api/v1/users.int.test.ts index 854362e..0d51190 100644 --- a/src/tests/api/v1/users.int.test.ts +++ b/src/tests/api/v1/users.int.test.ts @@ -143,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); @@ -171,70 +159,24 @@ 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 () => { + it('should respond with 400 if given an invalid id', async () => { await createUser(adminData); - const { authorizedApi } = await prepForAuthorizedTest(adminData); - const res = await authorizedApi.get(`${USERS_URL}/foo`); + const res = await api.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); - }); - - it('should respond with 404 if user does not exit, on request with admin JWT', async () => { + it('should respond with 404 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}/${dbUser.id}`); 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', async () => { await createUser(adminData); const dbUser = await createUser(userData); - const { authorizedApi } = await prepForAuthorizedTest(adminData); - const res = await authorizedApi.get(`${USERS_URL}/${dbUser.id}`); + const res = await api.get(`${USERS_URL}/${dbUser.id}`); const resUser = res.body as User; expect(res.type).toMatch(/json/); expect(res.statusCode).toBe(200); From 3c3733b1888bd2676b8ffa64795889e98e910206 Mon Sep 17 00:00:00 2001 From: Hussein Kandil <101815486+hussein-m-kandil@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:44:56 +0300 Subject: [PATCH 3/4] Support getting a user by ID or username --- src/api/v1/users/users.router.ts | 6 +++-- src/api/v1/users/users.service.ts | 32 +++++++++++++++++++++---- src/tests/api/v1/users.int.test.ts | 38 ++++++++++++++++-------------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/src/api/v1/users/users.router.ts b/src/api/v1/users/users.router.ts index 8f31960..b5bb240 100644 --- a/src/api/v1/users/users.router.ts +++ b/src/api/v1/users/users.router.ts @@ -36,8 +36,10 @@ usersRouter.get('/', async (req, res) => { res.json(users); }); -usersRouter.get('/:id', async (req, res) => { - const user = await usersService.findUserByIdOrThrow(req.params.id); +usersRouter.get('/:idOrUsername', async (req, res) => { + const user = await usersService.findUserByIdOrByUsernameOrThrow( + req.params.idOrUsername + ); res.json(user); }); diff --git a/src/api/v1/users/users.service.ts b/src/api/v1/users/users.service.ts index 4a7b4a4..0820f54 100644 --- a/src/api/v1/users/users.service.ts +++ b/src/api/v1/users/users.service.ts @@ -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'; @@ -27,8 +27,30 @@ export const findUserById = async (id: string): Promise => { return user; }; -export const findUserByIdOrThrow = async (id: string) => { - const user = await findUserById(id); +export const findUserByUsername = async ( + username: string +): Promise => { + const dbQuery = db.user.findUnique({ where: { username } }); + const user = await handleDBKnownErrors(dbQuery); + return user; +}; + +export const findUserByIdOrUsername = async ( + idOrUsername: string +): Promise => { + 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; }; @@ -55,7 +77,9 @@ export const deleteUser = async (id: string): Promise => { }; export default { - findUserByIdOrThrow, + findUserByIdOrByUsernameOrThrow, + findUserByIdOrUsername, + findUserByUsername, findUserById, getAllUsers, createUser, diff --git a/src/tests/api/v1/users.int.test.ts b/src/tests/api/v1/users.int.test.ts index 0d51190..5033ba9 100644 --- a/src/tests/api/v1/users.int.test.ts +++ b/src/tests/api/v1/users.int.test.ts @@ -158,14 +158,8 @@ describe('Users endpoint', async () => { }); }); - describe(`GET ${USERS_URL}/:id`, () => { - it('should respond with 400 if given an invalid id', async () => { - await createUser(adminData); - const res = await api.get(`${USERS_URL}/foo`); - assertInvalidIdErrorRes(res); - }); - - it('should respond with 404 if user does not exit', 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 dbUser = await createUser(userData); await db.user.delete({ where: { id: dbUser.id } }); @@ -173,18 +167,26 @@ describe('Users endpoint', async () => { assertNotFoundErrorRes(res); }); - it('should respond with the found user', async () => { + it('should respond with 404 on request with username, if user does not exit', async () => { + await createUser(adminData); + const res = await api.get(`${USERS_URL}/not_user`); + assertNotFoundErrorRes(res); + }); + + it('should respond with the found user on request with id or username', async () => { await createUser(adminData); const dbUser = await createUser(userData); - const res = await api.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(); + } }); }); From 46e4dc7918798a354eecbcee4968e717a6fd3a27 Mon Sep 17 00:00:00 2001 From: Hussein Kandil <101815486+hussein-m-kandil@users.noreply.github.com> Date: Mon, 23 Jun 2025 18:58:57 +0300 Subject: [PATCH 4/4] Edit data included with comments and votes --- src/api/v1/posts/posts.service.ts | 2 ++ src/lib/helpers.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/v1/posts/posts.service.ts b/src/api/v1/posts/posts.service.ts index f3b945e..5d73bdb 100644 --- a/src/api/v1/posts/posts.service.ts +++ b/src/api/v1/posts/posts.service.ts @@ -7,6 +7,7 @@ import { import { handleDBKnownErrors, fieldsToIncludeWithPost, + fieldsToIncludeWithComment, } from '../../../lib/helpers'; import { Prisma } from '../../../../prisma/generated/client'; import db from '../../../lib/db'; @@ -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'); diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 1df105b..8ccc9af 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -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') { @@ -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; @@ -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; @@ -222,7 +226,9 @@ export default { handleDBKnownErrors, findFilteredComments, fieldsToIncludeWithPost, + fieldsToIncludeWithVote, getTextFilterFromReqQuery, + fieldsToIncludeWithComment, getSignedInUserIdFromReqQuery, getVoteTypeFilterFromReqQuery, getCategoriesFilterFromReqQuery,