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:
@@ -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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user