feat(settings): preserve drafts and connection recovery
CI and Deploy / test (push) Failing after 4m27s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-10-17 10:50:40 +02:00
parent dac29278db
commit e7dba9a2f7
20 changed files with 3337 additions and 141 deletions
@@ -62,6 +62,8 @@ describe("EmailProviderConnections", () => {
beforeEach(() => {
jest.clearAllMocks();
mockedApi.get.mockReset();
mockedApi.post.mockReset();
mockedApi.delete.mockReset();
window.localStorage.clear();
});
@@ -108,13 +110,14 @@ describe("EmailProviderConnections", () => {
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 userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
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 () => {
@@ -221,4 +224,207 @@ describe("EmailProviderConnections", () => {
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<any>(); const retry = deferred<any>(); 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<any>();
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<any>(); 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<any>();
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<any>(); 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<any>(); 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<any>(); const oldWrite = deferred<any>(); 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<any>();
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<any>();
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<any>(); const refreshed = deferred<any>(); 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);
});
});