import { PrismaClient } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; /** * Was die Datenbank beim Terminal durchsetzt. * * Der Offline-Betrieb bringt eine unangenehme Eigenschaft mit: Vorgänge kommen * verspätet, mehrfach und in beliebiger Reihenfolge an. Was dabei nicht * passieren darf, steht deshalb nicht (nur) im Anwendungscode, sondern in der * Datenbank: * * 1. Mandantentrennung der sieben neuen Tabellen * 2. Ein Fahrtenbuch, dessen Kilometerstände nicht rückwärts laufen * 3. Anwesenheiten, die nicht vor dem Kommen enden * 4. Übermittelte Vorgänge, die sich nicht nachträglich ändern lassen — * sonst wäre der Idempotenzschlüssel wertlos * * Ohne erreichbare Datenbank überspringen sich die Tests selbst. */ const APP_URL = process.env.DATABASE_URL ?? ""; const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL; const app = new PrismaClient({ datasources: { db: { url: APP_URL } } }); const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } }); const SLUG = "test-kiosk-wehr"; let erreichbar = false; let organisationId = ""; let personId = ""; let fahrzeugId = ""; beforeAll(async () => { try { await system.$queryRaw`SELECT 1`; erreichbar = true; } catch { return; } const organisation = await system.organisation.upsert({ where: { slug: SLUG }, update: {}, create: { slug: SLUG, name: "Kiosk-Testwehr", kurzname: "KTW" }, }); organisationId = organisation.id; const person = await system.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "K001" } }, update: {}, create: { organisationId, mitgliedsnummer: "K001", vorname: "Kilian", nachname: "Kiosk" }, }); personId = person.id; const fahrzeug = await system.fahrzeug.upsert({ where: { organisationId_funkrufname: { organisationId, funkrufname: "Florian Test 40/1" } }, update: {}, create: { organisationId, funkrufname: "Florian Test 40/1", bezeichnung: "Löschgruppenfahrzeug für den Test", art: "LOESCHFAHRZEUG", kilometerstand: 10_000, }, }); fahrzeugId = fahrzeug.id; }); afterAll(async () => { if (erreichbar) { await system.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`; await tx.organisation.deleteMany({ where: { slug: SLUG } }); }); } await Promise.allSettled([app.$disconnect(), system.$disconnect()]); }); describe.runIf(process.env.DATABASE_URL)("Fahrtenbuch", () => { it("nimmt eine vollständige Fahrt an", async ({ skip }) => { if (!erreichbar) return skip(); const fahrt = await system.fahrt.create({ data: { organisationId, fahrzeugId, fahrerId: personId, abfahrtUm: new Date("2026-05-01T08:00:00.000Z"), rueckkehrUm: new Date("2026-05-01T11:30:00.000Z"), kilometerAbfahrt: 10_000, kilometerRueckkehr: 10_042, }, }); expect(fahrt.kilometerRueckkehr! - fahrt.kilometerAbfahrt).toBe(42); }); it("weist einen Kilometerstand ab, der rückwärts läuft", async ({ skip }) => { if (!erreichbar) return skip(); // Der Tippfehler, den es am Terminal wirklich gibt: Ziffer vergessen. await expect( system.fahrt.create({ data: { organisationId, fahrzeugId, fahrerId: personId, abfahrtUm: new Date("2026-05-02T08:00:00.000Z"), rueckkehrUm: new Date("2026-05-02T09:00:00.000Z"), kilometerAbfahrt: 10_100, kilometerRueckkehr: 1_010, }, }), ).rejects.toThrow(/fahrt_kilometer_vorwaerts/i); }); it("weist eine Rückkehr vor der Abfahrt ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.fahrt.create({ data: { organisationId, fahrzeugId, fahrerId: personId, abfahrtUm: new Date("2026-05-03T12:00:00.000Z"), rueckkehrUm: new Date("2026-05-03T09:00:00.000Z"), kilometerAbfahrt: 10_200, kilometerRueckkehr: 10_210, }, }), ).rejects.toThrow(/fahrt_rueckkehr_nach_abfahrt/i); }); it("lässt eine laufende Fahrt ohne Rückkehr zu", async ({ skip }) => { if (!erreichbar) return skip(); const fahrt = await system.fahrt.create({ data: { organisationId, fahrzeugId, fahrerId: personId, abfahrtUm: new Date("2026-05-04T08:00:00.000Z"), kilometerAbfahrt: 10_300, }, }); expect(fahrt.rueckkehrUm).toBeNull(); expect(fahrt.kilometerRueckkehr).toBeNull(); }); }); describe.runIf(process.env.DATABASE_URL)("Anwesenheit und Wäsche", () => { it("weist ein Gehen vor dem Kommen ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.anwesenheit.create({ data: { organisationId, personId, angemeldetUm: new Date("2026-05-05T19:00:00.000Z"), abgemeldetUm: new Date("2026-05-05T18:00:00.000Z"), }, }), ).rejects.toThrow(/anwesenheit_gehen_nach_kommen/i); }); it("lässt eine offene Anwesenheit zu — jemand ist noch im Haus", async ({ skip }) => { if (!erreichbar) return skip(); const anwesenheit = await system.anwesenheit.create({ data: { organisationId, personId, angemeldetUm: new Date("2026-05-06T19:00:00.000Z") }, }); expect(anwesenheit.abgemeldetUm).toBeNull(); }); it("weist eine Wäscheabgabe ohne Stück ab", async ({ skip }) => { if (!erreichbar) return skip(); await expect( system.waescheabgabe.create({ data: { organisationId, personId, bezeichnung: "Einsatzjacke", menge: 0 }, }), ).rejects.toThrow(/waescheabgabe_menge_positiv/i); }); }); describe.runIf(process.env.DATABASE_URL)("Übermittelte Vorgänge", () => { it("erkennt denselben Vorgang an seiner Kennung wieder", async ({ skip }) => { if (!erreichbar) return skip(); const kennung = "11111111-2222-4333-8444-555555555555"; await system.kioskVorgang.create({ data: { id: kennung, organisationId, personId, art: "ANWESENHEIT_KOMMEN", erfasstAm: new Date("2026-05-07T18:55:00.000Z"), }, }); // Der zweite Sendeversuch nach abgebrochener Antwort: dieselbe Kennung. await expect( system.kioskVorgang.create({ data: { id: kennung, organisationId, personId, art: "ANWESENHEIT_KOMMEN", erfasstAm: new Date("2026-05-07T18:55:00.000Z"), }, }), ).rejects.toThrow(); // Und der Server findet den alten Eintrag, statt neu anzulegen. const vorhanden = await system.kioskVorgang.findUnique({ where: { id: kennung } }); expect(vorhanden?.art).toBe("ANWESENHEIT_KOMMEN"); }); it("lässt sich vom Anwendungszugang nicht nachträglich ändern", async ({ skip }) => { if (!erreichbar) return skip(); const kennung = "66666666-7777-4888-8999-aaaaaaaaaaaa"; await system.kioskVorgang.create({ data: { id: kennung, organisationId, personId, art: "WAESCHE_ABGEBEN", erfasstAm: new Date("2026-05-08T18:00:00.000Z"), }, }); // Der Anwendungszugang darf schreiben, aber nicht umschreiben — sonst // ließe sich die Spur eines Vorgangs nachträglich glätten. await expect( app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.$executeRaw`UPDATE "kiosk_vorgang" SET "art" = 'MANGEL_MELDEN' WHERE "id" = ${kennung}::uuid`; }), ).rejects.toThrow(); await expect( app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.$executeRaw`DELETE FROM "kiosk_vorgang" WHERE "id" = ${kennung}::uuid`; }), ).rejects.toThrow(); }); }); describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Terminaltabellen", () => { it("zeigt ohne gesetzte Wehr nichts", async ({ skip }) => { if (!erreichbar) return skip(); const gefunden = await app.$transaction(async (tx) => ({ fahrten: await tx.fahrt.findMany(), anwesenheiten: await tx.anwesenheit.findMany(), waesche: await tx.waescheabgabe.findMany(), inventuren: await tx.inventurgang.findMany(), posten: await tx.inventurposten.findMany(), vorgaenge: await tx.kioskVorgang.findMany(), konflikte: await tx.syncKonflikt.findMany(), })); for (const [tabelle, zeilen] of Object.entries(gefunden)) { expect(zeilen, `${tabelle} darf ohne Mandantenkontext nichts zeigen`).toHaveLength(0); } }); it("zeigt im Kontext der Wehr nur deren eigene Fahrten", async ({ skip }) => { if (!erreichbar) return skip(); const fahrten = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`; return tx.fahrt.findMany(); }); expect(fahrten.length).toBeGreaterThan(0); expect(fahrten.every((fahrt) => fahrt.organisationId === organisationId)).toBe(true); }); });