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>
135 lines
5.5 KiB
TypeScript
135 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|