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

Welche Vorlagen auf der Geraeteseite als Kachel erscheinen, stand nur im Seed.
Wer eine eigene Vorlage dort haben wollte, kam nicht heran; wer eine
mitgelieferte loswerden wollte, auch nicht.

Neue Seite Verwaltung -> Schnellstart: je Geraetetyp eine Liste, Reihenfolge
ueber Hoch und Runter. Kein Drag & Drop -- der Editor braucht dnd-kit fuer
hundert Schritte, hier sind es zwei bis vier Kacheln, und zwei Knoepfe sind am
Handy verlaesslicher als eine Ziehgeste neben einer scrollenden Seite.

Gespeichert wird die ganze Liste; position kommt aus ihrer Reihenfolge, damit es
nur eine Wahrheit darueber gibt. Zur Auswahl stehen nur veroeffentlichte, nicht
archivierte Vorlagen.

Einstellung.schnellstartGepflegt haelt den Seed heraus, sobald jemand
gespeichert hat -- auch bei unveraenderter Liste. Eine fehlende Zeile sagt
nicht, ob sie noch nie da war oder bewusst entfernt wurde.

Das Durchklicken des gebauten Stands fand einen Fehler, den die Suite nicht
gesehen haette: die Umsortierung wurde nicht gespeichert. Altdaten tragen
mehrere Zeilen auf position 0, und bei gleicher Position ist die Reihenfolge in
Postgres nicht festgelegt -- Lesemodell und Dienst gaben sie unterschiedlich
zurueck, und der Vergleich hielt die Umsortierung fuer einen Leerlauf. Behoben
durch einen eindeutigen Tiebreak in beiden Abfragen, mit Regressionstest.

Closes #34
This commit is contained in:
Florian Wilde
2026-10-04 17:40:46 +02:00
parent 0060a55387
commit 4bd34c4233
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);