Files
ApiCore/ApiCore-Frontend/src/components/auth/logout-confirm-dialog.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 e614ded967
Backend Image / build-and-push (push) Successful in 7m6s
Frontend Image / build-and-push (push) Successful in 15m27s
Manifests / validate (push) Successful in 16s
feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je
Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz
mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten
Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit
Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer,
alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen),
Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden
Notizen nach. Zählen liest keine Audiodaten.

Gates: Frontend lint, typecheck, 2886 Tests.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:10:14 +02:00

217 lines
8.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { LogoutConfirmDialog } from "./logout-confirm-dialog";
const logoutMock = vi.fn();
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ logout: logoutMock, sessionMode }),
}));
vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const NOTHING_WAITS = { own: 0, failed: 0, foreign: 0 };
const onOpenChange = vi.fn();
const onLoggedOut = vi.fn();
function renderDialog() {
return render(
<LogoutConfirmDialog
open
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
}
describe("LogoutConfirmDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
logoutMock.mockResolvedValue(undefined);
vi.mocked(summarizeQueue).mockResolvedValue(NOTHING_WAITS);
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
reportRequestOutcome("reached-server");
sessionMode = "online";
});
afterEach(() => {
reportRequestOutcome("reached-server");
vi.restoreAllMocks();
});
it("logs out directly when nothing waits", async () => {
renderDialog();
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("asks before deleting unsent entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 1, foreign: 5 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
// Waiting and rejected entries of the person count; other accounts' do not.
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
expect(logoutMock).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Erst senden" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Abmelden und Einträge löschen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
it("asks to discard waiting voice notes and says they cannot be restored", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
expect(dialog).toHaveTextContent("Darunter 2 Sprachnotizen");
expect(dialog).toHaveTextContent("nicht wiederherstellen");
expect(logoutMock).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "Abmelden und Einträge löschen" }),
);
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
});
it("does not mention recordings when there are none", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
expect(await screen.findByRole("dialog", { name: "Abmelden?" })).not.toHaveTextContent(
"Sprachnotiz",
);
});
it("deletes the entries only after the destructive button was pressed", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("cancel leaves everything as it is", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(logoutMock).not.toHaveBeenCalled();
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("sends first and logs out once everything is gone", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue(NOTHING_WAITS);
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
});
it("stays open and says how many still wait when sending was not enough", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() =>
expect(screen.getByRole("dialog", { name: "Abmelden?" })).toHaveTextContent(
"1 Eintrag ist noch nicht gesendet.",
),
);
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending without network and warns that signing out deletes the entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("offline logout marks the logout as pending (through the auth context)", async () => {
// The pending mark is the auth context's job; the dialog only asks for the logout.
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
});
it("shows an error and stays open when the logout fails", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
logoutMock.mockRejectedValue(new Error("boom"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(onLoggedOut).not.toHaveBeenCalled();
});
it("does nothing while it is closed", () => {
render(
<LogoutConfirmDialog
open={false}
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
expect(summarizeQueue).not.toHaveBeenCalled();
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending in an offline session, even when the network looks fine", async () => {
sessionMode = "offline";
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("shows the error with a retry when the direct logout of an empty queue fails", async () => {
logoutMock.mockRejectedValueOnce(new Error("boom"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(dialog).not.toHaveTextContent("noch nicht gesendet");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(2));
expect(logoutMock).toHaveBeenLastCalledWith({ discardQueue: false });
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
});