feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Eine veroeffentlichte Vorlagen-Version ist fuer immer unveraenderlich — daran
haengt, dass ein archivierter Lauf in zehn Jahren noch genau die Schritte zeigt,
mit denen er gestartet wurde. Die Regel steht deshalb zweimal: im Service, der
die verstaendliche Meldung gibt, und in der Datenbank, die die Garantie gibt.
Vier Trigger fangen Schreibzugriffe ab, die nicht durch den Service kommen —
Migrationen, psql, der Seed und ab Phase 4 die Connectoren — und versperren den
Rueckweg nach ENTWURF, der alles andere ausgehebelt haette.

- Service: anlegen, Kopfdaten bearbeiten, Entwurf als tiefe Kopie der letzten
  veroeffentlichten Version, speichern, veroeffentlichen, verwerfen,
  duplizieren, archivieren
- Hoechstens ein Entwurf je Vorlage, durchgesetzt vom partiellen Index; zwei
  gleichzeitige Klicks auf „Bearbeiten“ bekommen denselben Entwurf statt eines
  Fehlers
- Optimistisches Sperren ueber geaendertAm: wer auf einem ueberholten Stand
  speichert, wird abgewiesen statt die Arbeit des anderen spurlos zu ersetzen
- JSON-Export und -Import samt Format-Dokumentation; der Export ist nachsichtig,
  der Import streng
- src/lib/validierung.ts als einzige Zod-Quelle mit deutschen Meldungen, per
  ESLint-Regel erzwungen

Der unabhaengige Review fand drei Blocker, alle an der Mandantengrenze: der
Suchfilter brachte ein zweites OR mit und ueberschrieb die Kundenbedingung,
Historie und Aufbau einer Version filterten gar nicht. Zu queries/vorlagen.ts
gab es keinen Test — jetzt gibt es tests/vorlagen-sicht.test.ts.

428 Tests gruen (vorher 336).

Schliesst #009

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 08:09:21 +02:00
co-authored by Claude Opus 5
parent ecc3c579df
commit abacad1cb7
41 changed files with 4006 additions and 104 deletions
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
versionMitAufbau,
versionsHistorie,
vorlagenFilterwerte,
vorlagenListe,
} from "@/server/queries/vorlagen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Mandantensicht auf die Vorlagen (#009).
*
* Diese Datei gibt es, weil der unabhängige Review zwei Lücken gefunden hat, die
* kein Test sehen konnte: der Suchfilter hat die Kundenbedingung überschrieben
* (zwei `OR`-Schlüssel im selben Objekt, der zweite gewinnt), und Historie wie
* Aufbau einer Version haben überhaupt nicht gefiltert. Beides fiel nicht auf,
* weil es zu `src/server/queries/vorlagen.ts` keinen einzigen Test gab.
*/
async function aufbau() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const techniker = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Techniker", rolle: "TECHNIKER" },
});
await testPrisma.benutzerKunde.create({
data: { benutzerId: techniker.id, kundeId: meiner.id },
});
async function vorlageMit(name: string, kundeId: string | null) {
const vorlage = await testPrisma.vorlage.create({
data: { name, kategorie: "Geheim", schlagworte: ["intern"], kundeId },
});
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: `Abschnitt von ${name}` },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ein Schritt" },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return { vorlage, versionId: version.id };
}
return {
techniker,
admin: { id: "admin", rolle: "ADMIN" as const },
meine: await vorlageMit("Geheimprojekt Meiner", meiner.id),
fremde: await vorlageMit("Geheimprojekt Fremder", fremder.id),
globale: await vorlageMit("Geheimprojekt Global", null),
eigene,
};
}
describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
beforeEach(async () => {
await datenbankLeeren();
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
});
describe("Liste", () => {
it("zeigt einem gebundenen Techniker nur seine und die globalen Vorlagen", async () => {
const { techniker } = await aufbau();
const namen = (await vorlagenListe(techniker)).map((z) => z.name).sort();
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
});
it("lässt die Mandantengrenze auch beim Suchen stehen", async () => {
// Genau der Blocker: der Suchfilter brachte ein zweites `OR` mit und
// überschrieb die Kundenbedingung — ein Buchstabe im Suchfeld genügte.
const { techniker } = await aufbau();
const namen = (await vorlagenListe(techniker, { suche: "Geheim" })).map((z) => z.name).sort();
expect(namen).toEqual(["Geheimprojekt Global", "Geheimprojekt Meiner"]);
expect(namen).not.toContain("Geheimprojekt Fremder");
});
it("lässt sie auch bei Kategorie- und Schlagwortfilter stehen", async () => {
const { techniker } = await aufbau();
for (const filter of [{ kategorie: "Geheim" }, { schlagwort: "intern" }]) {
const namen = (await vorlagenListe(techniker, filter)).map((z) => z.name);
expect(namen, JSON.stringify(filter)).not.toContain("Geheimprojekt Fremder");
}
});
it("lässt sie auch bei allen Filtern gleichzeitig stehen", async () => {
const { techniker } = await aufbau();
const namen = (
await vorlagenListe(techniker, {
suche: "Geheim",
kategorie: "Geheim",
schlagwort: "intern",
auchArchivierte: true,
})
).map((z) => z.name);
expect(namen).not.toContain("Geheimprojekt Fremder");
});
it("zeigt einem Administrator alles", async () => {
const { admin } = await aufbau();
expect(await vorlagenListe(admin, { suche: "Geheim" })).toHaveLength(3);
});
it("blendet fremde Kategorien und Schlagworte aus den Filterwerten aus", async () => {
const { techniker, fremde } = await aufbau();
await testPrisma.vorlage.update({
where: { id: fremde.vorlage.id },
data: { kategorie: "Nur-fuer-Fremde", schlagworte: ["geheimwort"] },
});
const werte = await vorlagenFilterwerte(techniker);
expect(werte.kategorien).not.toContain("Nur-fuer-Fremde");
expect(werte.schlagworte).not.toContain("geheimwort");
});
});
describe("Historie und Aufbau", () => {
it("zeigt die Historie einer fremden Vorlage nicht", async () => {
const { techniker, fremde, meine } = await aufbau();
expect(await versionsHistorie(fremde.vorlage.id, techniker)).toEqual([]);
expect(await versionsHistorie(meine.vorlage.id, techniker)).toHaveLength(1);
});
it("gibt den Aufbau einer fremden Version nicht heraus", async () => {
// Sonst stünden Abschnittstitel, Schritte und Befehle eines fremden Kunden
// offen — die Vorlage ist der innere Ablauf seines Betriebs.
const { techniker, fremde, meine, globale } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeUndefined();
expect(await versionMitAufbau(meine.versionId, techniker)).toBeDefined();
expect(await versionMitAufbau(globale.versionId, techniker)).toBeDefined();
});
it("gibt einem Administrator alles heraus", async () => {
const { admin, fremde } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, admin)).toBeDefined();
});
it("schränkt ohne gesetztes Präfix niemanden ein", async () => {
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
const { techniker, fremde } = await aufbau();
expect(await versionMitAufbau(fremde.versionId, techniker)).toBeDefined();
expect(await vorlagenListe(techniker)).toHaveLength(3);
});
});
});