Files
ihasmail-inbuxa/web/src/store/__tests__/startup-snapshot.test.ts
T
jcoffey 82dc877fe1 Start at once on a device marked as your own (#395)
* Start at once on a device marked as your own

On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.

A trusted device now starts from what it kept:

- the service worker answers an app route from its kept page and fetches a
  fresh one behind it; the app checks the server's version at start, and a
  reload for a new build puts the new page in place first, so it is not
  answered with the old one. Assets of the page just replaced are kept one
  build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
  server has confirmed the session; the answer replaces it, and a session
  that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
  list properties only, so the folders and the inbox paint before any reply
  and the folder query does not wait on the folder list. The "folder no
  longer exists" check still waits for the server's list.

All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.

* Show nothing kept before the session is confirmed

Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.

The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
2026-09-16 13:47:59 -07:00

194 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { buildSnapshot, useMail } from "@/store/mail";
import { useSession } from "@/store/session";
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
/**
* Starting from what a trusted device kept: the folder list and the first page
* of recent folders, applied once the server has confirmed the session. On a
* distant link each of those was a round trip before the inbox could show.
*/
const INBOX = "mbInbox";
const session = (remember: boolean) =>
({
capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: { a1: { name: "me", isPersonal: true, isReadOnly: false, accountCapabilities: { [CAP.mail]: {} } } },
primaryAccounts: { [CAP.mail]: "a1" },
state: "s",
username: "me",
apiUrl: "",
downloadUrl: "",
uploadUrl: "",
eventSourceUrl: "",
ihasmail: { remember, sessionId: "public-id" },
}) as unknown as JmapSession;
const email = (id: string) => ({
id,
threadId: `t${id}`,
mailboxIds: { [INBOX]: true },
keywords: {},
receivedAt: "2026-09-16T00:00:00Z",
subject: `Subject ${id}`,
preview: "p",
bodyValues: { "1": { value: "secret body" } },
});
const inboxQuery = { key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX };
/** The key the store gives the inbox list, learned by asking for it. */
function inboxKey(): string {
useMail.getState().setAccount("probe");
void useMail.getState().query(inboxQuery);
const key = useMail.getState().list!.key;
useMail.getState().setAccount(null);
return key;
}
function signedInWithList() {
const key = inboxKey();
useMail.getState().setAccount("a1");
useMail.setState({
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
mailboxesLoaded: true,
emails: { e1: email("e1"), e2: email("e2") } as never,
list: { key, filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX, ids: ["e1", "e2"], total: 7, queryState: "q", loading: false, loadingMore: false, error: null, exhausted: false },
});
}
let pending: ((v: Response) => void) | null;
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
pending = null;
// The session request stays unanswered unless a test answers it.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>((resolve) => { pending = resolve; })));
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
setDeviceTrusted(false);
localStorage.clear();
});
describe("the kept mail snapshot", () => {
it("holds folders and list rows without bodies", () => {
signedInWithList();
const snap = buildSnapshot(useMail.getState())!;
expect(snap.mailboxes.map((m) => m.id)).toEqual([INBOX]);
expect(snap.lists).toEqual([{ key: inboxKey(), ids: ["e1", "e2"], total: 7 }]);
expect(snap.emails.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(JSON.stringify(snap)).not.toContain("secret body");
});
it("is not made before the server's folder list has arrived", () => {
signedInWithList();
useMail.setState({ mailboxesLoaded: false });
expect(buildSnapshot(useMail.getState())).toBeNull();
});
it("paints the folders and the inbox on the next start of a trusted device", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
// A change to the list schedules the save.
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toContain('"e1"');
// Next start.
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
const s = useMail.getState();
expect(s.mailboxesCached).toBe(true);
expect(s.mailboxesLoaded).toBe(false);
expect(s.mailboxes[INBOX]?.name).toBe("Inbox");
void s.query(inboxQuery);
expect(useMail.getState().list).toMatchObject({ ids: ["e1", "e2"], total: 8, loading: true });
});
it("is neither written nor read on a device not marked as the reader's own", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
setDeviceTrusted(false);
useMail.getState().setAccount(null);
useMail.getState().setAccount("a1");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().mailboxes).toEqual({});
localStorage.clear();
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("is gone after signing out, and a save scheduled before it does not bring it back", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.setState((s) => ({ list: { ...s.list!, total: 9 } }));
clearSignedInData();
useSession.setState({ status: "anonymous", accountId: null });
vi.advanceTimersByTime(3500);
expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull();
});
it("belongs to one account", () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
useMail.getState().setAccount("a2");
expect(useMail.getState().mailboxesCached).toBe(false);
expect(useMail.getState().emails).toEqual({});
});
});
describe("before the server has confirmed the session", () => {
it("shows nothing kept: the spinner stays until the answer, and the kept folders arrive with it", async () => {
setDeviceTrusted(true);
useSession.setState({ status: "authenticated", accountId: "a1" });
signedInWithList();
useMail.setState((s) => ({ list: { ...s.list!, total: 8 } }));
vi.advanceTimersByTime(3500);
// Next start.
useMail.getState().setAccount(null);
useSession.setState({ status: "loading", session: null, accountId: null });
const boot = useSession.getState().bootstrap();
expect(useSession.getState().status).toBe("loading");
expect(useMail.getState().accountId).toBeNull();
expect(useMail.getState().mailboxes).toEqual({});
expect(useMail.getState().emails).toEqual({});
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(useSession.getState().status).toBe("authenticated");
expect(useMail.getState().mailboxesCached).toBe(true);
expect(useMail.getState().mailboxes[INBOX]?.name).toBe("Inbox");
});
it("keeps no session of its own", async () => {
setDeviceTrusted(true);
const boot = useSession.getState().bootstrap();
pending!({ ok: true, status: 200, json: async () => session(true) } as Response);
await boot;
expect(localStorage.getItem("ihasmail:session")).toBeNull();
});
});