Skip to content

Commit

Permalink
Merge pull request #23 from zulfikarrosadi/fix/register
Browse files Browse the repository at this point in the history
Fix/register
  • Loading branch information
zulfikarrosadi authored Sep 23, 2024
2 parents 0bdc0f6 + ce27596 commit b8b5003
Show file tree
Hide file tree
Showing 10 changed files with 222 additions and 192 deletions.
41 changes: 40 additions & 1 deletion src/auth/handler.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<User, 'password'>>
token?: { accessToken: string; refreshToken: string }
}>
login(data: Login): Promise<{
response: ApiResponse<Omit<User, 'password'>>
token?: { accessToken: string; refreshToken: string }
Expand All @@ -18,6 +22,41 @@ interface AuthService {
class AuthHandler {
constructor(private service: AuthService) {}

registerUser = async (
req: Request<
Record<string, unknown>,
Record<string, unknown>,
RegisterUser
>,
res: Response<ApiResponse<Omit<User, 'password'>>>,
) => {
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<string, unknown>, Record<string, unknown>, Login>,
res: Response,
Expand Down
55 changes: 52 additions & 3 deletions src/auth/repository.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<RowDataPacket[]>(
'SELECT id, email, fullname, password from users WHERE email = ?',
Expand All @@ -30,13 +60,32 @@ class AuthRepository {
}
}

async getUserById(
id: number,
): Promise<{ id: number; fullname: string; email: string }> {
const [rows] = await this.db.query<RowDataPacket[]>(
'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) {
Expand Down
21 changes: 21 additions & 0 deletions src/auth/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof registerUser>,
'passwordConfirmation'
>
export type Login = z.TypeOf<typeof loginSchema>
50 changes: 49 additions & 1 deletion src/auth/service.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,6 +15,8 @@ describe('auth service', () => {

beforeEach(() => {
authRepo = {
createUser: jest.fn(),
getUserById: jest.fn(),
getUserByEmail: jest.fn(),
saveTokenToDb: jest.fn(),
getTokenByUserId: jest.fn(),
Expand All @@ -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())
Expand Down
57 changes: 56 additions & 1 deletion src/auth/service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,7 +11,9 @@ export interface User {
}

interface AuthRepository {
createUser(data: RegisterUser): Promise<{ userId: number }>
getUserByEmail(email: string): Promise<Partial<User>>
getUserById(id: number): Promise<Omit<User, 'password'>>
saveTokenToDb(
token: string,
userId: number,
Expand All @@ -24,6 +26,59 @@ class AuthService extends Auth {
super()
}

async registerUser(data: RegisterUser): Promise<{
response: ApiResponse<Omit<User, 'password'>>
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<Omit<User, 'password'>>
token?: { accessToken: string; refreshToken: string }
Expand Down
6 changes: 2 additions & 4 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,14 @@ 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) {
const authRepo = new AuthRepository(connection)
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)
Expand All @@ -38,7 +36,7 @@ export default function routes(app: Express) {
'/api/register',
//@ts-ignore
validateInput(createUserSchema),
userHandler.registerUser,
authHandler.registerUser,
)
app.post('/api/#', validateInput(loginSchema), authHandler.login)
app.get('/api/refresh', authHandler.refreshToken)
Expand Down
38 changes: 1 addition & 37 deletions src/user/handler.ts
Original file line number Diff line number Diff line change
@@ -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<User>
token?: { accessToken: string; refreshToken: string }
}>
getUserById(idFromUrlPath: string): Promise<{ response: ApiResponse<User> }>
}

class UserHandler {
constructor(public service: UserService) {}

registerUser = async (
req: Request<Record<string, unknown>, Record<string, unknown>, CreateUser>,
res: Response<ApiResponse<User>>,
) => {
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<ApiResponse<User>>,
Expand Down
Loading

0 comments on commit b8b5003

Please # to comment.