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']>;
|
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.
|
* Create a chainable mock for Drizzle DB operations.
|
||||||
* Configure chain terminals (limit, returning) to resolve to desired values.
|
* Use mockReturnValue with selectChain/insertChain/updateChain/deleteChain.
|
||||||
* Example: mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([user]) }) }) })
|
|
||||||
*/
|
*/
|
||||||
export function createMockDb(): MockDb {
|
export function createMockDb(): MockDb {
|
||||||
const chain = {
|
const chain = {
|
||||||
|
|||||||
Reference in New Issue
Block a user