feat: client monorepo (Flutter app) + web fallback redesign #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) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T
|
|||||||
- **Rollenmodell** (Enum `Role`, Authentik-gestützt):
|
- **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.
|
- **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 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).
|
- **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.
|
- **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.
|
- **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).
|
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).
|
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).
|
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