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( , ); } 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( , ); 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()); }); });