From 39f83252874884586408eb100e869ee1f9c820ac Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:29:02 +0200 Subject: [PATCH] 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 --- backend/src/wahl/zuteilung.service.spec.ts | 222 +++++++++++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 5 +- 2 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 backend/src/wahl/zuteilung.service.spec.ts diff --git a/backend/src/wahl/zuteilung.service.spec.ts b/backend/src/wahl/zuteilung.service.spec.ts new file mode 100644 index 0000000..d160b31 --- /dev/null +++ b/backend/src/wahl/zuteilung.service.spec.ts @@ -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) }), + ); + }); +}); diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index eb7db70..be34e92 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -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). ---