import { describe, it, expect, vi, beforeEach } from 'vitest'; import { UserService } from '../src/services/user.service.js'; import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js'; function makeSafeUser(overrides: Partial = {}): SafeUser { return { id: 'user-1', email: 'alice@example.com', name: 'Alice', role: 'USER', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function mockUserRepo(): IUserRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByEmail: vi.fn(async () => null), create: vi.fn(async (data) => makeSafeUser({ email: data.email, name: data.name ?? null }), ), delete: vi.fn(async () => {}), count: vi.fn(async () => 0), }; } describe('UserService', () => { let repo: ReturnType; let service: UserService; beforeEach(() => { repo = mockUserRepo(); service = new UserService(repo); }); // ── list ────────────────────────────────────────────────── describe('list', () => { it('returns empty array when no users', async () => { const result = await service.list(); expect(result).toEqual([]); expect(repo.findAll).toHaveBeenCalledOnce(); }); it('returns all users', async () => { const users = [ makeSafeUser({ id: 'u1', email: 'a@b.com' }), makeSafeUser({ id: 'u2', email: 'c@d.com' }), ]; vi.mocked(repo.findAll).mockResolvedValue(users); const result = await service.list(); expect(result).toHaveLength(2); expect(result[0]!.email).toBe('a@b.com'); }); }); // ── create ──────────────────────────────────────────────── describe('create', () => { it('creates a user and hashes password', async () => { const result = await service.create({ email: 'alice@example.com', password: 'securePass123', }); expect(result.email).toBe('alice@example.com'); expect(repo.create).toHaveBeenCalledOnce(); // Verify the passwordHash was generated (not the plain password) const createCall = vi.mocked(repo.create).mock.calls[0]![0]!; expect(createCall.passwordHash).toBeDefined(); expect(createCall.passwordHash).not.toBe('securePass123'); expect(createCall.passwordHash.startsWith('$2b$')).toBe(true); }); it('creates a user with optional name', async () => { await service.create({ email: 'bob@example.com', password: 'securePass123', name: 'Bob', }); const createCall = vi.mocked(repo.create).mock.calls[0]![0]!; expect(createCall.email).toBe('bob@example.com'); expect(createCall.name).toBe('Bob'); }); it('returns user without passwordHash', async () => { const result = await service.create({ email: 'alice@example.com', password: 'securePass123', }); // SafeUser type should not have passwordHash expect(result).not.toHaveProperty('passwordHash'); }); it('throws ConflictError when email already exists', async () => { vi.mocked(repo.findByEmail).mockResolvedValue(makeSafeUser()); await expect( service.create({ email: 'alice@example.com', password: 'securePass123' }), ).rejects.toThrow(ConflictError); }); it('throws ZodError for invalid email', async () => { await expect( service.create({ email: 'not-an-email', password: 'securePass123' }), ).rejects.toThrow(); }); it('throws ZodError for short password', async () => { await expect( service.create({ email: 'a@b.com', password: 'short' }), ).rejects.toThrow(); }); it('throws ZodError for missing email', async () => { await expect( service.create({ password: 'securePass123' }), ).rejects.toThrow(); }); it('throws ZodError for password exceeding max length', async () => { await expect( service.create({ email: 'a@b.com', password: 'x'.repeat(129) }), ).rejects.toThrow(); }); }); // ── getById ─────────────────────────────────────────────── describe('getById', () => { it('returns user when found', async () => { const user = makeSafeUser(); vi.mocked(repo.findById).mockResolvedValue(user); const result = await service.getById('user-1'); expect(result.email).toBe('alice@example.com'); expect(repo.findById).toHaveBeenCalledWith('user-1'); }); it('throws NotFoundError when not found', async () => { await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); }); // ── getByEmail ──────────────────────────────────────────── describe('getByEmail', () => { it('returns user when found', async () => { const user = makeSafeUser(); vi.mocked(repo.findByEmail).mockResolvedValue(user); const result = await service.getByEmail('alice@example.com'); expect(result.email).toBe('alice@example.com'); expect(repo.findByEmail).toHaveBeenCalledWith('alice@example.com'); }); it('throws NotFoundError when not found', async () => { await expect(service.getByEmail('nobody@example.com')).rejects.toThrow(NotFoundError); }); }); // ── delete ──────────────────────────────────────────────── describe('delete', () => { it('deletes user by id', async () => { vi.mocked(repo.findById).mockResolvedValue(makeSafeUser()); await service.delete('user-1'); expect(repo.delete).toHaveBeenCalledWith('user-1'); }); it('throws NotFoundError when user does not exist', async () => { await expect(service.delete('missing')).rejects.toThrow(NotFoundError); }); }); // ── count ───────────────────────────────────────────────── describe('count', () => { it('returns 0 when no users', async () => { const result = await service.count(); expect(result).toBe(0); }); it('returns 1 when one user exists', async () => { vi.mocked(repo.count).mockResolvedValue(1); const result = await service.count(); expect(result).toBe(1); }); it('returns correct count for multiple users', async () => { vi.mocked(repo.count).mockResolvedValue(5); const result = await service.count(); expect(result).toBe(5); }); }); });