Files
KC-APP-Server/src/teamer/teamer.service.spec.ts
T
linusandClaude Sonnet 5 da76f8dc96 feat(backend): mail module + send personal Gemeinde-Teamer invites
New global mail/ module mirroring the files/storage/ provider pattern:
- MailProvider abstraction; default LogMailProvider only logs (no delivery),
  MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*,
  MAIL_FROM).
- MailService.sendTeamerInvite() composes the invite email with a link
  built from APP_BASE_URL.

TeamerService.createInvite() now mails personal invites (those with an
email) best-effort and returns `emailSent`; group links are unchanged.
Delivery failures are logged and swallowed, never blocking invite creation.

New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/
USER/PASS. Tests: teamer spec covers mail-on-personal-invite,
no-mail-on-group-link, and transport-drop; npm test green at 56. Docs
updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:17:53 +02:00

184 lines
7.4 KiB
TypeScript

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<string, unknown> = {};
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<string, unknown> }) => {
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<string, unknown> }) => {
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<string, unknown> }) =>
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' });
});
});