feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -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