Let the same choice open notes and drafts
The picker in the bell now governs both lists, so note_subscriptions is renamed to colleague_subscriptions -- a name that only mentions notes would mislead the next reader. Reading and writing a draft now reach differently far. hire_drafts_owner (for all) is split into four policies: select lets in your own drafts and those of the people you added, while insert/update/delete stay with the owner. A draft is unfinished work with no lock and no history; two people writing into the same row would overwrite each other silently. That split forces a change in the actions: a policy does not reject a write, it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row count instead of reporting success over a row that never changed. The card shows a foreign draft with its author and without Fortsetzen or Loeschen -- offering a button that reliably ends in a database error is a promise without cover. check-schema-types.mjs learns `alter table ... rename to`; without it the drift check reports one rename as two errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
136
tests/unit/entwuerfe.test.ts
Normal file
136
tests/unit/entwuerfe.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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("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",
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
it("erkennt den eigenen Entwurf", () => {
|
||||
const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
|
||||
expect(e.vonMir).toBe(true);
|
||||
});
|
||||
|
||||
it("nennt bei eigenen Entwürfen keinen Verfasser", () => {
|
||||
// Der eigene Name stünde an jeder Zeile und sagte nichts.
|
||||
const [e] = baueEntwuerfe([zeile({ created_by: ICH })], ICH);
|
||||
expect(e.autor).toBeNull();
|
||||
});
|
||||
|
||||
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("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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user