Terminal im Geraetehaus mit Offline-Betrieb ergaenzen

Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 18:35:48 +02:00
co-authored by Claude Opus 5
parent d8d692098d
commit 791523027f
126 changed files with 7787 additions and 40 deletions
+288
View File
@@ -0,0 +1,288 @@
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);
});
});