test: add auth service tests
Made-with: Cursor
This commit is contained in:
304
tests/services/auth.service.test.ts
Normal file
304
tests/services/auth.service.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AuthService } from '../../src/services/auth/auth.service.js';
|
||||
import {
|
||||
createMockDb,
|
||||
selectChain,
|
||||
insertChain,
|
||||
updateChain,
|
||||
deleteChain,
|
||||
} from '../test-utils.js';
|
||||
|
||||
vi.mock('../../src/utils/password.js', () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue('hashed-password'),
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/jwt.js', () => ({
|
||||
signAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
signRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
|
||||
verifyToken: vi.fn(),
|
||||
isRefreshPayload: vi.fn(),
|
||||
hashToken: vi.fn((t: string) => `hash-${t}`),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomBytes: vi.fn(() => ({
|
||||
toString: () => 'abc123',
|
||||
})),
|
||||
randomUUID: vi.fn(() => 'uuid-session-1'),
|
||||
}));
|
||||
|
||||
import { hashPassword, verifyPassword } from '../../src/utils/password.js';
|
||||
import { signAccessToken, signRefreshToken, verifyToken, isRefreshPayload } from '../../src/utils/jwt.js';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let mockDb: ReturnType<typeof createMockDb>;
|
||||
let authService: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb = createMockDb();
|
||||
authService = new AuthService(mockDb as never);
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('registers a new user when email and nickname are available', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([]))
|
||||
.mockReturnValueOnce(selectChain([]));
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(insertChain([{ id: 'user-123' }]))
|
||||
.mockReturnValueOnce(insertChain([]));
|
||||
|
||||
const result = await authService.register({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
nickname: 'tester',
|
||||
});
|
||||
|
||||
expect(result.userId).toBe('user-123');
|
||||
expect(result.verificationCode).toBeDefined();
|
||||
expect(hashPassword).toHaveBeenCalledWith('password123');
|
||||
});
|
||||
|
||||
it('throws when email is already taken', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'existing', email: 'test@example.com' }])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.register({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
nickname: 'tester',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EMAIL_TAKEN' });
|
||||
});
|
||||
|
||||
it('throws when nickname is already taken', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 'existing', nickname: 'tester' }]));
|
||||
|
||||
await expect(
|
||||
authService.register({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
nickname: 'tester',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NICKNAME_TAKEN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns tokens when credentials are valid', async () => {
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([mockUser])
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const result = await authService.login({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('access-token');
|
||||
expect(result.refreshToken).toBe('refresh-token');
|
||||
expect(verifyPassword).toHaveBeenCalledWith('hashed', 'password123');
|
||||
expect(signAccessToken).toHaveBeenCalled();
|
||||
expect(signRefreshToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when user not found', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.login({ email: 'nonexistent@example.com', password: 'x' })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('throws when password is wrong', async () => {
|
||||
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'user-1', email: 'test@example.com', passwordHash: 'hashed' }])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.login({ email: 'test@example.com', password: 'wrong' })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('deletes session by refresh token hash', async () => {
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
await authService.logout('some-refresh-token');
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('returns new tokens when refresh token is valid', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
sid: 'sid-1',
|
||||
type: 'refresh',
|
||||
} as never);
|
||||
vi.mocked(isRefreshPayload).mockReturnValueOnce(true);
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'sess-1', userId: 'user-1' }])
|
||||
);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'user-1', email: 'test@example.com' }])
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const result = await authService.refresh({
|
||||
refreshToken: 'valid-refresh-token',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('access-token');
|
||||
expect(result.refreshToken).toBe('refresh-token');
|
||||
});
|
||||
|
||||
it('throws when token is not a refresh payload', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
type: 'access',
|
||||
} as never);
|
||||
vi.mocked(isRefreshPayload).mockReturnValueOnce(false);
|
||||
|
||||
await expect(
|
||||
authService.refresh({ refreshToken: 'access-token' })
|
||||
).rejects.toMatchObject({ message: expect.stringContaining('Invalid refresh token') });
|
||||
});
|
||||
|
||||
it('throws when session not found', async () => {
|
||||
vi.mocked(verifyToken).mockResolvedValueOnce({
|
||||
sub: 'user-1',
|
||||
sid: 'sid-1',
|
||||
type: 'refresh',
|
||||
} as never);
|
||||
vi.mocked(isRefreshPayload).mockReturnValueOnce(true);
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.refresh({ refreshToken: 'invalid-or-expired' })
|
||||
).rejects.toMatchObject({ code: 'INVALID_REFRESH_TOKEN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmail', () => {
|
||||
it('throws when verification code is invalid', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.verifyEmail('user-1', 'WRONG')
|
||||
).rejects.toMatchObject({ code: 'INVALID_CODE' });
|
||||
});
|
||||
|
||||
it('updates user and deletes code when valid', async () => {
|
||||
const mockCode = {
|
||||
id: 'code-1',
|
||||
userId: 'user-1',
|
||||
code: 'ABC123',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
};
|
||||
(mockDb.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce(selectChain([mockCode]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 'user-1', emailVerifiedAt: null }]));
|
||||
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
updateChain([{ id: 'user-1' }])
|
||||
);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
await authService.verifyEmail('user-1', 'ABC123');
|
||||
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgotPassword', () => {
|
||||
it('returns empty token when user not found', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
const result = await authService.forgotPassword('unknown@example.com');
|
||||
|
||||
expect(result.token).toBe('');
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns token when user exists', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([{ id: 'user-1', email: 'test@example.com' }])
|
||||
);
|
||||
(mockDb.insert as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
insertChain([])
|
||||
);
|
||||
|
||||
const result = await authService.forgotPassword('test@example.com');
|
||||
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token).not.toBe('');
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('throws when token is invalid', async () => {
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([])
|
||||
);
|
||||
|
||||
await expect(
|
||||
authService.resetPassword('invalid-token', 'newPassword123')
|
||||
).rejects.toMatchObject({ code: 'INVALID_RESET_TOKEN' });
|
||||
});
|
||||
|
||||
it('updates password when token is valid', async () => {
|
||||
const mockRecord = { id: 'rec-1', userId: 'user-1', expiresAt: new Date(Date.now() + 60000) };
|
||||
(mockDb.select as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
selectChain([mockRecord])
|
||||
);
|
||||
(mockDb.update as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
updateChain([{ id: 'user-1' }])
|
||||
);
|
||||
(mockDb.delete as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
deleteChain()
|
||||
);
|
||||
|
||||
await authService.resetPassword('valid-token', 'newPassword123');
|
||||
|
||||
expect(hashPassword).toHaveBeenCalledWith('newPassword123');
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,52 @@ export type MockDb = {
|
||||
delete: ReturnType<NodePgDatabase<typeof schema>['delete']>;
|
||||
};
|
||||
|
||||
/** Build a select chain that resolves to the given rows at .limit(n) */
|
||||
export function selectChain(resolveAtLimit: unknown[] = []) {
|
||||
const limitFn = vi.fn().mockResolvedValue(resolveAtLimit);
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: limitFn,
|
||||
orderBy: vi.fn().mockReturnValue({ limit: limitFn }),
|
||||
}),
|
||||
limit: limitFn,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an insert chain that resolves at .returning() or .values() */
|
||||
export function insertChain(resolveAtReturning: unknown[] = []) {
|
||||
const returningFn = vi.fn().mockResolvedValue(resolveAtReturning);
|
||||
const chainFromValues = {
|
||||
returning: returningFn,
|
||||
then: (resolve: (v?: unknown) => void) => resolve(undefined),
|
||||
};
|
||||
return {
|
||||
values: vi.fn().mockReturnValue(chainFromValues),
|
||||
returning: returningFn,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an update chain that resolves at .where() */
|
||||
export function updateChain(resolveAtWhere: unknown[] = []) {
|
||||
return {
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(resolveAtWhere),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a delete chain that resolves at .where() */
|
||||
export function deleteChain() {
|
||||
return {
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a chainable mock for Drizzle DB operations.
|
||||
* Configure chain terminals (limit, returning) to resolve to desired values.
|
||||
* Example: mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([user]) }) }) })
|
||||
* Use mockReturnValue with selectChain/insertChain/updateChain/deleteChain.
|
||||
*/
|
||||
export function createMockDb(): MockDb {
|
||||
const chain = {
|
||||
|
||||
Reference in New Issue
Block a user