Files
alpenwerk-hr/tests/components/DraftsCard.test.tsx
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

154 lines
6.6 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: "Max Stubhan",
gesperrtVon: 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("sagt an eigenen Entwürfen: von mir", () => {
// Der eigene Name stünde an jeder Zeile und sagte nichts — man kennt ihn.
zeige([entwurf()]);
const z = zeile("Manuel Aigner");
expect(within(z).getByText("von mir")).toBeInTheDocument();
expect(within(z).queryByText(/Max Stubhan/)).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("lässt auch fremde Entwürfe fortsetzen", () => {
// Der Sinn der ganzen Freigabe: wer vertreten wird, soll die angefangene
// Einstellung zu Ende bringen können.
zeige([fremd]);
expect(within(zeile("Eva Novak")).getByRole("button", { name: "Fortsetzen" })).toBeInTheDocument();
});
it("bietet an fremden Entwürfen kein Löschen", () => {
// Die Regel liesse es zu — sie muss, weil der Assistent den Entwurf nach
// dem Anlegen selbst wegräumt. Eine Schaltfläche, die fremde halbfertige
// Arbeit wegwirft, gehört trotzdem nicht neben eine, die sie fortsetzt.
zeige([fremd]);
expect(within(zeile("Eva Novak")).queryByRole("button", { name: "Entwurf löschen" })).not.toBeInTheDocument();
});
it("nimmt Fortsetzen weg, solange jemand anderes drin ist", () => {
zeige([entwurf({ gesperrtVon: "Bernd Huber" })]);
expect(within(zeile("Manuel Aigner")).queryByRole("button", { name: "Fortsetzen" })).not.toBeInTheDocument();
});
it("sagt, wer den gesperrten Entwurf gerade bearbeitet", () => {
// Eine graue Schaltfläche ohne Erklärung liest sich wie ein Fehler, und
// die nächste Handlung wäre, es gleich noch einmal zu versuchen.
zeige([entwurf({ gesperrtVon: "Bernd Huber" })]);
expect(within(zeile("Manuel Aigner")).getByText(/Bernd Huber bearbeitet gerade/)).toBeInTheDocument();
});
it("sperrt auch das Löschen des eigenen Entwurfs, solange jemand drin ist", () => {
// Sonst risse man jemandem den Entwurf unter den Händen weg.
zeige([entwurf({ gesperrtVon: "Bernd Huber" })]);
expect(within(zeile("Manuel Aigner")).getByRole("button", { name: "Entwurf löschen" })).toBeDisabled();
});
it("trennt die Zeilen: freie bedienbar, gesperrte nicht", () => {
// Beide nebeneinander — der Fall, in dem eine Verwechslung teuer wird.
zeige([entwurf(), entwurf({ id: "d9", payload: { firstName: "Eva", lastName: "Novak" }, gesperrtVon: "Bernd Huber" })]);
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();
});
});