import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ToastProvider } from "./toast"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; import EmailProviderConnections from "./components/EmailProviderConnections"; import { translations } from "./i18n/translations"; jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, getApiErrorMessage: (_err: unknown, fallback: string) => fallback, })); const mockedApi = api as jest.Mocked; function deferred() { let resolve!: (value: T) => void; let reject!: (cause: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } const oauthProviders = [ { name: "Gmail", path: "/gmail", source: "jobtracker-gmail-oauth", popupName: "jobtracker-gmail-connect", auth: "https://accounts.google.com/o/oauth2/v2/auth", failed: "emailConnectionsGmailFailed" }, { name: "Outlook / Microsoft 365", path: "/microsoft-graph", source: "jobtracker-microsoft-oauth", popupName: "jobtracker-microsoft-connect", auth: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", failed: "emailConnectionsOutlookFailed" }, ] as const; function authUrl(provider: typeof oauthProviders[number], callback = `http://localhost:3000/api${provider.path}/oauth/callback`) { return `${provider.auth}?client_id=fixture&state=server-owned-state&redirect_uri=${encodeURIComponent(callback)}`; } function popupWindow() { return { closed: false, focus: jest.fn() } as unknown as Window; } function connectButton(name: string) { return within(screen.getByText(name, { exact: true }).parentElement!.parentElement!).getByRole("button", { name: "Connect" }); } function statusReads() { return mockedApi.get.mock.calls.filter(([url]) => String(url).endsWith("/status")); } function message(data: unknown, source: Window | null, origin = "http://localhost:3000") { act(() => { window.dispatchEvent(new MessageEvent("message", { data, source, origin })); }); } function disconnectedProviderReads() { mockedApi.get.mockImplementation((url: string) => { const provider = oauthProviders.find(value => url === `${value.path}/connect-url`); return Promise.resolve({ data: provider ? { url: authUrl(provider) } : { connected: false } } as any); }); } function renderComponent() { return render( , ); } describe("EmailProviderConnections", () => { beforeEach(() => { jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.delete.mockReset(); window.localStorage.clear(); }); afterEach(() => jest.restoreAllMocks()); it("renders connection controls in Norwegian Bokmål", async () => { window.localStorage.setItem("uiLanguage", "nb"); mockedApi.get.mockResolvedValue({ data: { connected: false } } as any); renderComponent(); expect(await screen.findByText("Tilkoblede e-postkontoer")).toBeInTheDocument(); expect(screen.getByLabelText("IMAP-vert")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Koble til IMAP-konto" })).toBeInTheDocument(); }); it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => { mockedApi.get.mockImplementation((path: string) => { if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } }); if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } }); if (path === "/imap/status") return Promise.resolve({ data: { connected: false } }); return Promise.reject(new Error("unexpected path")); }); renderComponent(); expect(await screen.findByText("me@gmail.test")).toBeInTheDocument(); expect(screen.getByLabelText("IMAP host")).toBeInTheDocument(); expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0); }); it("submits IMAP connect form and reloads status on success", async () => { mockedApi.get.mockImplementation((path: string) => { if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } }); if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } }); if (path === "/imap/status") return Promise.resolve({ data: { connected: false } }); return Promise.reject(new Error("unexpected path")); }); mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } }); renderComponent(); await screen.findByLabelText("IMAP host"); await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test"); await userEvent.type(screen.getByLabelText("Username"), "user@example.test"); await userEvent.type(screen.getByLabelText("Password"), "secret"); await act(async () => { await userEvent.click(screen.getByRole("button", { name: /connect imap account/i })); }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({ host: "imap.example.test", username: "user@example.test", password: "secret", }))); await waitFor(() => expect(statusReads()).toHaveLength(4)); }); it("[P7-09 OAuth] unsolicited success and error messages cannot announce or refresh any connection", async () => { disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host"); await waitFor(() => expect(statusReads()).toHaveLength(3)); const foreign = popupWindow(); message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, foreign); message({ source: "jobtracker-microsoft-oauth", status: "error", message: "Forged Outlook failure" }, foreign); expect(screen.queryByText("Forged Gmail success")).not.toBeInTheDocument(); expect(screen.queryByText("Forged Outlook failure")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); it.each(oauthProviders)("[P7-09 OAuth] $name accepts only its captured popup and canonical callback origin once", async provider => { disconnectedProviderReads(); const popup = popupWindow(); const other = popupWindow(); const opened = jest.spyOn(window, "open").mockReturnValue(popup); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton(provider.name)); await waitFor(() => expect(opened).toHaveBeenCalledWith(authUrl(provider), provider.popupName, "width=620,height=760,resizable=yes,scrollbars=yes")); const valid = { source: provider.source, status: "connected", message: "Verified mailbox callback" }; for (const [data, sender, origin] of [ [valid, popup, "https://attacker.example.test"], [valid, popup, "http://localhost:5202"], [valid, other, "http://localhost:3000"], [valid, null, "http://localhost:3000"], [{ ...valid, source: provider.source === "jobtracker-gmail-oauth" ? "jobtracker-microsoft-oauth" : "jobtracker-gmail-oauth" }, popup, "http://localhost:3000"], [{ ...valid, status: "unexpected" }, popup, "http://localhost:3000"], [{ ...valid, message: { unsafe: "not text" } }, popup, "http://localhost:3000"], [null, popup, "http://localhost:3000"], [[valid], popup, "http://localhost:3000"], ] as Array<[unknown, Window | null, string]>) { message(data, sender, origin); expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); } expect(screen.queryByText(valid.message)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); Object.defineProperty(popup, "closed", { value: true }); message(valid, popup); message(valid, popup); expect(await screen.findByText(valid.message)).toBeVisible(); await waitFor(() => expect(statusReads()).toHaveLength(4)); expect(statusReads().at(-1)?.[0]).toBe(`${provider.path}/status`); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); it("[P7-09 OAuth] a pending popup survives a locale change and valid error uses current fallback without refreshing", async () => { disconnectedProviderReads(); const provider = oauthProviders[1]; const popup = popupWindow(); jest.spyOn(window, "open").mockReturnValue(popup); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton(provider.name)); await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1)); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); message({ source: provider.source, status: "error" }, popup); expect(await screen.findByText(translations.nb.emailConnectionsOutlookFailed)).toBeVisible(); message({ source: provider.source, status: "connected", message: "Replayed terminal callback" }, popup); expect(screen.queryByText("Replayed terminal callback")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); }); it("[P7-09 OAuth] duplicate starts reserve one named popup until it is closed", async () => { disconnectedProviderReads(); const pending = deferred(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any)); const popup = popupWindow(); jest.spyOn(window, "open").mockReturnValue(popup); renderComponent(); await screen.findByLabelText("IMAP host"); const button = connectButton("Gmail"); act(() => { fireEvent.click(button); fireEvent.click(button); }); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1); await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } })); fireEvent.click(button); expect(window.open).toHaveBeenCalledTimes(1); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1); expect(popup.focus).toHaveBeenCalledTimes(1); }); it("[P7-09 OAuth] blocked popups and late disposed connect responses cannot establish a callback sender", async () => { disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(null); const first = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail")); expect(await screen.findByText(translations.en.emailConnectionsPopupBlocked)).toBeVisible(); message({ source: "jobtracker-gmail-oauth", status: "connected", message: "Blocked popup forged success" }, popupWindow()); expect(screen.queryByText("Blocked popup forged success")).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); first.unmount(); const pending = deferred(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any)); const second = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail")); second.unmount(); await act(async () => pending.resolve({ data: { url: authUrl(oauthProviders[0]) } })); expect(opened).toHaveBeenCalledTimes(1); }); it("[P7-09 OAuth] malformed or non-provider callback targets never launch a popup", async () => { disconnectedProviderReads(); const opened = jest.spyOn(window, "open").mockReturnValue(popupWindow()); const targets = ["javascript:alert(1)", "https://user:password@jobs.example.test/api/gmail/oauth/callback", "https://jobs.example.test/api/microsoft-graph/oauth/callback", "https://jobs.example.test/wrong-callback", "not a URL"]; mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/connect-url" ? { url: authUrl(oauthProviders[0], targets.shift()!) } : { connected: false } } as any)); renderComponent(); await screen.findByLabelText("IMAP host"); for (let index = 0; index < 5; index += 1) { fireEvent.click(connectButton("Gmail")); await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(index + 1)); await act(async () => {}); } expect(opened).not.toHaveBeenCalled(); expect(statusReads()).toHaveLength(3); }); it.each(["blocked", "rejected"] as const)("[P7-09 OAuth] delayed %s startup feedback uses the language selected while its connect request was pending", async outcome => { const pending = deferred(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/connect-url" ? pending.promise : Promise.resolve({ data: { connected: false } } as any)); jest.spyOn(window, "open").mockReturnValue(null); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.click(connectButton("Gmail")); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/connect-url")).toHaveLength(1); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await screen.findByLabelText("IMAP-vert"); await act(async () => { if (outcome === "blocked") pending.resolve({ data: { url: authUrl(oauthProviders[0]) } }); else pending.reject(new Error("offline")); }); const expected = outcome === "blocked" ? translations.nb.emailConnectionsPopupBlocked : translations.nb.emailConnectionsStartFailed.replace("{provider}", "Gmail"); expect(await screen.findByText(expected)).toBeVisible(); const previous = outcome === "blocked" ? translations.en.emailConnectionsPopupBlocked : translations.en.emailConnectionsStartFailed.replace("{provider}", "Gmail"); expect(screen.queryByText(previous)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); it("[P7-09 Connections] unknown and failed provider reads remain truthful and Retry reads only that provider once", async () => { const initial = deferred(); const retry = deferred(); let gmailReads = 0; mockedApi.get.mockImplementation((url: string) => url === "/gmail/status" ? (++gmailReads === 1 ? initial.promise : retry.promise) : Promise.resolve({ data: { connected: false } } as any)); renderComponent(); await screen.findByLabelText("IMAP host"); const gmail = screen.getByRole("region", { name: "Gmail" }); expect(within(gmail).getByText(translations.en.emailConnectionsLoading)).toBeVisible(); expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument(); await act(async () => initial.reject(new Error("offline"))); expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsStatusFailed); expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); const button = within(gmail).getByRole("button", { name: translations.en.retry }); act(() => { fireEvent.click(button); fireEvent.click(button); }); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/microsoft-graph/status")).toHaveLength(1); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/imap/status")).toHaveLength(1); await act(async () => retry.resolve({ data: { connected: false } })); expect(within(gmail).getByText("Not connected")).toBeVisible(); expect(within(gmail).getByRole("button", { name: "Connect" })).toBeEnabled(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); it("[P7-09 Connections] a held status read follows the current language without a locale refetch", async () => { const pending = deferred(); mockedApi.get.mockImplementation((url: string) => url === "/gmail/status" ? pending.promise : Promise.resolve({ data: { connected: false } } as any)); renderComponent(); await screen.findByLabelText("IMAP host"); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await act(async () => pending.reject(new Error("offline"))); expect(within(screen.getByRole("region", { name: "Gmail" })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsStatusFailed); expect(screen.queryByText(translations.en.emailConnectionsStatusFailed)).not.toBeInTheDocument(); expect(statusReads()).toHaveLength(3); }); it("[P7-09 Connections] a committed disconnect with failed refresh blocks writes until GET-only recovery", async () => { const pending = deferred(); let reads = 0; mockedApi.get.mockImplementation((url: string) => { if (url !== "/gmail/status") return Promise.resolve({ data: { connected: false } } as any); reads += 1; return reads === 1 ? Promise.resolve({ data: { connected: true, gmailAddress: "owner@example.test" } } as any) : reads === 2 ? Promise.reject(new Error("refresh offline")) : Promise.resolve({ data: { connected: false } } as any); }); mockedApi.delete.mockReturnValue(pending.promise); renderComponent(); await screen.findByText("owner@example.test"); const gmail = screen.getByRole("region", { name: "Gmail" }); const disconnect = within(gmail).getByRole("button", { name: "Disconnect" }); act(() => { fireEvent.click(disconnect); fireEvent.click(disconnect); }); expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(mockedApi.delete).toHaveBeenCalledWith("/gmail/connection"); await act(async () => pending.resolve({ status: 204 })); expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsRefreshRequired); expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument(); const repeated = within(gmail).queryByRole("button", { name: "Disconnect" }); if (repeated) { expect(repeated).toBeDisabled(); fireEvent.click(repeated); } expect(mockedApi.delete).toHaveBeenCalledTimes(1); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsRefreshRequired); fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.retry })); await waitFor(() => expect(within(gmail).getByText(translations.nb.emailConnectionsNotConnected)).toBeVisible()); expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(reads).toBe(3); expect(statusReads().filter(([url]) => url !== "/gmail/status")).toHaveLength(2); }); it("[P7-09 Connections] failed disconnect preserves its known status and uses current-language explicit retry", async () => { const pending = deferred(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/status" ? { connected: true, gmailAddress: "still-connected@example.test" } : { connected: false } } as any)); mockedApi.delete.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ status: 204 } as any); renderComponent(); await screen.findByText("still-connected@example.test"); const gmail = screen.getByRole("region", { name: "Gmail" }); fireEvent.click(within(gmail).getByRole("button", { name: "Disconnect" })); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await act(async () => pending.reject(new Error("offline"))); expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsDisconnectFailed.replace("{provider}", "Gmail")); expect(within(gmail).getByText("still-connected@example.test")).toBeVisible(); expect(statusReads()).toHaveLength(3); fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.emailConnectionsDisconnect })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledTimes(2)); await waitFor(() => expect(statusReads()).toHaveLength(4)); }); it("[P7-09 Connections] IMAP submits one captured snapshot and a late success cannot clear a newer password", async () => { disconnectedProviderReads(); const pending = deferred(); mockedApi.post.mockReturnValue(pending.promise); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "submitted-secret" } }); const button = screen.getByRole("button", { name: "Connect IMAP account" }); act(() => { fireEvent.click(button); fireEvent.click(button); }); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "submitted-secret" }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "newer-secret" } }); await act(async () => pending.resolve({ data: { username: "owner@example.test" } })); await waitFor(() => expect(statusReads()).toHaveLength(4)); expect(screen.getByLabelText("Password")).toHaveValue("newer-secret"); expect(screen.getByLabelText("IMAP host")).toHaveValue("imap.example.test"); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); it("[P7-09 Connections] failed IMAP save retains credentials for an explicit retry and current fallback", async () => { disconnectedProviderReads(); const pending = deferred(); mockedApi.post.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ data: {} } as any); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "retained-secret" } }); fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await act(async () => pending.reject(new Error("offline"))); expect(within(screen.getByRole("region", { name: translations.nb.emailConnectionsOtherImap })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsImapFailed); expect(screen.getByLabelText("IMAP-vert")).toHaveValue("imap.example.test"); expect(screen.getByLabelText("Brukernavn")).toHaveValue("owner@example.test"); expect(screen.getByLabelText("Passord")).toHaveValue("retained-secret"); expect(statusReads()).toHaveLength(3); fireEvent.click(screen.getByRole("button", { name: translations.nb.emailConnectionsConnectImap })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); expect(mockedApi.post.mock.calls[1]).toEqual(["/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "retained-secret" }]); await waitFor(() => expect(screen.getByLabelText("Passord")).toHaveValue("")); }); it("[P7-09 Connections] disposed reads and writes cannot refresh, toast or clear a new panel", async () => { const oldRead = deferred(); const oldWrite = deferred(); let first = true; mockedApi.get.mockImplementation((url: string) => first && url === "/gmail/status" ? oldRead.promise : Promise.resolve({ data: { connected: false } } as any)); mockedApi.post.mockReturnValue(oldWrite.promise); const old = renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.old.test" } }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "old@example.test" } }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "old-secret" } }); fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); old.unmount(); first = false; renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "new-panel-secret" } }); const reads = statusReads().length; await act(async () => { oldRead.resolve({ data: { connected: true, gmailAddress: "disposed@example.test" } }); oldWrite.resolve({ data: {} }); }); expect(statusReads()).toHaveLength(reads); expect(screen.queryByText("disposed@example.test")).not.toBeInTheDocument(); expect(screen.queryByText(translations.en.emailConnectionsImapConnected)).not.toBeInTheDocument(); expect(screen.getByLabelText("Password")).toHaveValue("new-panel-secret"); }); it("[P7-09 Connections] invalid IMAP ports remain visible and prevent sending a silently substituted port", async () => { disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } }); const port = screen.getByLabelText("Port"); for (const value of ["", "0", "1.5", "65536"]) { fireEvent.change(port, { target: { value } }); fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); expect(port).toHaveValue(value ? Number(value) : null); expect(port).toHaveAttribute("aria-invalid", "true"); expect(port).toHaveAccessibleDescription(translations.en.emailConnectionsPortInvalid); } expect(mockedApi.post).not.toHaveBeenCalled(); }); it("[P7-09 Connections] removed Retry restores meaningful focus without stealing deliberate field focus", async () => { let attempt = 0; const pending = deferred(); mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any) : ++attempt === 1 ? Promise.reject(new Error("offline")) : pending.promise); const first = renderComponent(); await screen.findByLabelText("IMAP host"); const gmail = screen.getByRole("region", { name: "Gmail" }); const retry = await within(gmail).findByRole("button", { name: translations.en.retry }); act(() => { retry.focus(); fireEvent.click(retry); }); await act(async () => pending.resolve({ data: { connected: false } })); expect(gmail).toContainElement(document.activeElement as HTMLElement); expect(document.body).not.toBe(document.activeElement); // A later explicit field move while a retry is pending must remain authoritative. let read = 0; const later = deferred(); mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any) : ++read === 1 ? Promise.reject(new Error("offline")) : later.promise); // A new mounted panel is an independent read session. first.unmount(); const current = renderComponent(); const panels = screen.getAllByRole("region", { name: "Gmail" }); const newest = panels[panels.length - 1]; const nextRetry = await within(newest).findByRole("button", { name: translations.en.retry }); act(() => { nextRetry.focus(); fireEvent.click(nextRetry); }); const fields = screen.getAllByLabelText("Password"); const field = fields[fields.length - 1]; act(() => field.focus()); await act(async () => later.resolve({ data: { connected: false } })); expect(field).toHaveFocus(); current.unmount(); }); it("[P7-09 Connections] an IMAP field removed after commit has a meaningful fallback while refresh is pending", async () => { const saved = deferred(); const refreshed = deferred(); let imapReads = 0; mockedApi.get.mockImplementation((url: string) => url === "/imap/status" && ++imapReads > 1 ? refreshed.promise : Promise.resolve({ data: { connected: false } } as any)); mockedApi.post.mockReturnValue(saved.promise); renderComponent(); await screen.findByLabelText("IMAP host"); fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); const password = screen.getByLabelText("Password"); fireEvent.change(password, { target: { value: "secret" } }); const button = screen.getByRole("button", { name: "Connect IMAP account" }); act(() => { button.focus(); fireEvent.click(button); }); act(() => password.focus()); await act(async () => saved.resolve({ data: {} })); expect(password).not.toBeInTheDocument(); const imap = screen.getByRole("region", { name: translations.en.emailConnectionsOtherImap }); expect(within(imap).getByRole("heading", { name: translations.en.emailConnectionsOtherImap })).toHaveFocus(); const other = connectButton("Gmail"); act(() => other.focus()); await act(async () => refreshed.resolve({ data: { connected: true, username: "owner@example.test" } })); expect(other).toHaveFocus(); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); });