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>
199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { HireWizardProvider, useHireWizard } from "@/components/hire/HireWizardContext";
|
|
import { ToastProvider } from "@/components/ui/Toast";
|
|
import type { Entwurf } from "@/lib/entwuerfe";
|
|
|
|
const entwurfSperren = vi.fn<(id: string) => Promise<{ success: boolean; error?: string }>>(async () => ({
|
|
success: true,
|
|
}));
|
|
const entwurfFreigeben = vi.fn<(id: string) => Promise<{ success: boolean }>>(async () => ({ success: true }));
|
|
const refresh = vi.fn();
|
|
|
|
vi.mock("@/actions/hireDrafts", () => ({
|
|
entwurfSperren: (id: string) => entwurfSperren(id),
|
|
entwurfFreigeben: (id: string) => entwurfFreigeben(id),
|
|
saveHireDraft: vi.fn(),
|
|
deleteHireDraft: vi.fn(),
|
|
}));
|
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) }));
|
|
|
|
// Der Assistent selbst spielt hier keine Rolle — geprüft wird, was um ihn
|
|
// herum passiert: die Sperre holen, bevor er aufgeht, und sie zurückgeben,
|
|
// wenn er zugeht.
|
|
vi.mock("@/components/hire/HireWizard", () => ({
|
|
HireWizard: ({ open, onClose, resumeDraft }: { open: boolean; onClose: () => void; resumeDraft: Entwurf | null }) =>
|
|
open ? (
|
|
<div>
|
|
<p>Assistent offen{resumeDraft ? `: ${resumeDraft.id}` : ""}</p>
|
|
<button type="button" onClick={onClose}>
|
|
Zumachen
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
const ENTWURF: Entwurf = {
|
|
id: "d1",
|
|
step: 2,
|
|
payload: { firstName: "Manuel", lastName: "Aigner" },
|
|
updated_at: "2026-09-09T08:00:00.000Z",
|
|
vonMir: false,
|
|
autor: "Anna Berger",
|
|
gesperrtVon: null,
|
|
};
|
|
|
|
function Ausloeser() {
|
|
const { openWizard } = useHireWizard();
|
|
return (
|
|
<>
|
|
<button type="button" onClick={() => openWizard({ draftId: "d1" })}>
|
|
Fortsetzen
|
|
</button>
|
|
<button type="button" onClick={() => openWizard()}>
|
|
Neu
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function zeige() {
|
|
return render(
|
|
<ToastProvider>
|
|
<HireWizardProvider openPositions={[]} locations={[]} drafts={[ENTWURF]}>
|
|
<Ausloeser />
|
|
</HireWizardProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
entwurfSperren.mockClear();
|
|
entwurfSperren.mockResolvedValue({ success: true });
|
|
entwurfFreigeben.mockClear();
|
|
refresh.mockClear();
|
|
});
|
|
|
|
describe("Sperre um den Assistenten", () => {
|
|
it("holt die Sperre, bevor der Assistent aufgeht", async () => {
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
|
|
expect(entwurfSperren).toHaveBeenCalledWith("d1");
|
|
expect(await screen.findByText("Assistent offen: d1")).toBeInTheDocument();
|
|
});
|
|
|
|
it("macht gar nicht erst auf, wenn die Sperre belegt ist", async () => {
|
|
// Aufzumachen und beim Speichern zu scheitern wäre der teure Weg: die
|
|
// Person hätte den ganzen Assistenten umsonst durchlaufen.
|
|
entwurfSperren.mockResolvedValue({ success: false, error: "Wird gerade bearbeitet." });
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
|
|
expect(screen.queryByText(/Assistent offen/)).not.toBeInTheDocument();
|
|
expect(await screen.findByText("Wird gerade bearbeitet.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("holt die Liste nach, wenn die Sperre belegt war", async () => {
|
|
// Danach steht an der Zeile, wer sie hält — statt dass nur eine Meldung
|
|
// aufblitzt und die Karte weiter „Fortsetzen" anbietet.
|
|
entwurfSperren.mockResolvedValue({ success: false, error: "Wird gerade bearbeitet." });
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
|
|
expect(refresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("gibt die Sperre zurück, wenn der Assistent zugeht", async () => {
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
await user.click(await screen.findByRole("button", { name: "Zumachen" }));
|
|
|
|
expect(entwurfFreigeben).toHaveBeenCalledWith("d1");
|
|
});
|
|
|
|
it("gibt nichts frei, was nie gesperrt war", async () => {
|
|
// Eine neue Einstellung hat noch keinen Entwurf — es gäbe keine Zeile,
|
|
// auf die sich die Freigabe beziehen könnte.
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Neu" }));
|
|
await user.click(await screen.findByRole("button", { name: "Zumachen" }));
|
|
|
|
expect(entwurfSperren).not.toHaveBeenCalled();
|
|
expect(entwurfFreigeben).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sperrt beim Anlegen ohne Entwurf nichts", async () => {
|
|
const user = userEvent.setup();
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Neu" }));
|
|
|
|
expect(entwurfSperren).not.toHaveBeenCalled();
|
|
expect(await screen.findByText("Assistent offen")).toBeInTheDocument();
|
|
});
|
|
|
|
it("gibt die Sperre nur einmal zurück", async () => {
|
|
// Ein zweites Freigeben träfe womöglich schon die Sperre der nächsten
|
|
// Person, die den Entwurf inzwischen geöffnet hat.
|
|
const user = userEvent.setup();
|
|
const { unmount } = zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
await user.click(await screen.findByRole("button", { name: "Zumachen" }));
|
|
unmount();
|
|
|
|
expect(entwurfFreigeben).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("gibt die Sperre auch beim Verlassen der Seite zurück", async () => {
|
|
// Zumachen ist der Normalfall, Wegnavigieren nicht der seltene.
|
|
const user = userEvent.setup();
|
|
const { unmount } = zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
unmount();
|
|
|
|
expect(entwurfFreigeben).toHaveBeenCalledWith("d1");
|
|
});
|
|
|
|
it("frischt die Sperre auf, solange der Assistent offen ist", async () => {
|
|
// Ohne das liefe die Frist einem langen Ausfüllen davon, und das
|
|
// Speichern am Ende fände seine eigene Sperre abgelaufen.
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
expect(entwurfSperren).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(11 * 60 * 1000);
|
|
expect(entwurfSperren.mock.calls.length).toBeGreaterThan(1);
|
|
expect(entwurfSperren).toHaveBeenLastCalledWith("d1");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("hört mit dem Auffrischen auf, sobald zugemacht ist", async () => {
|
|
// Sonst hielte ein zugeklappter Assistent den Entwurf endlos belegt —
|
|
// genau das, was die Frist verhindern soll.
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
zeige();
|
|
await user.click(screen.getByRole("button", { name: "Fortsetzen" }));
|
|
await user.click(await screen.findByRole("button", { name: "Zumachen" }));
|
|
const nachher = entwurfSperren.mock.calls.length;
|
|
|
|
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
|
expect(entwurfSperren.mock.calls.length).toBe(nachher);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|