feat(settings): preserve drafts and connection recovery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user