feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen (#44)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #44.
This commit is contained in:
2026-10-04 15:41:47 +00:00
parent 0060a55387
commit f5936327a0
17 changed files with 910 additions and 24 deletions
+264
View File
@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, GeraeteTyp, VersionStatus } from "@/generated/prisma/enums";
import { RECHTE } from "@/server/auth/rollen";
import { schnellstartPflege } from "@/server/queries/schnellstart";
import { SchnellstartFehler, schnellstartSetzen } from "@/server/services/schnellstart";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Schnellstart je Geraetetyp pflegen (#022).
*
* Dass die Kacheln auf der Geraeteseite richtig erscheinen, prueft
* `tests/geraeteseite.test.ts` — auch den Fall, dass eine archivierte Vorlage von
* selbst verschwindet. Hier geht es um das Pflegen: die Reihenfolge, die
* Abweisungen, das Protokoll, und die Marke, die den Seed heraushaelt.
*/
async function grundbestand() {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
return { kunde, adminId: admin.id };
}
/** Eine Vorlage mit veroeffentlichter Version 1 – nur die ist zuweisbar. */
async function vorlageAnlegen(
name: string,
optionen: { archiviert?: boolean; nurEntwurf?: boolean; kundeId?: string } = {},
): Promise<string> {
const vorlage = await testPrisma.vorlage.create({
data: {
name,
archiviert: optionen.archiviert ?? false,
...(optionen.kundeId ? { kundeId: optionen.kundeId } : {}),
},
});
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
if (!optionen.nurEntwurf) {
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
}
return vorlage.id;
}
async function listeVon(typ: GeraeteTyp): Promise<readonly string[]> {
const { typen } = await schnellstartPflege();
return typen.find((eintrag) => eintrag.typ === typ)?.zugewiesen.map((e) => e.name) ?? [];
}
describe("Recht auf den Schnellstart", () => {
it("liegt bei verwaltung, also nur bei ADMIN", () => {
// Die Zuordnung gilt global und bestimmt, was jeder Techniker an jedem Gerät
// zuerst sieht. TECHNIKER und LESER dürfen das nicht ändern.
expect(RECHTE.verwaltung).toEqual(["ADMIN"]);
});
});
describe.runIf(datenbankVerfuegbar)("Schnellstart pflegen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("setzt die Liste in der übergebenen Reihenfolge", async () => {
const { adminId } = await grundbestand();
const erste = await vorlageAnlegen("Erster Griff");
const zweite = await vorlageAnlegen("Zweiter Griff");
await schnellstartSetzen(GeraeteTyp.PC, [zweite, erste], adminId);
expect(await listeVon(GeraeteTyp.PC)).toEqual(["Zweiter Griff", "Erster Griff"]);
// Umsortieren heißt ersetzen, nicht ergänzen.
await schnellstartSetzen(GeraeteTyp.PC, [erste, zweite], adminId);
expect(await listeVon(GeraeteTyp.PC)).toEqual(["Erster Griff", "Zweiter Griff"]);
expect(await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } })).toBe(2);
});
it("sortiert auch um, wenn alte Zeilen dieselbe Position tragen", async () => {
/*
* Der Fall, den erst das Durchklicken des gebauten Stands zeigte. Altdaten
* tragen mehrere Zeilen auf `position` 0 — der Seed zaehlt je Vorgabe von 0,
* und ein Beispielskript legte eine zweite dazu. Bei gleicher Position ist
* die Reihenfolge in Postgres nicht festgelegt, und der Vergleich
* "unveraendert" hielt eine echte Umsortierung deshalb fuer einen Leerlauf:
* die Marke wurde gesetzt, geschrieben wurde nichts.
*/
const { adminId } = await grundbestand();
const a = await vorlageAnlegen("Alpha");
const b = await vorlageAnlegen("Beta");
await testPrisma.geraetetypVorlage.createMany({
data: [
{ geraeteTyp: GeraeteTyp.PC, vorlageId: a, position: 0 },
{ geraeteTyp: GeraeteTyp.PC, vorlageId: b, position: 0 },
],
});
// Genau die Reihenfolge schicken, die das Lesemodell zeigt – ein Mensch, der
// nichts umsortiert, aber auf Speichern drückt.
const gezeigt = await listeVon(GeraeteTyp.PC);
const nachName = new Map([
["Alpha", a],
["Beta", b],
]);
const umgekehrt = [...gezeigt].reverse().map((name) => nachName.get(name) ?? "");
await schnellstartSetzen(GeraeteTyp.PC, umgekehrt, adminId);
expect(await listeVon(GeraeteTyp.PC)).toEqual([...gezeigt].reverse());
// Und die Positionen stehen danach aufeinanderfolgend, nicht beide auf 0.
const zeilen = await testPrisma.geraetetypVorlage.findMany({
where: { geraeteTyp: GeraeteTyp.PC },
orderBy: { position: "asc" },
select: { position: true },
});
expect(zeilen.map((zeile) => zeile.position)).toEqual([0, 1]);
});
it("zieht beim Speichern alte, gleiche Positionen gerade", async () => {
// Ohne Aenderung gespeichert: die Liste bleibt, die Positionen werden
// aufeinanderfolgend. Aufraeumen, kein Fehlerfall (#022).
const { adminId } = await grundbestand();
const a = await vorlageAnlegen("Alpha");
const b = await vorlageAnlegen("Beta");
await testPrisma.geraetetypVorlage.createMany({
data: [
{ geraeteTyp: GeraeteTyp.PC, vorlageId: a, position: 0 },
{ geraeteTyp: GeraeteTyp.PC, vorlageId: b, position: 0 },
],
});
const gezeigt = await listeVon(GeraeteTyp.PC);
const nachName = new Map([
["Alpha", a],
["Beta", b],
]);
await schnellstartSetzen(
GeraeteTyp.PC,
gezeigt.map((name) => nachName.get(name) ?? ""),
adminId,
);
expect(await listeVon(GeraeteTyp.PC)).toEqual(gezeigt);
const zeilen = await testPrisma.geraetetypVorlage.findMany({
where: { geraeteTyp: GeraeteTyp.PC },
orderBy: { position: "asc" },
select: { position: true },
});
expect(zeilen.map((zeile) => zeile.position)).toEqual([0, 1]);
});
it("nimmt eine leere Liste als Angabe und entfernt alles", async () => {
const { adminId } = await grundbestand();
const vorlage = await vorlageAnlegen("Einziger Griff");
await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId);
await schnellstartSetzen(GeraeteTyp.PC, [], adminId);
expect(await listeVon(GeraeteTyp.PC)).toEqual([]);
});
it("lässt andere Gerätetypen unberührt", async () => {
const { adminId } = await grundbestand();
const vorlage = await vorlageAnlegen("Für beide");
await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId);
await schnellstartSetzen(GeraeteTyp.SERVER, [vorlage], adminId);
await schnellstartSetzen(GeraeteTyp.PC, [], adminId);
expect(await listeVon(GeraeteTyp.SERVER)).toEqual(["Für beide"]);
});
it("weist dieselbe Vorlage zweimal in einer Liste ab", async () => {
const { adminId } = await grundbestand();
const vorlage = await vorlageAnlegen("Einmal reicht");
await expect(schnellstartSetzen(GeraeteTyp.PC, [vorlage, vorlage], adminId)).rejects.toThrow(
SchnellstartFehler,
);
});
it("weist eine archivierte Vorlage ab", async () => {
const { adminId } = await grundbestand();
const weggelegt = await vorlageAnlegen("Weggelegt", { archiviert: true });
await expect(schnellstartSetzen(GeraeteTyp.PC, [weggelegt], adminId)).rejects.toThrow(/archiviert/);
});
it("weist eine Vorlage ohne veröffentlichte Version ab", async () => {
// Eine Kachel, die zu einem Entwurf führt, kann keinen Lauf starten.
const { adminId } = await grundbestand();
const entwurf = await vorlageAnlegen("Noch Entwurf", { nurEntwurf: true });
await expect(schnellstartSetzen(GeraeteTyp.PC, [entwurf], adminId)).rejects.toThrow(
/veröffentlichte Version/,
);
});
it("bietet zur Auswahl nur veröffentlichte, nicht archivierte Vorlagen", async () => {
const { kunde } = await grundbestand();
await vorlageAnlegen("Brauchbar");
await vorlageAnlegen("Weggelegt", { archiviert: true });
await vorlageAnlegen("Noch Entwurf", { nurEntwurf: true });
await vorlageAnlegen("Nur für einen Kunden", { kundeId: kunde.id });
const { auswahl } = await schnellstartPflege();
expect(auswahl.map((eintrag) => eintrag.name).sort()).toEqual(["Brauchbar", "Nur für einen Kunden"]);
// Eine kundengebundene Vorlage ist zuweisbar und wird als solche gezeigt;
// dass sie nur bei ihren Geräten erscheint, entscheidet das Lesen.
expect(auswahl.find((eintrag) => eintrag.name === "Nur für einen Kunden")?.kundeName).toBe(
"Eigene Organisation",
);
});
it("nennt jeden Gerätetyp, auch die ohne Zuordnung", async () => {
await grundbestand();
const { typen } = await schnellstartPflege();
expect(typen.map((eintrag) => eintrag.typ)).toEqual(Object.values(GeraeteTyp));
});
it("schreibt die Änderung mit Namen ins Protokoll, nicht mit Kennungen", async () => {
const { adminId } = await grundbestand();
const erste = await vorlageAnlegen("Erster Griff");
const zweite = await vorlageAnlegen("Zweiter Griff");
await schnellstartSetzen(GeraeteTyp.PC, [erste], adminId);
await schnellstartSetzen(GeraeteTyp.PC, [erste, zweite], adminId);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "GeraetetypVorlage" },
orderBy: { erstelltAm: "asc" },
});
expect(eintraege).toHaveLength(2);
expect(eintraege[1]?.aktion).toBe("Schnellstart für PC geändert");
expect(eintraege[1]?.vorher).toEqual({ vorlagen: ["Erster Griff"] });
expect(eintraege[1]?.nachher).toEqual({ vorlagen: ["Erster Griff", "Zweiter Griff"] });
// Ein Protokolleintrag muss auch lesbar sein, wenn die Vorlage später
// anders heißt – deshalb Namen und keine Kennungen.
expect(JSON.stringify(eintraege[1]?.nachher)).not.toContain(erste);
});
it("schreibt keinen Protokolleintrag, wenn sich die Liste nicht ändert", async () => {
const { adminId } = await grundbestand();
const vorlage = await vorlageAnlegen("Unverändert");
await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId);
await schnellstartSetzen(GeraeteTyp.PC, [vorlage], adminId);
expect(await testPrisma.protokoll.count({ where: { entitaet: "GeraetetypVorlage" } })).toBe(1);
});
it("setzt die Marke, die den Seed heraushält – auch bei unveränderter Liste", async () => {
const { adminId } = await grundbestand();
const vorlage = await vorlageAnlegen("Einziger Griff");
expect(
(await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } })).schnellstartGepflegt,
).toBe(false);
// Ein Speichern, das nichts ändert, ist trotzdem die Aussage „ich pflege das
// selbst“ — sonst legte der Seed seine Vorgaben weiter nach.
await schnellstartSetzen(GeraeteTyp.PC, [], adminId);
expect(
(await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } })).schnellstartGepflegt,
).toBe(true);
expect(vorlage).toBeTruthy();
});
});
+30
View File
@@ -414,6 +414,36 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0);
});
it("laesst den Schnellstart in Ruhe, sobald er in der Oberflaeche gepflegt wurde", async () => {
// Der Fall aus #022: wer eine Kachel entfernt, soll sie nicht beim naechsten
// Seed-Lauf zurueckbekommen. Eine fehlende Zeile sagt nicht, ob sie noch nie
// da war oder bewusst entfernt wurde — die Marke sagt es.
const gesaet = await vorlagenSaeen(testPrisma);
expect(await typVorgabenSaeen(testPrisma, gesaet)).toBeGreaterThan(0);
await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } });
// upsert, weil dieser Block die Einstellung nicht selbst anlegt - und genau
// deshalb belegt der Test auch, dass typVorgabenSaeen ohne sie zurechtkommt.
await testPrisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", schnellstartGepflegt: true },
update: { schnellstartGepflegt: true },
});
expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(0);
expect(await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } })).toBe(0);
});
it("legt die Vorgaben weiter an, solange niemand sie gepflegt hat", async () => {
const gesaet = await vorlagenSaeen(testPrisma);
await typVorgabenSaeen(testPrisma, gesaet);
const vorher = await testPrisma.geraetetypVorlage.count({ where: { geraeteTyp: GeraeteTyp.PC } });
expect(vorher).toBeGreaterThan(0);
await testPrisma.geraetetypVorlage.deleteMany({ where: { geraeteTyp: GeraeteTyp.PC } });
expect(await typVorgabenSaeen(testPrisma, gesaet)).toBe(vorher);
});
it("legt Abteilungen, Personen, Geraete und freie Tags an", async () => {
const eigene = await eigeneOrganisation();
const bestand = await stammdatenSaeen(testPrisma, eigene.id);