Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle, an der die Mandantenregel steht. Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock. 336 Tests. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { kundeHolen, kundenListe, kundenZurAuswahl } from "@/server/queries/kunden";
|
||
import {
|
||
KundenFehler,
|
||
kuerzelPruefen,
|
||
kundeAnlegen,
|
||
kundeArchivieren,
|
||
kundeBearbeiten,
|
||
type KundenAenderung,
|
||
type NeuerKunde,
|
||
} from "@/server/services/kunden";
|
||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||
|
||
/** Die Lesemodelle verlangen einen Benutzer; ein Administrator sieht alles. */
|
||
const ADMIN = { id: "admin", rolle: "ADMIN" } as const;
|
||
|
||
/**
|
||
* Kundenverwaltung gegen die echte Datenbank (#008).
|
||
*
|
||
* Zwei Regeln haengen unmittelbar am Betriebsmodus und stehen deshalb hier fest:
|
||
* die eigene Organisation entsteht nicht ueber `kundeAnlegen`, und sie laesst
|
||
* sich nicht archivieren – sonst waere der Modus `INTERN` ohne Kunden.
|
||
*/
|
||
|
||
/** Urheber fuer die Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
|
||
async function adminAnlegen() {
|
||
return testPrisma.benutzer.create({
|
||
data: { email: "admin@firma.example", name: "Administrator", rolle: "ADMIN" },
|
||
});
|
||
}
|
||
|
||
async function eigeneOrganisationAnlegen() {
|
||
return testPrisma.kunde.create({
|
||
data: { name: "Eigene Firma", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||
});
|
||
}
|
||
|
||
/** Kleinste Vorlage, die ausreicht, um einen Lauf daran zu haengen. */
|
||
async function veroeffentlichteVersionAnlegen() {
|
||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Probe" } });
|
||
return testPrisma.vorlagenVersion.create({
|
||
data: {
|
||
vorlageId: vorlage.id,
|
||
version: 1,
|
||
status: "VEROEFFENTLICHT",
|
||
veroeffentlichtAm: new Date(),
|
||
},
|
||
});
|
||
}
|
||
|
||
describe.runIf(datenbankVerfuegbar)("Kundenverwaltung", () => {
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
});
|
||
|
||
describe("Kuerzel", () => {
|
||
it("trimmt und schreibt gross", () => {
|
||
expect(kuerzelPruefen("mus")).toBe("MUS");
|
||
expect(kuerzelPruefen(" REI ")).toBe("REI");
|
||
expect(kuerzelPruefen("mus ")).toBe("MUS");
|
||
});
|
||
|
||
it("weist alles ab, was nicht 2 bis 6 Zeichen A-Z und 0-9 ist", () => {
|
||
// "MUeS" waere in Ordnung, ein Umlaut nicht: das Kuerzel steht auf
|
||
// Etiketten und in Hostnamen.
|
||
for (const falsch of ["X", "ZULANG7", "MU-S", "MU_S", "MÜS", "", " "]) {
|
||
expect(() => kuerzelPruefen(falsch)).toThrow(KundenFehler);
|
||
}
|
||
});
|
||
|
||
it("sagt in der Meldung, was erwartet wird", () => {
|
||
expect(() => kuerzelPruefen("X")).toThrow(/2 bis 6 Zeichen/);
|
||
});
|
||
|
||
it("nimmt das getrimmte Kuerzel beim Anlegen", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: " Musterfirma ", kuerzel: " mus " }, admin.id);
|
||
expect(kunde.kuerzel).toBe("MUS");
|
||
expect(kunde.name).toBe("Musterfirma");
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel ab und benennt den vorhandenen Kunden", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
await expect(kundeAnlegen({ name: "Muster GmbH", kuerzel: "mus" }, admin.id)).rejects.toThrow(
|
||
/Musterfirma/,
|
||
);
|
||
expect(await testPrisma.kunde.count()).toBe(1);
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel auch gegen einen archivierten Kunden ab", async () => {
|
||
// Sonst waere das Kuerzel zweimal im Umlauf, sobald jemand den alten
|
||
// Kunden zurueckholt – und der eindeutige Index kennt kein Archiv.
|
||
const admin = await adminAnlegen();
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
await expect(kundeAnlegen({ name: "Neue Firma", kuerzel: "ALT" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
});
|
||
|
||
it("weist ein doppeltes Kuerzel auch beim Bearbeiten ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const rei = await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
|
||
|
||
await expect(kundeBearbeiten(rei.id, { kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: rei.id } });
|
||
expect(unveraendert.kuerzel).toBe("REI");
|
||
});
|
||
});
|
||
|
||
describe("Name", () => {
|
||
it("weist einen leeren Namen ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
for (const name of ["", " "]) {
|
||
await expect(kundeAnlegen({ name, kuerzel: "MUS" }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
|
||
}
|
||
expect(await testPrisma.kunde.count()).toBe(0);
|
||
});
|
||
|
||
it("weist einen leeren Namen auch beim Bearbeiten ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
await expect(kundeBearbeiten(kunde.id, { name: " " }, admin.id)).rejects.toBeInstanceOf(KundenFehler);
|
||
});
|
||
});
|
||
|
||
describe("Eigene Organisation", () => {
|
||
it("entsteht nie ueber kundeAnlegen, egal was in der Eingabe steht", async () => {
|
||
// Sie entsteht nur im Seed und beim Moduswechsel. Die Eingabe wird hier
|
||
// absichtlich ueber die Typgrenze hinaus befuellt, wie es ein Aufrufer aus
|
||
// einem Formular koennte.
|
||
const admin = await adminAnlegen();
|
||
const eingabe = { name: "Untergeschoben", kuerzel: "UGS", istEigeneOrganisation: true } as NeuerKunde;
|
||
const kunde = await kundeAnlegen(eingabe, admin.id);
|
||
|
||
expect(kunde.istEigeneOrganisation).toBe(false);
|
||
expect(await testPrisma.kunde.count({ where: { istEigeneOrganisation: true } })).toBe(0);
|
||
});
|
||
|
||
it("laesst sich nicht archivieren", async () => {
|
||
const admin = await adminAnlegen();
|
||
const eigene = await eigeneOrganisationAnlegen();
|
||
|
||
await expect(kundeArchivieren(eigene.id, true, admin.id)).rejects.toThrow(/einzige Kunde/);
|
||
const unveraendert = await testPrisma.kunde.findUniqueOrThrow({ where: { id: eigene.id } });
|
||
expect(unveraendert.archiviert).toBe(false);
|
||
expect(await testPrisma.protokoll.count({ where: { entitaetId: eigene.id } })).toBe(0);
|
||
});
|
||
|
||
it("laesst sich umbenennen, ohne ihre Rolle zu verlieren", async () => {
|
||
const admin = await adminAnlegen();
|
||
const eigene = await eigeneOrganisationAnlegen();
|
||
const geaendert = await kundeBearbeiten(eigene.id, { name: "Neuer Firmenname" }, admin.id);
|
||
expect(geaendert.name).toBe("Neuer Firmenname");
|
||
expect(geaendert.istEigeneOrganisation).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("Bearbeiten", () => {
|
||
it("aendert istEigeneOrganisation nicht", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const aenderung = { name: "Musterfirma AG", istEigeneOrganisation: true } as KundenAenderung;
|
||
|
||
const geaendert = await kundeBearbeiten(kunde.id, aenderung, admin.id);
|
||
|
||
expect(geaendert.name).toBe("Musterfirma AG");
|
||
expect(geaendert.istEigeneOrganisation).toBe(false);
|
||
});
|
||
|
||
it("protokolliert das Umbenennen mit Vorher und Nachher", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeBearbeiten(kunde.id, { name: "Musterfirma AG" }, admin.id);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "geändert" } },
|
||
});
|
||
expect(eintraege).toHaveLength(1);
|
||
expect(eintraege[0]?.vorher).toEqual({ name: "Musterfirma" });
|
||
expect(eintraege[0]?.nachher).toEqual({ name: "Musterfirma AG" });
|
||
expect(eintraege[0]?.benutzerId).toBe(admin.id);
|
||
});
|
||
|
||
it("protokolliert eine Kuerzelaenderung mit Vorher und Nachher", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeBearbeiten(kunde.id, { kuerzel: "mu2" }, admin.id);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaetId: kunde.id, aktion: { contains: "Kürzel" } },
|
||
});
|
||
expect(eintraege).toHaveLength(1);
|
||
expect(eintraege[0]?.vorher).toEqual({ kuerzel: "MUS" });
|
||
expect(eintraege[0]?.nachher).toEqual({ kuerzel: "MU2" });
|
||
});
|
||
|
||
it("schreibt nichts, wenn sich nichts aendert", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen(
|
||
{ name: "Musterfirma", kuerzel: "MUS", notizen: "Zwei Standorte" },
|
||
admin.id,
|
||
);
|
||
|
||
await kundeBearbeiten(
|
||
kunde.id,
|
||
// Auch die Schreibweise des Kuerzels macht keinen Unterschied, weil es
|
||
// vor dem Vergleich normalisiert wird.
|
||
{ name: " Musterfirma ", kuerzel: "mus", notizen: "Zwei Standorte" },
|
||
admin.id,
|
||
);
|
||
|
||
expect(
|
||
await testPrisma.protokoll.count({
|
||
where: { entitaetId: kunde.id, aktion: { contains: "geändert" } },
|
||
}),
|
||
).toBe(0);
|
||
});
|
||
|
||
it("loescht Notizen mit null", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS", notizen: "Alt" }, admin.id);
|
||
|
||
const geaendert = await kundeBearbeiten(kunde.id, { notizen: null }, admin.id);
|
||
expect(geaendert.notizen).toBeNull();
|
||
});
|
||
|
||
it("weist eine unbekannte Kennung mit einer eigenen Meldung ab", async () => {
|
||
const admin = await adminAnlegen();
|
||
await expect(kundeBearbeiten("gibtsnicht", { name: "Egal" }, admin.id)).rejects.toBeInstanceOf(
|
||
KundenFehler,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Archivieren", () => {
|
||
it("archiviert und gibt wieder frei, je mit Protokolleintrag", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeArchivieren(kunde.id, true, admin.id);
|
||
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(true);
|
||
|
||
await kundeArchivieren(kunde.id, false, admin.id);
|
||
expect((await testPrisma.kunde.findUniqueOrThrow({ where: { id: kunde.id } })).archiviert).toBe(false);
|
||
|
||
const eintraege = await testPrisma.protokoll.findMany({
|
||
where: { entitaet: "Kunde", entitaetId: kunde.id, aktion: { contains: "Kunde " } },
|
||
orderBy: { erstelltAm: "asc" },
|
||
});
|
||
// Angelegt, archiviert, wieder freigegeben.
|
||
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual([
|
||
"Kunde angelegt",
|
||
"Kunde archiviert",
|
||
"Kunde wieder freigegeben",
|
||
]);
|
||
expect(eintraege[1]?.vorher).toEqual({ archiviert: false });
|
||
expect(eintraege[1]?.nachher).toEqual({ archiviert: true });
|
||
expect(eintraege[2]?.nachher).toEqual({ archiviert: false });
|
||
});
|
||
|
||
it("schreibt nichts, wenn der Zustand schon stimmt", async () => {
|
||
const admin = await adminAnlegen();
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
|
||
await kundeArchivieren(kunde.id, false, admin.id);
|
||
|
||
expect(await testPrisma.protokoll.count({ where: { entitaetId: kunde.id } })).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe("Lesemodelle", () => {
|
||
/** Ein Kunde mit zwei Geraeten, einem Mitarbeiter und einem Lauf. */
|
||
async function kundeMitBestandAnlegen(adminId: string) {
|
||
const kunde = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, adminId);
|
||
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-7K3F9Q" } });
|
||
await testPrisma.geraet.create({ data: { kundeId: kunde.id, tagCode: "LZ-M2XH4T" } });
|
||
await testPrisma.mitarbeiter.create({
|
||
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
|
||
});
|
||
const version = await veroeffentlichteVersionAnlegen();
|
||
await testPrisma.lauf.create({
|
||
data: {
|
||
vorlagenVersionId: version.id,
|
||
titel: "PC Neuinstallation",
|
||
kundeId: kunde.id,
|
||
erstelltVonId: adminId,
|
||
},
|
||
});
|
||
return kunde;
|
||
}
|
||
|
||
it("zaehlt Geraete, Mitarbeiter und Laeufe je Kunde", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeMitBestandAnlegen(admin.id);
|
||
await kundeAnlegen({ name: "Baeckerei Reiter", kuerzel: "REI" }, admin.id);
|
||
|
||
const liste = await kundenListe(ADMIN);
|
||
const musZeile = liste.find((zeile) => zeile.id === mus.id);
|
||
expect(musZeile?.anzahlGeraete).toBe(2);
|
||
expect(musZeile?.anzahlMitarbeiter).toBe(1);
|
||
expect(musZeile?.anzahlLaeufe).toBe(1);
|
||
|
||
const reiZeile = liste.find((zeile) => zeile.kuerzel === "REI");
|
||
expect(reiZeile?.anzahlGeraete).toBe(0);
|
||
expect(reiZeile?.anzahlLaeufe).toBe(0);
|
||
});
|
||
|
||
it("sortiert nicht archivierte zuerst, dann nach Namen", async () => {
|
||
const admin = await adminAnlegen();
|
||
const zeta = await kundeAnlegen({ name: "Zeta Werke", kuerzel: "ZET" }, admin.id);
|
||
const alpha = await kundeAnlegen({ name: "Alpha AG", kuerzel: "ALP" }, admin.id);
|
||
const beta = await kundeAnlegen({ name: "Beta GmbH", kuerzel: "BET" }, admin.id);
|
||
await kundeArchivieren(alpha.id, true, admin.id);
|
||
|
||
const liste = await kundenListe(ADMIN, true);
|
||
expect(liste.map((zeile) => zeile.id)).toEqual([beta.id, zeta.id, alpha.id]);
|
||
});
|
||
|
||
it("blendet archivierte aus, solange nicht danach gefragt wird", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
expect((await kundenListe(ADMIN, false)).map((zeile) => zeile.id)).toEqual([mus.id]);
|
||
expect((await kundenListe(ADMIN)).map((zeile) => zeile.id)).toEqual([mus.id]);
|
||
expect((await kundenListe(ADMIN, true)).map((zeile) => zeile.id)).toContain(alt.id);
|
||
});
|
||
|
||
it("liefert einen einzelnen Kunden mit Zaehlungen, sonst undefined", async () => {
|
||
const admin = await adminAnlegen();
|
||
const mus = await kundeMitBestandAnlegen(admin.id);
|
||
|
||
const zeile = await kundeHolen(mus.id, ADMIN);
|
||
expect(zeile?.kuerzel).toBe("MUS");
|
||
expect(zeile?.anzahlGeraete).toBe(2);
|
||
expect(zeile?.istEigeneOrganisation).toBe(false);
|
||
expect(await kundeHolen("gibtsnicht", ADMIN)).toBeUndefined();
|
||
});
|
||
|
||
it("nennt im Auswahlfeld keine archivierten Kunden", async () => {
|
||
const admin = await adminAnlegen();
|
||
await kundeAnlegen({ name: "Musterfirma", kuerzel: "MUS" }, admin.id);
|
||
const alt = await kundeAnlegen({ name: "Alte Firma", kuerzel: "ALT" }, admin.id);
|
||
await kundeArchivieren(alt.id, true, admin.id);
|
||
|
||
const auswahl = await kundenZurAuswahl(ADMIN);
|
||
expect(auswahl.map((eintrag) => eintrag.kuerzel)).toEqual(["MUS"]);
|
||
});
|
||
});
|
||
});
|