Files
alpenwerk-hr/tests/unit/notes-visibility.test.ts
Maximilian Stubhan eeaf210e78
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
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>
2026-09-10 16:05:40 +02:00

124 lines
4.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 { sichtbareNotizen } from "@/lib/notes";
import { baueKollegen } from "@/lib/shell-data";
// Wessen Notizen jemand sieht, ist die eine Stelle, an der ein Fehler nicht
// auffällt: die Glocke zeigt weiter eine Zahl, nur die falsche. Zu wenig, und
// eine Wiedervorlage bleibt liegen; zu viel, und die Einstellung wirkt nicht.
// Deshalb wird hier die Abfrage gelesen, die tatsächlich herauskommt.
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";
function abfrage(userId = ICH) {
return db
.selectFrom("employee_notes as n")
.select("n.id")
.where(sichtbareNotizen(userId))
.compile();
}
const sql = (userId?: string) => abfrage(userId).sql.replace(/\s+/g, " ");
describe("sichtbareNotizen", () => {
it("lässt Notizen ohne Verfasser durch", () => {
// author_user_id kann leer sein. Eine Notiz auszublenden, weil niemand
// weiss, von wem sie ist, wäre genau der stille Verlust, den die Liste
// verhindern soll.
expect(sql()).toContain('"n"."author_user_id" is null');
});
it("lässt die eigenen Notizen immer durch", () => {
expect(sql()).toContain('"n"."author_user_id" = $');
});
it("holt dazu, wer ausdrücklich hinzugewählt wurde", () => {
// Vorzeichen und Tabelle zusammen: ein `not exists` auf derselben
// Tabelle kehrte die Bedeutung um, ohne dass ein Wort sich änderte.
const s = sql();
expect(s).toContain("exists");
expect(s).not.toContain("not exists");
expect(s).toContain("from colleague_subscriptions s");
expect(s).toContain("s.user_id = $");
expect(s).toContain("s.author_user_id = \"n\".\"author_user_id\"");
});
it("verknüpft die drei Fälle mit ODER, nicht mit UND", () => {
// Mit UND sähe niemand mehr etwas: keine Notiz ist gleichzeitig ohne
// Verfasser und von mir.
const s = sql();
expect(s).toMatch(/is null\s+or/);
expect(s).not.toMatch(/is null\s+and/);
});
it("bindet die Kennung als Parameter, nicht in den Text", () => {
// Sie kommt aus der Sitzung, nicht aus der Adresse — trotzdem hat sie im
// Abfragetext nichts verloren.
const { sql: text, parameters } = abfrage("bösartig'; drop table employee_notes; --");
expect(text).not.toContain("drop table");
expect(parameters).toContain("bösartig'; drop table employee_notes; --");
});
it("nennt die Kennung zweimal — für die eigenen Notizen und für die Ausnahmen", () => {
expect(abfrage().parameters.filter((p) => p === ICH)).toHaveLength(2);
});
it("lässt sich auf einen anderen Aliasnamen setzen", () => {
// Die Übersicht bindet dieselbe Tabelle ein; käme sie je unter anderem
// Namen, muss die Regel mitkönnen statt kopiert zu werden.
const s = db
.selectFrom("employee_notes as notiz")
.select("notiz.id")
.where(sichtbareNotizen(ICH, "notiz.author_user_id"))
.compile().sql;
expect(s).toContain('"notiz"."author_user_id"');
});
});
describe("baueKollegen", () => {
const leute = [
{ id: "a", full_name: "Anna Berger", email: "a@example.test" },
{ id: "b", full_name: null, email: "b@example.test" },
{ id: "c", full_name: " ", email: "c@example.test" },
];
it("lässt ohne Auswahl alle Haken leer", () => {
// Die Vorgabe ist die eigene Person; wer mehr will, wählt hinzu.
expect(baueKollegen(leute, []).map((k) => k.sichtbar)).toEqual([false, false, false]);
});
it("setzt den Haken, wo ein Abo steht", () => {
expect(baueKollegen(leute, ["b"]).map((k) => [k.id, k.sichtbar])).toEqual([
["a", false],
["b", true],
["c", false],
]);
});
it("fällt ohne Namen auf die E-Mail zurück", () => {
// Ein Haken ohne Beschriftung wäre einer, von dem niemand weiss, wen er
// betrifft. Auch ein Name aus Leerzeichen zählt als keiner.
expect(baueKollegen(leute, []).map((k) => k.name)).toEqual([
"Anna Berger",
"b@example.test",
"c@example.test",
]);
});
it("kommt mit einem Abo zurecht, zu dem es niemanden mehr gibt", () => {
// Wer die Personalabteilung verlässt, verschwindet aus der Liste; die
// Zeile bleibt, bis der Fremdschlüssel sie räumt.
expect(baueKollegen(leute, ["längst-weg"])).toHaveLength(3);
});
});