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) }), ); }); });