Let the same choice open notes and drafts
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s

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:
2026-09-10 16:05:40 +02:00
parent e8e675fd07
commit eeaf210e78
17 changed files with 650 additions and 54 deletions

View File

@@ -0,0 +1,134 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { ToastProvider } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
const openWizard = vi.fn();
const deleteHireDraft = vi.fn<(id: string) => Promise<{ success: boolean; error?: string }>>(async () => ({
success: true,
}));
const refresh = vi.fn();
vi.mock("@/components/hire/HireWizardContext", () => ({ useHireWizard: () => ({ openWizard }) }));
vi.mock("@/actions/hireDrafts", () => ({ deleteHireDraft: (id: string) => deleteHireDraft(id) }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) }));
// Seit die Auswahl in der Glocke auch die Entwürfe steuert, stehen hier
// fremde Zeilen. Sie dürfen aussehen wie das, was sie sind: zum Ansehen.
// Böte die Karte an ihnen „Fortsetzen", liefe die Person durch den ganzen
// Assistenten und bekäme am Ende eine Fehlermeldung aus der Datenbank.
function entwurf(teil: Partial<Entwurf> = {}): Entwurf {
return {
id: "d1",
step: 2,
payload: { firstName: "Manuel", lastName: "Aigner" },
updated_at: "2026-09-09T08:00:00.000Z",
vonMir: true,
autor: null,
...teil,
};
}
const fremd = entwurf({ id: "d2", payload: { firstName: "Eva", lastName: "Novak" }, vonMir: false, autor: "Anna Berger" });
function zeige(drafts: Entwurf[]) {
return render(
<ToastProvider>
<DraftsCard drafts={drafts} />
</ToastProvider>
);
}
/** Die Zeile zu einem Namen — die Karte zeigt mehrere nebeneinander. */
function zeile(name: string) {
return screen.getByText(name).closest("li") as HTMLElement;
}
beforeEach(() => {
openWizard.mockClear();
deleteHireDraft.mockClear();
deleteHireDraft.mockResolvedValue({ success: true });
refresh.mockClear();
});
describe("DraftsCard", () => {
it("zeigt zu eigenen Entwürfen Fortsetzen und Löschen", () => {
zeige([entwurf()]);
const z = zeile("Manuel Aigner");
expect(within(z).getByRole("button", { name: "Fortsetzen" })).toBeInTheDocument();
expect(within(z).getByRole("button", { name: "Entwurf löschen" })).toBeInTheDocument();
});
it("nennt an eigenen Entwürfen keinen Verfasser", () => {
// Der eigene Name stünde an jeder Zeile und sagte nichts.
zeige([entwurf()]);
expect(within(zeile("Manuel Aigner")).queryByText(/^von /)).not.toBeInTheDocument();
});
it("nennt an fremden Entwürfen, von wem sie stammen", () => {
zeige([fremd]);
expect(within(zeile("Eva Novak")).getByText("von Anna Berger")).toBeInTheDocument();
});
it("bietet an fremden Entwürfen weder Fortsetzen noch Löschen", () => {
// Die Datenbank wiese beides ab (hire_drafts_update, hire_drafts_delete).
// Eine Schaltfläche, die verlässlich in einen Fehler führt, ist ein
// Versprechen ohne Deckung.
zeige([fremd]);
const z = zeile("Eva Novak");
expect(within(z).queryByRole("button", { name: "Fortsetzen" })).not.toBeInTheDocument();
expect(within(z).queryByRole("button", { name: "Entwurf löschen" })).not.toBeInTheDocument();
});
it("sagt an fremden Entwürfen, warum dort nichts steht", () => {
// Ohne den Hinweis sähe die Zeile aus wie eine, an der die Schaltflächen
// fehlen.
zeige([fremd]);
expect(within(zeile("Eva Novak")).getByText("Nur zur Ansicht")).toBeInTheDocument();
});
it("trennt die Zeilen: eigene bedienbar, fremde nicht", () => {
// Beide nebeneinander — der Fall, in dem eine Verwechslung teuer wird.
zeige([entwurf(), fremd]);
expect(within(zeile("Manuel Aigner")).getByRole("button", { name: "Fortsetzen" })).toBeInTheDocument();
expect(within(zeile("Eva Novak")).queryByRole("button", { name: "Fortsetzen" })).not.toBeInTheDocument();
});
it("setzt beim Klick genau den angeklickten Entwurf fort", () => {
const user = userEvent.setup();
zeige([entwurf(), fremd]);
return user.click(within(zeile("Manuel Aigner")).getByRole("button", { name: "Fortsetzen" })).then(() => {
expect(openWizard).toHaveBeenCalledWith({ draftId: "d1" });
});
});
it("löscht genau den angeklickten Entwurf", async () => {
// Zwei eigene nebeneinander: eine Karte, die immer den ersten löscht,
// fiele mit nur einer Zeile nicht auf.
const user = userEvent.setup();
zeige([entwurf(), entwurf({ id: "d3", payload: { firstName: "Lena", lastName: "Wolf" } })]);
await user.click(within(zeile("Lena Wolf")).getByRole("button", { name: "Entwurf löschen" }));
expect(deleteHireDraft).toHaveBeenCalledWith("d3");
});
it("meldet den Fehler, wenn das Löschen abgewiesen wird", async () => {
deleteHireDraft.mockResolvedValue({ success: false, error: "Gehört jemand anderem." });
const user = userEvent.setup();
zeige([entwurf()]);
await user.click(screen.getByRole("button", { name: "Entwurf löschen" }));
expect(await screen.findByText("Gehört jemand anderem.")).toBeInTheDocument();
});
it("bleibt bei leerer Liste unsichtbar", () => {
// Eine Überschrift über nichts ist eine Zeile, die jeden Tag gelesen und
// jeden Tag verworfen wird.
// Nicht auf einen leeren Container prüfen: der ToastProvider legt
// seinen eigenen Kasten an, und der stünde da, egal was die Karte tut.
zeige([]);
expect(screen.queryByRole("heading", { name: /Entwürfe/ })).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});

View 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" });
});
});

View File

@@ -48,7 +48,7 @@ describe("sichtbareNotizen", () => {
const s = sql();
expect(s).toContain("exists");
expect(s).not.toContain("not exists");
expect(s).toContain("from note_subscriptions s");
expect(s).toContain("from colleague_subscriptions s");
expect(s).toContain("s.user_id = $");
expect(s).toContain("s.author_user_id = \"n\".\"author_user_id\"");
});