test(backend): unit-test ZuteilungService assignment algorithm

First automated tests in the backend. Fakes Prisma + SyncService in
memory and asserts on the zuteilung.createMany payload:
- Force-Zuteilung wins over participant wishes
- wish-round fallback when a workshop hits capacity
- participant left unassigned when nothing is free
- underfilled-workshop consolidation reassigns via remaining wishes
- workshop exactly meeting minTeilnehmer is kept
- one CREATE sync entry captured per resulting Zuteilung

npm test green (9 tests). Plan verification section updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:29:02 +02:00
co-authored by Claude Sonnet 5
parent 100f5bc2af
commit 39f8325287
2 changed files with 225 additions and 2 deletions
+222
View File
@@ -0,0 +1,222 @@
import { NotFoundException } from '@nestjs/common';
import { SyncOperation } from '@prisma/client';
import { ZuteilungService } from './zuteilung.service';
/// Unit tests for the assignment algorithm ported from the WP plugin's
/// kc_run_zuteilung. Prisma and SyncService are faked in-memory; assertions
/// run against the `zuteilung.createMany` payload the service builds.
interface WorkshopFixture {
id: string;
name: string;
kapazitaet: number;
minTeilnehmer: number;
}
interface TeilnehmerFixture {
id: string;
prioritaeten: string[];
}
interface ForceFixture {
id: string;
teilnehmerId: string;
workshopId: string;
}
interface CreatedRow {
teilnehmerId: string;
workshopId: string | null;
wunschRang: number;
isForced: boolean;
}
const WAHL_ID = 'wahl-1';
function makeService(fixture: {
workshops: WorkshopFixture[];
teilnehmer: TeilnehmerFixture[];
forces?: ForceFixture[];
wahlExists?: boolean;
}) {
let lastCreateMany: CreatedRow[] = [];
const workshops = fixture.workshops.map((w) => ({ ...w, wahlId: WAHL_ID }));
const teilnehmer = fixture.teilnehmer.map((t) => ({
id: t.id,
wahlId: WAHL_ID,
guestAccountId: `guest-${t.id}`,
prioritaeten: t.prioritaeten,
createdAt: new Date(),
}));
const forces = (fixture.forces ?? []).map((f) => ({ ...f, wahlId: WAHL_ID }));
const prisma = {
wahl: {
findUnique: jest
.fn()
.mockResolvedValue(
fixture.wahlExists === false ? null : { id: WAHL_ID, kcId: 'kc-1' },
),
},
workshop: { findMany: jest.fn().mockResolvedValue(workshops) },
teilnehmer: { findMany: jest.fn().mockResolvedValue(teilnehmer) },
forceZuteilung: { findMany: jest.fn().mockResolvedValue(forces) },
zuteilung: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
createMany: jest.fn().mockImplementation(({ data }: { data: CreatedRow[] }) => {
lastCreateMany = data;
return Promise.resolve({ count: data.length });
}),
findMany: jest
.fn()
.mockImplementation(() =>
Promise.resolve(lastCreateMany.map((row, i) => ({ id: `zut-${i}`, ...row }))),
),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const service = new ZuteilungService(prisma as never, sync as never);
return { service, prisma, sync, rows: () => lastCreateMany };
}
function rowFor(rows: CreatedRow[], teilnehmerId: string): CreatedRow {
const row = rows.find((r) => r.teilnehmerId === teilnehmerId);
if (!row) throw new Error(`no zuteilung row for ${teilnehmerId}`);
return row;
}
describe('ZuteilungService', () => {
it('throws NotFound when the Wahl does not exist', async () => {
const { service } = makeService({
workshops: [],
teilnehmer: [],
wahlExists: false,
});
await expect(service.run(WAHL_ID)).rejects.toBeInstanceOf(NotFoundException);
});
it('clears previous Zuteilungen before recomputing', async () => {
const { service, prisma } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
});
await service.run(WAHL_ID);
expect(prisma.zuteilung.deleteMany).toHaveBeenCalledWith({
where: { teilnehmer: { wahlId: WAHL_ID } },
});
});
it('honours Force-Zuteilungen over the participant wishes', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 },
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-b'] }],
forces: [{ id: 'f1', teilnehmerId: 't1', workshopId: 'ws-a' }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toEqual({
teilnehmerId: 't1',
workshopId: 'ws-a',
wunschRang: 0,
isForced: true,
});
});
it('assigns a first-wish workshop when capacity allows', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 3, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toMatchObject({
workshopId: 'ws-a',
wunschRang: 1,
isForced: false,
});
});
it('falls back to the next wish once a workshop is full', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 1, minTeilnehmer: 0 },
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
],
});
await service.run(WAHL_ID);
const placed = [rowFor(rows(), 't1'), rowFor(rows(), 't2')]
.map((r) => `${r.workshopId}:${r.wunschRang}`)
.sort();
// One keeps the 1st wish (ws-a), the other slides to the 2nd wish (ws-b).
expect(placed).toEqual(['ws-a:1', 'ws-b:2']);
});
it('leaves a participant unassigned when no capacity is left anywhere', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 0, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: [] }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toEqual({
teilnehmerId: 't1',
workshopId: null,
wunschRang: -1,
isForced: false,
});
});
it('dissolves a workshop that stays below minTeilnehmer and reassigns via remaining wishes', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 3 },
{ id: 'ws-b', name: 'B', kapazitaet: 10, minTeilnehmer: 0 },
],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
],
});
await service.run(WAHL_ID);
// ws-a got 2 (< min 3) -> dissolved; both fall through to their 2nd wish.
for (const id of ['t1', 't2']) {
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-b', wunschRang: 2 });
}
});
it('keeps a workshop that exactly meets minTeilnehmer', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 2 }],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a'] },
{ id: 't2', prioritaeten: ['ws-a'] },
],
});
await service.run(WAHL_ID);
for (const id of ['t1', 't2']) {
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-a', wunschRang: 1 });
}
});
it('captures one CREATE sync entry per resulting Zuteilung', async () => {
const { service, sync } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a'] },
{ id: 't2', prioritaeten: ['ws-a'] },
],
});
await service.run(WAHL_ID);
expect(sync.capture).toHaveBeenCalledTimes(2);
expect(sync.capture).toHaveBeenCalledWith(
'Zuteilung',
SyncOperation.CREATE,
expect.any(String),
expect.objectContaining({ teilnehmerId: expect.any(String) }),
);
});
});
+3 -2
View File
@@ -13,7 +13,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T
- **Rollenmodell** (Enum `Role`, Authentik-gestützt):
- **Leitungsteam (LT)** global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird.
- **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Authentik-Account.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Lokaler Account.
- **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global.
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren.
@@ -103,7 +103,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM
1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
4. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
4. `ZuteilungService`: Jest-Unit-Tests (`src/wahl/zuteilung.service.spec.ts`, Prisma/Sync gemockt) decken Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops und die Sync-Capture-Anzahl ab. `npm test` grün.
5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
---