import { ConflictException, ForbiddenException, NotFoundException, } from '@nestjs/common'; import { Role } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { TeamerService } from './teamer.service'; import { AuthenticatedUser } from '../auth/authenticated-request'; /// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite /// branching. Prisma + SyncService faked in memory. const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() }; function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser { return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships }; } const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]); const VERANTW_GEM1 = caller([ { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER }, ]); const VERANTW_GEM2 = caller([ { kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER }, ]); function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) { const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde; const emails = new Set(opts.existingEmails ?? []); const created: Record = {}; const prisma = { gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) }, kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) }, user: { findUnique: jest.fn(({ where }: { where: { email: string } }) => Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null), ), create: jest.fn(({ data }: { data: Record }) => { created.user = { id: 'u-1', createdAt: new Date(), ...data }; return Promise.resolve(created.user); }), delete: jest.fn().mockResolvedValue({ id: 'u-1' }), }, membership: { create: jest.fn(({ data }: { data: Record }) => { created.membership = { id: 'm-1', ...data }; return Promise.resolve(created.membership); }), findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), }, teamerInvite: { create: jest.fn(({ data }: { data: Record }) => Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }), ), }, }; const sync = { capture: jest.fn().mockResolvedValue(undefined) }; const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) }; const service = new TeamerService(prisma as never, sync as never, mail as never); return { service, prisma, sync, mail, created }; } describe('TeamerService scope check', () => { it('404s when the Gemeinde does not exist', async () => { const { service } = makeService({ gemeinde: null }); await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException); }); it('lets the Leitungsteam manage any Gemeinde', async () => { const { service, prisma } = makeService(); await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]); }); it('lets a Verantwortliche/r manage their own Gemeinde', async () => { const { service, prisma } = makeService(); await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]); }); it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => { const { service } = makeService(); await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf( ForbiddenException, ); }); }); describe('TeamerService.createTeamer', () => { it('rejects a duplicate email', async () => { const { service } = makeService({ existingEmails: ['dup@example.org'] }); await expect( service.createTeamer(VERANTW_GEM1, 'gem-1', { firstName: 'A', lastName: 'B', email: 'dup@example.org', password: 'password1', }), ).rejects.toBeInstanceOf(ConflictException); }); it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => { const { service, created, sync } = makeService(); const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', { firstName: 'Ada', lastName: 'Lo', email: 'Ada@Example.org', password: 'password1', }); expect(res).not.toHaveProperty('passwordHash'); expect(res.email).toBe('ada@example.org'); expect((created.user as { kcId: string }).kcId).toBe('kc-1'); expect( await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash), ).toBe(true); expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER); expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1'); expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything()); expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything()); }); }); describe('TeamerService.createInvite', () => { it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => { const { service, mail } = makeService(); const inv = await service.createInvite(LT, 'gem-1', {}); expect(inv.email).toBeNull(); expect(inv.maxUses).toBeNull(); expect(inv.expiresAt).toBeNull(); expect(inv.token).toEqual(expect.any(String)); expect(inv.emailSent).toBe(false); expect(mail.sendTeamerInvite).not.toHaveBeenCalled(); }); it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => { const { service, mail } = makeService(); const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' }); expect(inv.email).toBe('new@example.org'); expect(inv.maxUses).toBe(1); expect(inv.emailSent).toBe(true); expect(mail.sendTeamerInvite).toHaveBeenCalledWith( expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }), ); }); it('still returns the invite when the mail transport drops it', async () => { const { service, mail } = makeService(); mail.sendTeamerInvite.mockResolvedValueOnce(false); const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' }); expect(inv.emailSent).toBe(false); expect(inv.token).toEqual(expect.any(String)); }); it('turns expiresInHours into a concrete expiry', async () => { const { service } = makeService(); const before = Date.now(); const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 }); const ms = (inv.expiresAt as Date).getTime() - before; expect(ms).toBeGreaterThan(47 * 3600_000); expect(ms).toBeLessThan(49 * 3600_000); }); }); describe('TeamerService.removeTeamer', () => { it('404s when the user is not a local Teamer of that Gemeinde', async () => { const { service } = makeService(); await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf( NotFoundException, ); }); it('deletes the account and captures a User DELETE', async () => { const { service, prisma, sync } = makeService(); prisma.membership.findFirst = jest .fn() .mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } }); const res = await service.removeTeamer(LT, 'gem-1', 'u-1'); expect(res).toEqual({ id: 'u-1' }); expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } }); expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' }); }); });