import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GroupService } from '../src/services/group.service.js'; import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; import type { IGroupRepository, GroupWithMembers } from '../src/repositories/group.repository.js'; import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js'; import type { Group } from '@prisma/client'; function makeGroup(overrides: Partial = {}): Group { return { id: 'grp-1', name: 'developers', description: 'Dev team', version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function makeGroupWithMembers(overrides: Partial = {}, members: GroupWithMembers['members'] = []): GroupWithMembers { return { ...makeGroup(overrides), members, }; } function makeUser(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 mockGroupRepo(): IGroupRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async (data) => makeGroup({ name: data.name, description: data.description ?? '' })), update: vi.fn(async (id, data) => makeGroup({ id, description: data.description ?? '' })), delete: vi.fn(async () => {}), setMembers: vi.fn(async () => {}), findGroupsForUser: vi.fn(async () => []), }; } function mockUserRepo(): IUserRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByEmail: vi.fn(async () => null), create: vi.fn(async () => makeUser()), delete: vi.fn(async () => {}), count: vi.fn(async () => 0), }; } describe('GroupService', () => { let groupRepo: ReturnType; let userRepo: ReturnType; let service: GroupService; beforeEach(() => { groupRepo = mockGroupRepo(); userRepo = mockUserRepo(); service = new GroupService(groupRepo, userRepo); }); describe('list', () => { it('returns empty list', async () => { const result = await service.list(); expect(result).toEqual([]); expect(groupRepo.findAll).toHaveBeenCalled(); }); it('returns groups with members', async () => { const groups = [ makeGroupWithMembers({ id: 'g1', name: 'admins' }, [ { id: 'gm-1', user: { id: 'u1', email: 'a@b.com', name: 'A' } }, ]), ]; vi.mocked(groupRepo.findAll).mockResolvedValue(groups); const result = await service.list(); expect(result).toHaveLength(1); expect(result[0].members).toHaveLength(1); }); }); describe('create', () => { it('creates a group without members', async () => { const created = makeGroupWithMembers({ name: 'my-group', description: '' }, []); vi.mocked(groupRepo.findById).mockResolvedValue(created); const result = await service.create({ name: 'my-group' }); expect(result.name).toBe('my-group'); expect(groupRepo.create).toHaveBeenCalledWith({ name: 'my-group', description: '' }); expect(groupRepo.setMembers).not.toHaveBeenCalled(); }); it('creates a group with members', async () => { const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' }); const bob = makeUser({ id: 'u-bob', email: 'bob@example.com', name: 'Bob' }); vi.mocked(userRepo.findByEmail).mockImplementation(async (email: string) => { if (email === 'alice@example.com') return alice; if (email === 'bob@example.com') return bob; return null; }); const created = makeGroupWithMembers({ name: 'team' }, [ { id: 'gm-1', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } }, { id: 'gm-2', user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' } }, ]); vi.mocked(groupRepo.findById).mockResolvedValue(created); const result = await service.create({ name: 'team', members: ['alice@example.com', 'bob@example.com'], }); expect(groupRepo.setMembers).toHaveBeenCalledWith('grp-1', ['u-alice', 'u-bob']); expect(result.members).toHaveLength(2); }); it('throws ConflictError when name exists', async () => { vi.mocked(groupRepo.findByName).mockResolvedValue(makeGroupWithMembers({ name: 'taken' })); await expect(service.create({ name: 'taken' })).rejects.toThrow(ConflictError); }); it('throws NotFoundError for unknown member email', async () => { vi.mocked(userRepo.findByEmail).mockResolvedValue(null); await expect( service.create({ name: 'team', members: ['unknown@example.com'] }), ).rejects.toThrow(NotFoundError); }); it('validates input', async () => { await expect(service.create({ name: '' })).rejects.toThrow(); await expect(service.create({ name: 'UPPERCASE' })).rejects.toThrow(); }); }); describe('getById', () => { it('returns group when found', async () => { const group = makeGroupWithMembers({ id: 'g1' }); vi.mocked(groupRepo.findById).mockResolvedValue(group); const result = await service.getById('g1'); expect(result.id).toBe('g1'); }); it('throws NotFoundError when not found', async () => { await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); }); describe('getByName', () => { it('returns group when found', async () => { const group = makeGroupWithMembers({ name: 'admins' }); vi.mocked(groupRepo.findByName).mockResolvedValue(group); const result = await service.getByName('admins'); expect(result.name).toBe('admins'); }); it('throws NotFoundError when not found', async () => { await expect(service.getByName('missing')).rejects.toThrow(NotFoundError); }); }); describe('update', () => { it('updates description', async () => { const group = makeGroupWithMembers({ id: 'g1' }); vi.mocked(groupRepo.findById).mockResolvedValue(group); const updated = makeGroupWithMembers({ id: 'g1', description: 'new desc' }); // After update, getById is called again to return fresh data vi.mocked(groupRepo.findById).mockResolvedValue(updated); const result = await service.update('g1', { description: 'new desc' }); expect(groupRepo.update).toHaveBeenCalledWith('g1', { description: 'new desc' }); expect(result.description).toBe('new desc'); }); it('updates members (full replacement)', async () => { const group = makeGroupWithMembers({ id: 'g1' }, [ { id: 'gm-1', user: { id: 'u-old', email: 'old@example.com', name: 'Old' } }, ]); vi.mocked(groupRepo.findById).mockResolvedValue(group); const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' }); vi.mocked(userRepo.findByEmail).mockResolvedValue(alice); const updated = makeGroupWithMembers({ id: 'g1' }, [ { id: 'gm-2', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } }, ]); vi.mocked(groupRepo.findById).mockResolvedValueOnce(group).mockResolvedValue(updated); const result = await service.update('g1', { members: ['alice@example.com'] }); expect(groupRepo.setMembers).toHaveBeenCalledWith('g1', ['u-alice']); expect(result.members).toHaveLength(1); }); it('throws NotFoundError when group not found', async () => { await expect(service.update('missing', { description: 'x' })).rejects.toThrow(NotFoundError); }); it('throws NotFoundError for unknown member email on update', async () => { const group = makeGroupWithMembers({ id: 'g1' }); vi.mocked(groupRepo.findById).mockResolvedValue(group); vi.mocked(userRepo.findByEmail).mockResolvedValue(null); await expect( service.update('g1', { members: ['unknown@example.com'] }), ).rejects.toThrow(NotFoundError); }); }); describe('delete', () => { it('deletes group', async () => { const group = makeGroupWithMembers({ id: 'g1' }); vi.mocked(groupRepo.findById).mockResolvedValue(group); await service.delete('g1'); expect(groupRepo.delete).toHaveBeenCalledWith('g1'); }); it('throws NotFoundError when group not found', async () => { await expect(service.delete('missing')).rejects.toThrow(NotFoundError); }); }); describe('group includes resolved member info', () => { it('members include user id, email, and name', async () => { const group = makeGroupWithMembers({ id: 'g1', name: 'team' }, [ { id: 'gm-1', user: { id: 'u1', email: 'alice@example.com', name: 'Alice' } }, { id: 'gm-2', user: { id: 'u2', email: 'bob@example.com', name: null } }, ]); vi.mocked(groupRepo.findById).mockResolvedValue(group); const result = await service.getById('g1'); expect(result.members[0].user).toEqual({ id: 'u1', email: 'alice@example.com', name: 'Alice' }); expect(result.members[1].user).toEqual({ id: 'u2', email: 'bob@example.com', name: null }); }); }); });