test: add auth service tests

Made-with: Cursor
This commit is contained in:
Anton
2026-03-04 15:41:05 +03:00
parent aeb563d037
commit 144dcc60ec
2 changed files with 348 additions and 2 deletions

View 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();
});
});
});

View File

@@ -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 = {