Files
alpenwerk-hr/tests/unit/entwuerfe.test.ts
Maximilian Stubhan 99e4357c02
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m18s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m45s
Let colleagues finish each other's drafts, one at a time
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.

A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.

The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.

Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.

Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.

Not run against a live database here; the CI migration job is the first
real execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:43:43 +02:00

187 lines
7.6 KiB
TypeScript

import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
import { describe, expect, it } from "vitest";
import type { Schema } from "@/lib/db/schema";
import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "@/lib/entwuerfe";
// Wessen Entwürfe jemand sieht — und, wichtiger, an welchen die Schaltflächen
// hängen. Die Regeln in der Datenbank sind die Grenze; hier wird gelesen, was
// die Abfrage daraus macht, weil ein Fehler darin nicht auffiele: die Karte
// zeigte eine Liste, nur die falsche.
const db = new Kysely<Schema>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DummyDriver(),
createIntrospector: (d) => new PostgresIntrospector(d),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
});
const ICH = "11111111-1111-1111-1111-111111111111";
const ANDERE = "22222222-2222-2222-2222-222222222222";
// entwuerfeAbfrage erwartet den Ausdrucksbauer aus selectNoFrom — genau so
// hängt lib/dashboard-data.ts sie ein.
function abfrage(userId = ICH) {
return db.selectNoFrom((eb) => [entwuerfeAbfrage(eb, userId).as("x")]).compile();
}
const text = (userId?: string) => abfrage(userId).sql.replace(/\s+/g, " ");
describe("entwuerfeAbfrage", () => {
it("zeigt die eigenen Entwürfe", () => {
expect(text()).toContain('"d"."created_by" = $');
});
it("holt dazu, wer hinzugewählt wurde", () => {
// Vorzeichen und Tabelle zusammen: ein `not exists` kehrte die Bedeutung
// um, ohne dass ein Wort sich änderte.
const s = text();
expect(s).toContain("exists");
expect(s).not.toContain("not exists");
expect(s).toContain("from colleague_subscriptions s");
expect(s).toContain('s.author_user_id = "d"."created_by"');
});
it("verknüpft eigene und hinzugewählte mit ODER, nicht mit UND", () => {
// Mit UND bliebe die Liste immer leer: kein Entwurf ist gleichzeitig von
// mir und von jemandem, den ich hinzugewählt habe.
expect(text()).toMatch(/"d"\."created_by" = \$\d+ or exists/);
});
it("holt den Namen der verfassenden Person dazu", () => {
// Ohne ihn stünde an einem fremden Entwurf nur, dass er fremd ist.
const s = text();
expect(s).toContain('left join "profiles" as "p" on "p"."id" = "d"."created_by"');
expect(s).toContain('"p"."full_name" as "author_name"');
expect(s).toContain('"p"."email" as "author_email"');
});
it("stellt die eigenen Entwürfe nach vorn", () => {
// Vor der Freigabe standen dort nur die eigenen; wer seinen halbfertigen
// Entwurf sucht, soll ihn nicht zwischen fremden suchen.
const s = text();
expect(s).toMatch(/order by case when "d"\."created_by" = \$\d+ then 0 else 1 end/);
expect(s).toContain('"d"."updated_at" desc');
});
it("bindet die Kennung als Parameter, nicht in den Text", () => {
const { sql: roh, parameters } = abfrage("bösartig'; drop table hire_drafts; --");
expect(roh).not.toContain("drop table");
expect(parameters).toContain("bösartig'; drop table hire_drafts; --");
});
it("lässt die Datenbank entscheiden, ob die Sperre noch gilt", () => {
// Mit der Uhr des Servers, der gerade rendert, gerechnet wäre es eine
// zweite Uhr und eine zweite Fassung der Frist — die Regeln in der
// Datenbank benutzen dieselbe Funktion.
const s = text();
expect(s).toContain('not app_entwurf_frei("d"."locked_by", "d"."locked_at")');
expect(s).toContain('left join "profiles" as "sp" on "sp"."id" = "d"."locked_by"');
});
it("filtert gesperrte Entwürfe nicht heraus", () => {
// Sie sollen dastehen, nur nicht anfassbar sein. Verschwänden sie,
// suchte man einen Entwurf, den es gibt.
expect(text()).not.toMatch(/where[^;]*locked_by/);
});
it("formatiert den Zeitstempel innerhalb von JSON ausdrücklich", () => {
// In JSON gibt Postgres ihn anders aus als der Treiber es täte, und der
// Unterschied fällt erst beim Vergleichen zweier Listen auf.
expect(text()).toContain("to_char(");
});
});
describe("baueEntwuerfe", () => {
function zeile(teil: Partial<EntwurfZeile> = {}): EntwurfZeile {
return {
id: "d1",
step: 2,
payload: { firstName: "Manuel", lastName: "Aigner" },
updated_at: "2026-09-09T08:00:00.000Z",
created_by: ANDERE,
author_name: "Anna Berger",
author_email: "a@example.test",
gesperrt: false,
sperrer_name: null,
sperrer_email: null,
...teil,
};
}
it("erkennt den eigenen Entwurf", () => {
const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
expect(e.vonMir).toBe(true);
});
it("nennt den Verfasser auch am eigenen Entwurf", () => {
// Die Karte schreibt daran zwar „von mir"; die Angabe steht trotzdem
// bereit, damit die Zeile nicht davon abhängt, wer sie ansieht.
const [e] = baueEntwuerfe([zeile({ created_by: ICH, author_name: "Max Stubhan" })], ICH);
expect(e.autor).toBe("Max Stubhan");
});
it("nennt bei fremden Entwürfen den Namen", () => {
const [e] = baueEntwuerfe([zeile()], ICH);
expect(e.vonMir).toBe(false);
expect(e.autor).toBe("Anna Berger");
});
it("fällt ohne Namen auf die E-Mail zurück", () => {
// Auch ein Name aus Leerzeichen zählt als keiner.
expect(baueEntwuerfe([zeile({ author_name: " " })], ICH)[0].autor).toBe("a@example.test");
expect(baueEntwuerfe([zeile({ author_name: null })], ICH)[0].autor).toBe("a@example.test");
});
it("nennt jemanden, auch wenn beides fehlt", () => {
// Ein Entwurf ohne jede Zuordnung wäre einer, bei dem niemand weiss, wen
// er fragen soll.
expect(baueEntwuerfe([zeile({ author_name: null, author_email: null })], ICH)[0].autor).toBe("Unbekannt");
});
it("hält ohne Anmeldung nichts für eigen", () => {
// created_by kann leer sein, userId auch. Beide leer heisst nicht „meiner"
// — sonst hinge an einer herrenlosen Zeile eine Löschen-Schaltfläche.
const [e] = baueEntwuerfe([zeile({ created_by: null })], null);
expect(e.vonMir).toBe(false);
});
it("lässt einen freien Entwurf ohne Sperrvermerk", () => {
// Leer heisst hier „bearbeitbar" — die Karte hängt daran, ob sie
// „Fortsetzen" anbietet.
expect(baueEntwuerfe([zeile()], ICH)[0].gesperrtVon).toBeNull();
});
it("nennt, wer den Entwurf gerade offen hat", () => {
const [e] = baueEntwuerfe([zeile({ gesperrt: true, sperrer_name: "Bernd Huber" })], ICH);
expect(e.gesperrtVon).toBe("Bernd Huber");
});
it("fällt beim Sperrer ohne Namen auf die E-Mail zurück", () => {
const [e] = baueEntwuerfe([zeile({ gesperrt: true, sperrer_name: " ", sperrer_email: "b@example.test" })], ICH);
expect(e.gesperrtVon).toBe("b@example.test");
});
it("sagt auch ohne jeden Namen, dass gesperrt ist", () => {
// Sonst wirkte „Fortsetzen" grundlos abgeschaltet, und die nächste
// Handlung wäre, es gleich noch einmal zu versuchen.
const [e] = baueEntwuerfe([zeile({ gesperrt: true, sperrer_name: null, sperrer_email: null })], ICH);
expect(e.gesperrtVon).toBe("jemandem");
});
it("unterscheidet fremd von gesperrt", () => {
// Ein fremder Entwurf ist bearbeitbar, ein gesperrter nicht — würden die
// beiden verwechselt, wäre die ganze Freigabe wirkungslos.
const [e] = baueEntwuerfe([zeile()], ICH);
expect(e.vonMir).toBe(false);
expect(e.gesperrtVon).toBeNull();
});
it("reicht Inhalt und Zeitpunkt unverändert weiter", () => {
const [e] = baueEntwuerfe([zeile()], ICH);
expect(e).toMatchObject({ id: "d1", step: 2, updated_at: "2026-09-09T08:00:00.000Z" });
expect(e.payload).toEqual({ firstName: "Manuel", lastName: "Aigner" });
});
});