import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely"; import type { Schema } from "@/lib/db/schema"; import { describe, expect, it } from "vitest"; import { SORTIERFELDER, STANDARD_FELD, STANDARD_RICHTUNG, naechsteRichtung, parseFeld, parseRichtung, sortiere, type Sortierfeld, } from "@/lib/employee-sort"; // Feld und Richtung stehen in der Adresse, und dort kann alles stehen: eine // veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie dürfen // die Liste weder leeren noch in einer Reihenfolge zeigen, die niemand // gewählt hat — und schon gar nicht in die Abfrage durchschlagen. describe("parseFeld", () => { it("nimmt jedes angebotene Feld", () => { // Aus der Liste abgeleitet, nicht abgeschrieben: käme eine achte Spalte // dazu, prüft dieser Test sie mit, ohne dass jemand daran denken muss. for (const f of SORTIERFELDER) { expect(parseFeld(f.value)).toBe(f.value); } }); it("fällt ohne Angabe auf den Namen zurück", () => { expect(parseFeld(undefined)).toBe(STANDARD_FELD); expect(parseFeld("")).toBe(STANDARD_FELD); expect(STANDARD_FELD).toBe("name"); }); it("lässt sich keine Spalte unterschieben", () => { // Das Feld entscheidet, welcher SQL-Ausdruck in die Sortierung geht. expect(parseFeld("Name")).toBe(STANDARD_FELD); expect(parseFeld("sv_nummer")).toBe(STANDARD_FELD); expect(parseFeld("id; drop table employees")).toBe(STANDARD_FELD); }); }); describe("parseRichtung", () => { it("kennt genau zwei Richtungen", () => { expect(parseRichtung("asc")).toBe("asc"); expect(parseRichtung("desc")).toBe("desc"); }); it("fällt auf aufsteigend zurück", () => { expect(parseRichtung(undefined)).toBe(STANDARD_RICHTUNG); expect(parseRichtung("DESC")).toBe(STANDARD_RICHTUNG); expect(parseRichtung("absteigend")).toBe(STANDARD_RICHTUNG); expect(STANDARD_RICHTUNG).toBe("asc"); }); }); describe("naechsteRichtung", () => { it("dreht die Richtung, wenn dieselbe Spalte nochmal angeklickt wird", () => { expect(naechsteRichtung("name", "asc", "name")).toBe("desc"); expect(naechsteRichtung("name", "desc", "name")).toBe("asc"); }); it("beginnt bei einer anderen Spalte wieder aufsteigend", () => { // Wer von „Eintritt, neueste zuerst" auf „Name" wechselt, will Namen von // A an — nicht die Richtung der vorigen Spalte geerbt. expect(naechsteRichtung("eintritt", "desc", "name")).toBe("asc"); expect(naechsteRichtung("status", "desc", "persnr")).toBe("asc"); }); it("liefert für jede Kombination eine der beiden Richtungen", () => { // Die Richtung geht unmittelbar in die Abfrage; etwas anderes als asc // oder desc wäre dort ein Fehler zur Laufzeit, nicht beim Bauen. const felder = SORTIERFELDER.map((f) => f.value as Sortierfeld); for (const a of felder) { for (const b of felder) { for (const r of ["asc", "desc"] as const) { expect(["asc", "desc"]).toContain(naechsteRichtung(a, r, b)); } } } }); }); describe("SORTIERFELDER", () => { it("deckt jede Spalte der Liste ab", () => { // Die Kopfzeile wird aus dieser Liste erzeugt. Fehlt eine Spalte hier, // fehlt sie in der Tabelle — und eine überzählige erzeugt einen Kopf // ohne Inhalt darunter. expect(SORTIERFELDER.map((f) => f.label)).toEqual([ "Mitarbeiter:in", "Pers.-Nr.", "Bereich/Team", "Standort", "Eintritt", "Beschäftigung", "Status", ]); }); it("vergibt jeden Wert nur einmal", () => { const werte = SORTIERFELDER.map((f) => f.value); expect(new Set(werte).size).toBe(werte.length); }); }); // ── Das erzeugte SQL ──────────────────────────────────────────────────── // // Die Sortierung ist der einzige Teil dieser Seite, bei dem ein Fehler // stumm bleibt: die Liste zeigt weiter fünfzehn Zeilen, nur die falschen. // Deshalb wird hier nicht das Verhalten beschrieben, sondern die Abfrage // gelesen, die tatsächlich herauskommt. const db = new Kysely({ dialect: { createAdapter: () => new PostgresAdapter(), createDriver: () => new DummyDriver(), createIntrospector: (d) => new PostgresIntrospector(d), createQueryCompiler: () => new PostgresQueryCompiler(), }, }); const STICHTAG = "2026-09-15"; const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") => sortiere(db.selectFrom("employees").select("id"), feld, richtung, STICHTAG) .compile() .sql.replace(/^.*?order by /s, "") .replace(/\s+/g, " "); /** * Die Sortierkriterien einzeln — nur an den Kommas *ausserhalb* von Klammern * getrennt. * * Ein einfaches `split(", ")` zerschneidet die Unterabfrage für den Bereich: * sie enthält selbst Kommas, in `coalesce(a, b, c)`. */ function kriterien(orderBy: string): string[] { const teile: string[] = []; let tiefe = 0; let laufend = ""; for (const c of orderBy) { if (c === "(") tiefe++; else if (c === ")") tiefe--; if (c === "," && tiefe === 0) { teile.push(laufend.trim()); laufend = ""; continue; } laufend += c; } if (laufend.trim()) teile.push(laufend.trim()); return teile; } describe("das erzeugte SQL", () => { it("sortiert Namen nach Nachname, dann Vorname", () => { expect(ordnung("name", "asc")).toBe( '"last_name" asc nulls last, "first_name" asc nulls last, "id"' ); }); it("dreht beide Namensteile mit", () => { // Nur den Nachnamen zu drehen ergäbe Z–A über die Nachnamen, aber A–Z // innerhalb einer Namensgruppe — sichtbar bei den fünfzehn Aigner. expect(ordnung("name", "desc")).toBe( '"last_name" desc nulls last, "first_name" desc nulls last, "id"' ); }); it("hängt an jede Spalte die Kennung als letztes Kriterium", () => { // Ohne sie ist die Reihenfolge bei gleichen Werten unbestimmt, und // dieselbe Person kann auf zwei Seiten stehen oder auf keiner. for (const f of SORTIERFELDER) { for (const r of ["asc", "desc"] as const) { expect(ordnung(f.value, r), `${f.value}/${r}`).toMatch(/, "id"$/); } } }); it("lässt Unbekanntes immer am Ende stehen", () => { // Ohne `nulls last` stünden bei absteigender Sortierung alle Personen // ohne Planstelle oder Standort ganz oben. for (const f of SORTIERFELDER) { for (const r of ["asc", "desc"] as const) { const teile = kriterien(ordnung(f.value, r)).slice(0, -1); // ohne "id" expect(teile.length, `${f.value}/${r}`).toBeGreaterThan(0); for (const teil of teile) { expect(teil, `${f.value}/${r}: ${teil}`).toContain("nulls last"); } } } }); it("holt Bereich und Team über die laufende Besetzung", () => { const sql = ordnung("bereich", "asc"); expect(sql).toContain("position_assignments"); expect(sql).toContain("pa.valid_to is null"); // Der Bereich ist die Ebene unter der Gesellschaft, nicht die Einheit // selbst — sonst stünde in der Spalte etwas anderes als sortiert wird. expect(sql).toContain("unit_type = 'Bereich'"); // Und über *alle drei* Ebenen: wer in einem Team sitzt, hat den Bereich // zwei Sprünge über sich. Bliebe nur die eigene Einheit übrig, stünden // alle Teammitglieder ohne Bereich da und rutschten ans Listenende. // // Dass drei Ebenen reichen, ist eine Aussage über die Daten (der // Aufzählungstyp org_unit_type kennt genau vier Stufen) und lässt sich // hier nicht prüfen — dass sie überhaupt abgefragt werden, schon. expect(sql).toContain("ou.unit_type = 'Bereich'"); expect(sql).toContain("e1.unit_type = 'Bereich'"); expect(sql).toContain("e2.unit_type = 'Bereich'"); }); it("sortiert den Standort nach seinem Namen, nicht nach seiner Kennung", () => { // location_id ist eine UUID. Danach zu sortieren sähe aus wie Zufall. expect(ordnung("standort", "asc")).toContain("select name from locations"); }); it("nimmt bei der Beschäftigung die Stunden als zweites Kriterium", () => { const sql = ordnung("beschaeftigung", "asc"); expect(sql.indexOf("employment_type")).toBeLessThan(sql.indexOf("weekly_hours")); }); it("setzt keinen Wert aus der Adresse in die Abfrage", () => { // Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus // der Adresse hierher, stünden sie im SQL statt als Parameter. // // Gebunden wird genau ein Wert, und er kommt nicht aus der Adresse: der // Stichtag der Statusableitung. Deshalb wird er hier namentlich // zugelassen und alles andere ausgeschlossen — „gar keine Parameter" // wäre die schärfere Zusicherung, aber die falsche. for (const f of SORTIERFELDER) { const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc", STICHTAG).compile(); expect(new Set(parameters), f.value).toEqual(new Set(f.value === "status" ? [STICHTAG] : [])); } }); // ── Status: abgeleitet, nicht aus der Spalte gelesen ────────────────── // // Die Zeile zeigt den zum Stichtag abgeleiteten Status. Nach // `employees.status` zu sortieren hiesse, nach einem Wert zu ordnen, der // nirgends auf der Seite steht — eine als „Ausgetreten" beschriftete Person // stünde mitten unter den aktiven. describe("Status", () => { it("ordnet nach der Ableitung und nicht nach der Spalte", () => { const sql = ordnung("status", "asc"); expect(sql).toContain("exit_date"); expect(sql).toContain("entry_date"); expect(sql).toContain("karenz_start_date"); expect(sql).not.toContain('"status"'); }); it("hält die Reihenfolge des Dienstverhältnisses ein", () => { // Aktiv (1), Karenz (2), Geplant (3), Ausgetreten (4) — dieselbe // Folge wie im Aufzählungstyp, und aussagekräftiger als alphabetisch. const sql = ordnung("status", "asc"); expect(sql.indexOf("then 4")).toBeLessThan(sql.indexOf("then 3")); expect(sql.indexOf("then 3")).toBeLessThan(sql.indexOf("then 2")); expect(sql).toContain("else 1"); }); it("erkennt den Nichtantritt wie die Ableitung", () => { // exit_date <= entry_date: kein einziger Tag Beschäftigung. Fehlte der // Vergleich, sortierte ein künftiger Nichtantritt unter „Geplant", // während sein Chip „Ausgetreten" zeigt. expect(ordnung("status", "asc")).toContain("exit_date <= entry_date"); }); }); });