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);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MARKE_FEHLER_TEXT,
MARKE_GUELTIG_SEKUNDEN,
markeErzeugen,
markePruefen,
} from "@/lib/kiosk/anmeldemarke";
/**
* Die Anmeldemarke ist ein Anmeldeweg — sie verdient dieselbe Sorgfalt wie ein
* Passwort. Geprüft wird deshalb nicht nur der gute Fall, sondern vor allem,
* was passiert, wenn jemand an der Marke dreht.
*/
const PERSON = "8f7d3b1e-2c4a-4e6f-9a1b-0c2d4e6f8a1b";
const ORGANISATION = "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d";
afterEach(() => {
vi.useRealTimers();
});
describe("Anmeldemarke", () => {
it("erkennt die eigene Marke wieder", () => {
const marke = markeErzeugen(PERSON, ORGANISATION);
const ergebnis = markePruefen(marke);
expect(ergebnis).toEqual({ personId: PERSON, organisationId: ORGANISATION });
});
it("weist eine Marke ab, an der jemand gedreht hat", () => {
const marke = markeErzeugen(PERSON, ORGANISATION);
// Andere Person, alte Signatur — der klassische Versuch.
const fremde = marke.replace(PERSON, "0000ffff-1111-4222-8333-444455556666");
expect(markePruefen(fremde)).toEqual({ fehler: "verfaelscht" });
});
it("weist eine Marke ab, deren Signatur gefälscht wurde", () => {
const marke = markeErzeugen(PERSON, ORGANISATION);
const teile = marke.split(".");
const verdreht = [...teile.slice(0, 3), "x".repeat(teile[3]!.length)].join(".");
expect(markePruefen(verdreht)).toEqual({ fehler: "verfaelscht" });
});
it("erkennt etwas, das gar keine Marke ist", () => {
expect(markePruefen("")).toEqual({ fehler: "ungueltig" });
expect(markePruefen("https://beispiel.de/g/INV-0001")).toEqual({ fehler: "ungueltig" });
expect(markePruefen("a.b.c")).toEqual({ fehler: "ungueltig" });
});
it("läuft nach der festgelegten Zeit ab", () => {
vi.useFakeTimers();
const marke = markeErzeugen(PERSON, ORGANISATION);
// Kurz vor Ablauf gilt sie noch.
vi.advanceTimersByTime((MARKE_GUELTIG_SEKUNDEN - 1) * 1000);
expect(markePruefen(marke)).toHaveProperty("personId", PERSON);
// Zwei Sekunden später nicht mehr — das abfotografierte Handy nützt nichts.
vi.advanceTimersByTime(2000);
expect(markePruefen(marke)).toEqual({ fehler: "abgelaufen" });
});
it("nimmt keine Marke aus der Zukunft an", () => {
vi.useFakeTimers();
vi.advanceTimersByTime(10 * 60 * 1000);
const spaetere = markeErzeugen(PERSON, ORGANISATION);
vi.setSystemTime(new Date(Date.now() - 5 * 60 * 1000));
expect(markePruefen(spaetere)).toEqual({ fehler: "abgelaufen" });
});
it("hat für jeden Fehler einen Satz, den man am Terminal lesen kann", () => {
for (const text of Object.values(MARKE_FEHLER_TEXT)) {
expect(text.length).toBeGreaterThan(10);
expect(text).toMatch(/[.!]$/);
}
});
it("erzeugt bei jedem Aufruf eine andere Marke", () => {
vi.useFakeTimers();
const erste = markeErzeugen(PERSON, ORGANISATION);
vi.advanceTimersByTime(1000);
const zweite = markeErzeugen(PERSON, ORGANISATION);
expect(erste).not.toBe(zweite);
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* @vitest-environment jsdom
*
* Die Warteschlange lebt im Browser: sie fragt `window` und `indexedDB`. Der
* Rest der Testsuite läuft in Node, deshalb hier ausdrücklich jsdom — und
* darin fake-indexeddb, denn eine IndexedDB bringt jsdom nicht mit.
*/
// IndexedDB gibt es in Node nicht — fake-indexeddb legt eine echte
// Implementierung ins globale Objekt. Der Import muss vor allem stehen, was
// Dexie anfasst, deshalb ganz oben.
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
anzahlOffen,
datenbank,
einreihen,
synchronisieren,
verwerfen,
warteschlange,
type SendeVorgang,
} from "@/lib/kiosk/outbox";
import type { VorgangErgebnis } from "@/lib/kiosk/vorgaenge";
/**
* Die Warteschlange des Terminals.
*
* Hier hängt daran, ob im Gerätehaus etwas verloren geht: Meldungen dürfen
* erst verschwinden, wenn der Server sie hat, und ein zweiter Sendeversuch
* darf nichts doppeln. Beides lässt sich ohne Browser prüfen — die Logik
* steckt in dieser Datei, nicht in der Oberfläche.
*/
/** Antwortet auf alles mit „angenommen". */
function serverNimmtAlles(): { sender: ReturnType<typeof vi.fn>; gesehen: SendeVorgang[][] } {
const gesehen: SendeVorgang[][] = [];
const sender = vi.fn(async (vorgaenge: SendeVorgang[]) => {
gesehen.push(vorgaenge);
return {
ergebnisse: vorgaenge.map<VorgangErgebnis>((vorgang) => ({
id: vorgang.id,
erfolgreich: true,
bereitsVerarbeitet: false,
})),
};
});
return { sender, gesehen };
}
beforeEach(async () => {
await datenbank().vorgaenge.clear();
});
describe("Warteschlange des Terminals", () => {
it("nimmt einen Vorgang an und gibt ihm eine eigene Kennung", async () => {
const eintrag = await einreihen(
{ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "UEBUNG" } },
"Gekommen (Übung)",
);
expect(eintrag.id).toMatch(/^[0-9a-f-]{36}$/);
expect(await anzahlOffen()).toBe(1);
const liste = await warteschlange();
expect(liste[0]?.beschriftung).toBe("Gekommen (Übung)");
expect(liste[0]?.versuche).toBe(0);
});
it("arbeitet die Vorgänge in der Reihenfolge der Erfassung ab", async () => {
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, "erster");
// Ohne Abstand hätten beide dieselbe ISO-Zeit — im Gerätehaus liegen
// zwischen zwei Vorgängen Sekunden, hier reicht eine Millisekunde.
await new Promise((fertig) => setTimeout(fertig, 5));
await einreihen({ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } }, "zweiter");
const liste = await warteschlange();
expect(liste.map((eintrag) => eintrag.beschriftung)).toEqual(["erster", "zweiter"]);
});
it("löscht erst, wenn der Server bestätigt hat", async () => {
await einreihen(
{
art: "MANGEL_MELDEN",
daten: { inventarnummer: "INV-0001", beschreibung: "Riss", prioritaet: "MITTEL" },
},
"Mangel an INV-0001",
);
const { sender } = serverNimmtAlles();
const ergebnis = await synchronisieren(sender);
expect(ergebnis.gesendet).toBe(1);
expect(ergebnis.erledigt).toBe(1);
expect(await anzahlOffen()).toBe(0);
});
it("behält alles, wenn kein Netz da ist, und zählt den Versuch", async () => {
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "EINSATZ" } }, "Gekommen");
const ergebnis = await synchronisieren(async () => {
throw new TypeError("Failed to fetch");
});
expect(ergebnis.erledigt).toBe(0);
expect(ergebnis.verblieben).toBe(1);
expect(ergebnis.fehler).toContain("Failed to fetch");
const liste = await warteschlange();
expect(liste[0]?.versuche).toBe(1);
});
it("schickt denselben Vorgang mit derselben Kennung erneut", async () => {
const eintrag = await einreihen(
{ art: "WAESCHE_ABGEBEN", daten: { bezeichnung: "Einsatzjacke", menge: 1 } },
"Wäsche abgegeben",
);
// Erster Versuch: die Antwort geht verloren.
await synchronisieren(async () => {
throw new Error("Verbindung abgebrochen");
});
expect(await anzahlOffen()).toBe(1);
// Zweiter Versuch: derselbe Vorgang, dieselbe Kennung — daran erkennt ihn
// der Server wieder.
const { sender, gesehen } = serverNimmtAlles();
await synchronisieren(sender);
expect(gesehen[0]?.[0]?.id).toBe(eintrag.id);
expect(await anzahlOffen()).toBe(0);
});
it("nimmt „bereits verarbeitet“ als Erfolg — sonst bliebe der Vorgang ewig liegen", async () => {
const eintrag = await einreihen(
{ art: "ANWESENHEIT_GEHEN", daten: { anwesenheitId: null } },
"Gegangen",
);
const ergebnis = await synchronisieren(async () => ({
ergebnisse: [{ id: eintrag.id, erfolgreich: false, bereitsVerarbeitet: true }],
}));
expect(ergebnis.erledigt).toBe(1);
expect(await anzahlOffen()).toBe(0);
});
it("nimmt einen Konflikt aus der Warteschlange und zählt ihn getrennt", async () => {
const eintrag = await einreihen(
{
art: "MANGEL_MELDEN",
daten: { inventarnummer: "INV-9999", beschreibung: "kaputt", prioritaet: "HOCH" },
},
"Mangel an INV-9999",
);
const ergebnis = await synchronisieren(async () => ({
ergebnisse: [
{
id: eintrag.id,
erfolgreich: false,
bereitsVerarbeitet: false,
konflikt: true,
meldung: "Gerät gibt es nicht",
},
],
}));
// Der Vorgang liegt jetzt beim Server in der Konfliktliste — hier ist er
// fertig, sonst käme er bei jedem Sendeversuch wieder.
expect(ergebnis.konflikte).toBe(1);
expect(ergebnis.erledigt).toBe(1);
expect(await anzahlOffen()).toBe(0);
});
it("merkt sich den Grund, wenn der Server einen Vorgang ablehnt", async () => {
const eintrag = await einreihen(
{ art: "FAHRT_BEENDEN", daten: { fahrtId: eintragKennung(), kilometerRueckkehr: 100 } },
"Fahrt beendet",
);
await synchronisieren(async () => ({
ergebnisse: [
{
id: eintrag.id,
erfolgreich: false,
bereitsVerarbeitet: false,
meldung: "Kilometerstand kleiner als bei der Abfahrt",
},
],
}));
const liste = await warteschlange();
expect(liste).toHaveLength(1);
expect(liste[0]?.versuche).toBe(1);
expect(liste[0]?.letzterFehler).toContain("Kilometerstand");
});
it("lädt ein wartendes Foto zuerst hoch und hängt die Kennung an die Meldung", async () => {
const bild = new Blob(["nicht wirklich ein Bild"], { type: "image/jpeg" });
await einreihen(
{
art: "MANGEL_MELDEN",
daten: { inventarnummer: "INV-0002", beschreibung: "Delle", prioritaet: "MITTEL" },
},
"Mangel an INV-0002",
{ inhalt: bild, name: "mangel.jpg" },
);
const fotoSender = vi.fn(async () => "3f2504e0-4f89-41d3-9a0c-0305e82c3301");
const { sender, gesehen } = serverNimmtAlles();
await synchronisieren(sender, fotoSender);
expect(fotoSender).toHaveBeenCalledOnce();
expect(gesehen[0]?.[0]?.daten).toMatchObject({
fotoDateiId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
});
});
it("schickt die Meldung auch dann, wenn das Foto nicht durchgeht", async () => {
const bild = new Blob(["x"], { type: "image/jpeg" });
await einreihen(
{
art: "MANGEL_MELDEN",
daten: { inventarnummer: "INV-0003", beschreibung: "Riss", prioritaet: "KRITISCH" },
},
"Mangel an INV-0003",
{ inhalt: bild, name: "mangel.jpg" },
);
const { sender } = serverNimmtAlles();
const ergebnis = await synchronisieren(sender, async () => {
throw new Error("Speicher nicht erreichbar");
});
// Eine Meldung ohne Bild ist besser als gar keine.
expect(ergebnis.erledigt).toBe(1);
expect(await anzahlOffen()).toBe(0);
});
it("überträgt höchstens so viele Vorgänge, wie erlaubt sind", async () => {
for (let zaehler = 0; zaehler < 5; zaehler += 1) {
await einreihen({ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "DIENST" } }, `Nr ${zaehler}`);
await new Promise((fertig) => setTimeout(fertig, 2));
}
const { sender } = serverNimmtAlles();
const ergebnis = await synchronisieren(sender, undefined, 2);
expect(ergebnis.gesendet).toBe(2);
expect(ergebnis.verblieben).toBe(3);
});
it("lässt einen Vorgang von Hand verwerfen", async () => {
const eintrag = await einreihen(
{ art: "ANWESENHEIT_KOMMEN", daten: { anlass: "SONSTIGES" } },
"Gekommen",
);
await verwerfen(eintrag.id);
expect(await anzahlOffen()).toBe(0);
});
});
/** Eine beliebige, aber gültige UUID für Nutzlasten, die eine verlangen. */
function eintragKennung(): string {
return "6b1d2f3a-4c5e-4a7b-8c9d-0e1f2a3b4c5d";
}