diff --git a/src/wahl/zuteilung.service.spec.ts b/src/wahl/zuteilung.service.spec.ts new file mode 100644 index 0000000..d160b31 --- /dev/null +++ b/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) }), + ); + }); +});