diff --git a/src/auth/handler.ts b/src/auth/handler.ts index 8ac6948..47347bc 100644 --- a/src/auth/handler.ts +++ b/src/auth/handler.ts @@ -1,10 +1,14 @@ import type { Request, Response } from 'express' import { accessTokenMaxAge, refreshTokenMaxAge } from '../lib/token' import type ApiResponse from '../schema' -import type { Login } from './schema' +import type { Login, RegisterUser } from './schema' import type { User } from './service' interface AuthService { + registerUser(data: RegisterUser): Promise<{ + response: ApiResponse> + token?: { accessToken: string; refreshToken: string } + }> login(data: Login): Promise<{ response: ApiResponse> token?: { accessToken: string; refreshToken: string } @@ -18,6 +22,41 @@ interface AuthService { class AuthHandler { constructor(private service: AuthService) {} + registerUser = async ( + req: Request< + Record, + Record, + RegisterUser + >, + res: Response>>, + ) => { + const result = await this.service.registerUser({ + fullname: req.body.fullname, + password: req.body.password, + email: req.body.email, + }) + if (result.response.status === 'fail') { + return res.status(result.response.errors.code).json(result.response) + } + + return res + .status(201) + .cookie('accessToken', result.token?.accessToken, { + secure: true, + sameSite: 'none', + httpOnly: true, + maxAge: accessTokenMaxAge, + }) + .cookie('refreshToken', result.token?.refreshToken, { + secure: true, + sameSite: 'none', + httpOnly: true, + path: '/api/refresh', + maxAge: refreshTokenMaxAge, + }) + .json(result.response) + } + login = async ( req: Request, Record, Login>, res: Response, diff --git a/src/auth/repository.ts b/src/auth/repository.ts index a43d0b2..6662bce 100644 --- a/src/auth/repository.ts +++ b/src/auth/repository.ts @@ -1,5 +1,10 @@ import type { Pool, ResultSetHeader, RowDataPacket } from 'mysql2/promise' -import { AuthCredentialError } from '../lib/Error' +import { + AuthCredentialError, + EmailAlreadyExistsError, + ServerError, +} from '../lib/Error' +import type { RegisterUser } from './schema' type User = { email: string @@ -11,6 +16,31 @@ type User = { class AuthRepository { constructor(private db: Pool) {} + private USER_ALREADY_EXISTS = 1062 + private ER_BAD_DB_ERROR = 'ER_BAD_DB_ERROR' + + async createUser(data: RegisterUser) { + try { + const [rows] = await this.db.execute( + 'INSERT INTO users (fullname, email, password) VALUES (?,?,?)', + [data.fullname, data.email, data.password], + ) + const result = rows as ResultSetHeader + + return { userId: result.insertId } + } catch (error: any) { + if (error.errno === this.USER_ALREADY_EXISTS) { + throw new EmailAlreadyExistsError() + } + if (error.code && error.code === this.ER_BAD_DB_ERROR) { + throw new ServerError( + "error while creating the account, this is not your fault, we're working on it. please try again later", + ) + } + throw new Error(error) + } + } + async getUserByEmail(email: string) { const [rows] = await this.db.query( 'SELECT id, email, fullname, password from users WHERE email = ?', @@ -30,13 +60,32 @@ class AuthRepository { } } + async getUserById( + id: number, + ): Promise<{ id: number; fullname: string; email: string }> { + const [rows] = await this.db.query( + 'SELECT id, fullname, email FROM users WHERE id = ?', + [id], + ) + + if (!rows.length) { + throw new Error('user not found') + } + + return { + id: rows[0]?.id, + fullname: rows[0]?.fullname, + email: rows[0]?.email, + } + } + async saveTokenToDb(token: string, userId: number) { const [rows] = await this.db.execute( 'UPDATE users SET refresh_token = ? WHERE id = ?', [token, userId], ) - - return rows as ResultSetHeader + const result = rows as ResultSetHeader + return { affectedRows: result.affectedRows } } async getTokenByUserId(userId: number) { diff --git a/src/auth/schema.ts b/src/auth/schema.ts index b3f3c56..3b048c8 100644 --- a/src/auth/schema.ts +++ b/src/auth/schema.ts @@ -10,4 +10,25 @@ export const loginSchema = z.object({ .min(1, 'password is required'), }) +export const registerUser = z + .object({ + fullname: z.string().min(1, 'fullname should have minimum 1 characters'), + email: z + .string() + .min(1, 'email is required') + .email('your email is in invalid format'), + password: z.string().min(1, 'password is required'), + passwordConfirmation: z + .string({ required_error: 'password confirmation is required' }) + .min(1, 'password confirmation is required'), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: 'password and password confirmation is not match', + path: ['password'], + }) + +export type RegisterUser = Omit< + z.TypeOf, + 'passwordConfirmation' +> export type Login = z.TypeOf diff --git a/src/auth/service.test.ts b/src/auth/service.test.ts index c2cf5ae..38f3f9d 100644 --- a/src/auth/service.test.ts +++ b/src/auth/service.test.ts @@ -1,5 +1,5 @@ import { hashSync } from 'bcrypt' -import { AuthCredentialError } from '../lib/Error' +import { AuthCredentialError, EmailAlreadyExistsError } from '../lib/Error' import { createNewToken, refreshTokenMaxAge, verifyToken } from '../lib/token' import type AuthRepository from './repository' import AuthService from './service' @@ -15,6 +15,8 @@ describe('auth service', () => { beforeEach(() => { authRepo = { + createUser: jest.fn(), + getUserById: jest.fn(), getUserByEmail: jest.fn(), saveTokenToDb: jest.fn(), getTokenByUserId: jest.fn(), @@ -23,6 +25,52 @@ describe('auth service', () => { authService = new AuthService(authRepo) }) + describe('register user', () => { + it('should register new user', async () => { + authRepo.createUser.mockResolvedValue({ userId: 1 }) + authRepo.saveTokenToDb.mockResolvedValue({ affectedRows: 1 }) + authRepo.getUserById.mockResolvedValue({ + id: 1, + fullname: FULLNAME, + email: VALID_EMAIL, + }) + const newUser = await authService.registerUser({ + fullname: FULLNAME, + email: VALID_EMAIL, + password: 'password', + }) + + expect(authRepo.createUser).toHaveBeenCalled() + expect(newUser.response.status).toBe('success') + expect(newUser).toHaveProperty('token') + expect(newUser.response).toEqual({ + status: 'success', + data: { + user: { id: 1, email: VALID_EMAIL, fullname: FULLNAME }, + }, + }) + expect(newUser.token?.accessToken).not.toBeNull() + expect(newUser.token?.refreshToken).not.toBeNull() + }) + + it('should fail: email already exists', async () => { + authRepo.createUser.mockRejectedValue(new EmailAlreadyExistsError()) + const newUser = await authService.registerUser({ + email: 'already_exist_email', + password: VALID_PASSWORD, + fullname: FULLNAME, + }) + + expect(authRepo.createUser).toHaveBeenCalled() + expect(newUser.response.status).toBe('fail') + if (newUser.response.status === 'fail') { + expect(newUser.response.errors.message).toBe( + 'this email already exists', + ) + } + }) + }) + describe('login', () => { it('should fail caused none existent user', async () => { authRepo.getUserByEmail.mockRejectedValue(new AuthCredentialError()) diff --git a/src/auth/service.ts b/src/auth/service.ts index b84461c..59dcb53 100644 --- a/src/auth/service.ts +++ b/src/auth/service.ts @@ -1,7 +1,7 @@ import { Auth } from '../lib/Auth' import { AuthCredentialError } from '../lib/Error' import type ApiResponse from '../schema' -import type { Login } from './schema' +import type { Login, RegisterUser } from './schema' export interface User { id: number @@ -11,7 +11,9 @@ export interface User { } interface AuthRepository { + createUser(data: RegisterUser): Promise<{ userId: number }> getUserByEmail(email: string): Promise> + getUserById(id: number): Promise> saveTokenToDb( token: string, userId: number, @@ -24,6 +26,59 @@ class AuthService extends Auth { super() } + async registerUser(data: RegisterUser): Promise<{ + response: ApiResponse> + token?: { accessToken: string; refreshToken: string } + }> { + try { + const newUser = await this.repository.createUser({ + fullname: data.fullname, + email: data.email, + password: await this.hashPassword(data.password), + }) + + const user = await this.repository.getUserById(newUser.userId) + if (!user.email || !user.fullname) { + throw new Error('create user is fail, please try again') + } + + const accessToken = this.createAccessToken({ + fullname: user.fullname, + email: user.email, + userId: newUser.userId, + }) + const refreshToken = this.createRefreshToken({ + fullname: user.fullname, + email: user.email, + userId: newUser.userId, + }) + await this.repository.saveTokenToDb(refreshToken, newUser.userId) + return { + response: { + status: 'success', + data: { + user: { + id: newUser.userId, + fullname: user.fullname, + email: user.email, + }, + }, + }, + token: { accessToken, refreshToken }, + } + } catch (error: any) { + return { + response: { + status: 'fail', + errors: { + code: typeof error.code === 'number' ? error.code : 400, + message: error.message || error, + }, + }, + } + } + } + async login(data: Login): Promise<{ response: ApiResponse> token?: { accessToken: string; refreshToken: string } diff --git a/src/routes.ts b/src/routes.ts index 912861d..f4d66d5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -17,7 +17,6 @@ import ProductRepository from './product/repository' import { createProduct, updateProduct } from './product/schema' import ProductService from './product/service' import UserHandler from './user/handler' -import UserRepository from './user/repository' import UserSerivce from './user/service' export default function routes(app: Express) { @@ -25,8 +24,7 @@ export default function routes(app: Express) { const authService = new AuthService(authRepo) const authHandler = new AuthHandler(authService) - const userRepo = new UserRepository(connection) - const userService = new UserSerivce(userRepo) + const userService = new UserSerivce(authRepo) const userHandler = new UserHandler(userService) const productRepo = new ProductRepository(connection) @@ -38,7 +36,7 @@ export default function routes(app: Express) { '/api/register', //@ts-ignore validateInput(createUserSchema), - userHandler.registerUser, + authHandler.registerUser, ) app.post('/api/login', validateInput(loginSchema), authHandler.login) app.get('/api/refresh', authHandler.refreshToken) diff --git a/src/user/handler.ts b/src/user/handler.ts index c738cda..4b1bbf5 100644 --- a/src/user/handler.ts +++ b/src/user/handler.ts @@ -1,50 +1,14 @@ import type { Request, Response } from 'express' -import { accessTokenMaxAge, refreshTokenMaxAge } from '../lib/token' import type ApiResponse from '../schema' -import type { CreateUser, User } from './schema' +import type { User } from './schema' interface UserService { - registerUser(data: CreateUser): Promise<{ - response: ApiResponse - token?: { accessToken: string; refreshToken: string } - }> getUserById(idFromUrlPath: string): Promise<{ response: ApiResponse }> } class UserHandler { constructor(public service: UserService) {} - registerUser = async ( - req: Request, Record, CreateUser>, - res: Response>, - ) => { - const result = await this.service.registerUser({ - fullname: req.body.fullname, - password: req.body.password, - email: req.body.email, - }) - if (result.response.status === 'fail') { - return res.status(result.response.errors.code).json(result.response) - } - - return res - .status(201) - .cookie('accessToken', result.token?.accessToken, { - secure: true, - sameSite: 'none', - httpOnly: true, - maxAge: accessTokenMaxAge, - }) - .cookie('refreshToken', result.token?.refreshToken, { - secure: true, - sameSite: 'none', - httpOnly: true, - path: '/api/refresh', - maxAge: refreshTokenMaxAge, - }) - .json(result.response) - } - getUserById = async ( req: Request<{ id: string }>, res: Response>, diff --git a/src/user/repository.ts b/src/user/repository.ts index 2bce635..cdbd1b9 100644 --- a/src/user/repository.ts +++ b/src/user/repository.ts @@ -1,34 +1,8 @@ -import type { Pool, ResultSetHeader, RowDataPacket } from 'mysql2/promise' -import { EmailAlreadyExistsError, ServerError } from '../lib/Error' -import type { CreateUser } from './schema' +import type { Pool, RowDataPacket } from 'mysql2/promise' class UserRepository { - private USER_ALREADY_EXISTS = 1062 - private ER_BAD_DB_ERROR = 'ER_BAD_DB_ERROR' constructor(private db: Pool) {} - async createUser(data: CreateUser) { - try { - const [rows] = await this.db.execute( - 'INSERT INTO users (fullname, email, password) VALUES (?,?,?)', - [data.fullname, data.email, data.password], - ) - const result = rows as ResultSetHeader - - return { userId: result.insertId } - } catch (error: any) { - if (error.errno === this.USER_ALREADY_EXISTS) { - throw new EmailAlreadyExistsError() - } - if (error.code && error.code === this.ER_BAD_DB_ERROR) { - throw new ServerError( - "error while creating the account, this is not your fault, we're working on it. please try again later", - ) - } - throw new Error(error) - } - } - async getUserById( id: number, ): Promise<{ id: number; fullname: string; email: string }> { @@ -47,15 +21,6 @@ class UserRepository { email: rows[0]?.email, } } - - async saveTokenToDb(refreshToken: string, userId: number) { - const [rows] = await this.db.execute( - 'UPDATE users SET refresh_token = ? WHERE id = ?', - [refreshToken, userId], - ) - const result = rows as ResultSetHeader - return { affectedRows: result.affectedRows } - } } export default UserRepository diff --git a/src/user/service.test.ts b/src/user/service.test.ts index e7e1726..0521e61 100644 --- a/src/user/service.test.ts +++ b/src/user/service.test.ts @@ -1,4 +1,3 @@ -import { EmailAlreadyExistsError } from '../lib/Error' import type UserRepository from './repository' import UserSerivce from './service' @@ -6,7 +5,6 @@ describe('user service', () => { let userRepo: jest.Mocked let userService: UserSerivce const VALID_EMAIL = 'testing@email.com' - const VALID_PASSWORD = 'password' const FULLNAME = 'testing' beforeEach(() => { @@ -14,58 +12,11 @@ describe('user service', () => { USER_ALREADY_EXISTS: 1062, createUser: jest.fn(), getUserById: jest.fn(), - saveTokenToDb: jest.fn(), } as unknown as jest.Mocked userService = new UserSerivce(userRepo) }) - describe('register user', () => { - it('should register new user', async () => { - userRepo.createUser.mockResolvedValue({ userId: 1 }) - userRepo.saveTokenToDb.mockResolvedValue({ affectedRows: 1 }) - userRepo.getUserById.mockResolvedValue({ - id: 1, - fullname: FULLNAME, - email: VALID_EMAIL, - }) - const newUser = await userService.registerUser({ - fullname: FULLNAME, - email: VALID_EMAIL, - password: 'password', - }) - - expect(userRepo.createUser).toHaveBeenCalled() - expect(newUser.response.status).toBe('success') - expect(newUser).toHaveProperty('token') - expect(newUser.response).toEqual({ - status: 'success', - data: { - user: { id: 1, email: VALID_EMAIL, fullname: FULLNAME }, - }, - }) - expect(newUser.token?.accessToken).not.toBeNull() - expect(newUser.token?.refreshToken).not.toBeNull() - }) - - it('should fail: email already exists', async () => { - userRepo.createUser.mockRejectedValue(new EmailAlreadyExistsError()) - const newUser = await userService.registerUser({ - email: 'already_exist_email', - password: VALID_PASSWORD, - fullname: FULLNAME, - }) - - expect(userRepo.createUser).toHaveBeenCalled() - expect(newUser.response.status).toBe('fail') - if (newUser.response.status === 'fail') { - expect(newUser.response.errors.message).toBe( - 'this email already exists', - ) - } - }) - }) - describe('get user by id', () => { it('should fail: user not found cause bad user id', async () => { const user = await userService.getUserById('bad_user_id') diff --git a/src/user/service.ts b/src/user/service.ts index 5deb9dd..2398e96 100644 --- a/src/user/service.ts +++ b/src/user/service.ts @@ -3,12 +3,7 @@ import type ApiResponse from '../schema' import type { CreateUser, User } from './schema' interface UserRepository { - createUser(data: CreateUser): Promise<{ userId: number }> getUserById(id: number): Promise> - saveTokenToDb( - refreshToken: string, - userId: number, - ): Promise<{ affectedRows: number }> } class UserSerivce extends Auth { @@ -16,61 +11,6 @@ class UserSerivce extends Auth { super() } - public registerUser = async ( - data: CreateUser, - ): Promise<{ - response: ApiResponse - token?: { accessToken: string; refreshToken: string } - }> => { - try { - const newUser = await this.repo.createUser({ - fullname: data.fullname, - email: data.email, - password: await this.hashPassword(data.password), - }) - - const user = await this.repo.getUserById(newUser.userId) - if (!user.email || !user.fullname) { - throw new Error('create user is fail, please try again') - } - - const accessToken = this.createAccessToken({ - fullname: user.fullname, - email: user.email, - userId: newUser.userId, - }) - const refreshToken = this.createRefreshToken({ - fullname: user.fullname, - email: user.email, - userId: newUser.userId, - }) - await this.repo.saveTokenToDb(refreshToken, newUser.userId) - return { - response: { - status: 'success', - data: { - user: { - id: newUser.userId, - fullname: user.fullname, - email: user.email, - }, - }, - }, - token: { accessToken, refreshToken }, - } - } catch (error: any) { - return { - response: { - status: 'fail', - errors: { - code: typeof error.code === 'number' ? error.code : 400, - message: error.message || error, - }, - }, - } - } - } - public getUserById = async ( idFromUrlPath: string, ): Promise<{ response: ApiResponse }> => {