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