Menu
Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1
AkurAI-Mail / src/framework/store.test.js
Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson
import { describe, expect, test } from "bun:test";
import { createStore } from "./store.js";
import { RECENT_RECIPIENTS_KEY } from "../lib/webmail/recipients.js";
const MESSAGES = Array.from({ length: 3 }, (_, i) => ({ id: `m${i}`, from: `s${i}@example.com`, subject: `Subject ${i}`, date: "2026-10-01T08:00:00Z" }));
/** @param {Record<string, string>} [initial] */
function memoryStorage(initial = {}) {
/** @type {Record<string, string>} */
const data = { ...initial };
return {
getItem: (/** @type {string} */ k) => data[k] ?? null,
setItem: (/** @type {string} */ k, /** @type {string} */ v) => { data[k] = v; },
removeItem: (/** @type {string} */ k) => { delete data[k]; },
data,
};
}
/** A fake webmail API that records calls and answers like the server. */
function fakeApi({ fail = false } = {}) {
/** @type {{ kind: string, params?: Record<string, string>, body?: any }[]} */
const calls = [];
const json = (/** @type {unknown} */ body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
return {
calls,
mailbox: async (/** @type {Record<string, string>} */ params) => {
calls.push({ kind: "mailbox", params });
if (fail) throw new TypeError("network down");
if (params.action === "signals") return json({ ok: true, signals: { c1: { conversationId: "c1", priority: { value: "high" } } }, messageSignals: { m1: "c1" } });
if (params.action === "signal-correct") return json({ ok: true });
return json({ ok: true, folders: [{ name: "Inbox", unread: 2 }, { name: "Archive", unread: 1 }], messages: MESSAGES, total: 3, nextCursor: params.cursor ? "" : "c2", contacts: [{ email: "ann@example.com" }], preferences: { theme: "dark" }, vipSenders: ["s2@example.com"], signatures: { "me@example.com": "-- me" } });
},
webmail: async (/** @type {any} */ body) => {
calls.push({ kind: "webmail", body });
if (body.action === "read") return json({ ok: true, message: { ...MESSAGES.find((m) => m.id === body.id), bodyText: "hello", to: "me@example.com", toAddresses: [{ address: "me@example.com" }, { address: "pal@example.com", name: "Pal" }], fromAddresses: [{ address: "s1@example.com", name: "Sender One" }] } });
if (body.action === "send") return json({ ok: true, pendingSend: { id: "p1", send_at: Math.floor(Date.now() / 1000) + 10 } });
if (body.action === "scheduled-sends") return json({ ok: true, scheduledSends: [{ id: "s1", status: "pending", send_at: 5, payload: { subject: "Later", to: "x@y.is" } }] });
if (body.action === "attachment") return json({ ok: true, contentType: "text/plain", data: "aGk=", filename: "a.txt" });
return json({ ok: true });
},
};
}
function makeStore(overrides = {}) {
const api = fakeApi(overrides);
const storage = memoryStorage();
const session = memoryStorage();
const store = createStore({ session: { email: "me@example.com", admin: false }, hostname: "mail.test", api, storage, sessionStorage: session, navigate: (url) => { navigated.push(url); }, setInterval: () => 1, clearInterval: () => {}, now: () => 1_700_000_000_000, ...overrides });
return { store, api, storage, session };
}
/** @type {string[]} */
const navigated = [];
describe("createStore", () => {
test("loads a folder, caches the plain view, derives counts and signals", async () => {
const { store, api, storage } = makeStore();
await store.loadMailbox("Inbox");
expect(store.messages().length).toBe(3);
expect(store.unreadCount()).toBe(3);
expect(store.pageCount()).toBe(1);
expect(store.preferences().theme).toBe("dark");
expect(store.activeSignature()).toBe("-- me");
expect(JSON.parse(storage.data["akurai-webmail-cache:me@example.com:Inbox"]).total).toBe(3);
expect(api.calls.map((c) => c.params?.action ?? c.kind)).toEqual(["mailbox", "signals"]);
expect(store.signalForMessage("m1")?.priority.value).toBe("high");
expect(store.signalForMessage("m0")).toBeNull();
expect(store.isVipMessage(MESSAGES[2])).toBe(true);
expect(store.sync.lastSyncAt()).toBe(1_700_000_000_000);
});
test("falls back to the cached mailbox when offline and schedules a retry", async () => {
const { store, storage } = makeStore({ fail: true });
storage.setItem("akurai-webmail-cache:me@example.com:Inbox", JSON.stringify({ messages: MESSAGES.slice(0, 1), total: 1, folders: [] }));
await store.loadMailbox("Inbox");
expect(store.messages().length).toBe(1);
expect(store.notice()).toBe("Offline — showing cached mailbox.");
expect(store.sync.errorKind()).toBe("offline");
});
test("search, chips and smart mailboxes reload with filter params", async () => {
const { store, api } = makeStore();
await store.search("invoice");
await store.toggleChip("unread");
await store.chooseSmartMailbox("flagged");
const mailboxCalls = api.calls.filter((c) => c.kind === "mailbox" && !c.params?.action).map((c) => c.params);
expect(mailboxCalls[0]?.query).toBe("invoice");
expect(mailboxCalls[1]?.unread).toBe("true");
expect(mailboxCalls[2]?.flagged).toBe("true");
expect(store.smartMailbox()).toBe("flagged");
await store.clearChips();
expect(store.activeChips().size).toBe(0);
});
test("pages forward and back through cursors", async () => {
const { store, api } = makeStore();
await store.loadMailbox();
await store.nextPage();
expect(store.page()).toBe(1);
expect(api.calls.at(-2)?.params?.cursor).toBe("c2");
await store.previousPage();
expect(store.page()).toBe(0);
expect(api.calls.at(-2)?.params?.cursor).toBeUndefined();
});
test("reads a message, moves the selection, replies with the right recipients", async () => {
const { store } = makeStore();
await store.loadMailbox();
await store.readMessage("m1");
expect(store.selected().subject).toBe("Subject 1");
await store.selectRelative("next");
expect(store.selected().id).toBe("m2");
await store.readMessage("m1");
store.reply(true);
expect(store.compose().to).toBe('"Sender One" <s1@example.com>');
expect(store.compose().cc).toBe('"Pal" <pal@example.com>');
expect(store.compose().subject).toBe("Re: Subject 1");
expect(store.compose().body).toContain("hello");
expect(store.composeOpen()).toBe(true);
store.forward();
expect(store.compose().subject).toBe("Fwd: Subject 1");
expect(store.compose().to).toBe("");
});
test("send remembers recipients, starts the undo window, undo cancels it", async () => {
const { store, api, storage } = makeStore();
await store.loadMailbox();
store.compose({ to: "ann@example.com, bob@example.com", cc: "", bcc: "", subject: "Hi", body: "<p>x</p>", attachments: [] });
const result = await store.send();
expect(result.ok).toBe(true);
expect(store.pendingSend()?.id).toBe("p1");
expect(store.composeOpen()).toBe(false);
expect(JSON.parse(storage.data[RECENT_RECIPIENTS_KEY])).toEqual(["ann@example.com", "bob@example.com"]);
expect(store.suggestions().map((s) => s.email)).toEqual(["ann@example.com", "bob@example.com"]);
const sent = api.calls.find((c) => c.body?.action === "send")?.body.message;
expect(sent.signature).toBe("-- me");
expect(sent.undoDelaySecs).toBe(10);
await store.undoSend();
expect(store.pendingSend()).toBeNull();
expect(store.notice()).toBe("Send undone");
});
test("send later validates the time and lists scheduled sends through the view", async () => {
const { store } = makeStore();
store.sendLaterAt("");
expect((await store.sendLater()).ok).toBe(false);
expect(store.notice()).toBe("Pick a send time first");
store.sendLaterAt("2030-01-01T10:00");
expect((await store.sendLater()).ok).toBe(true);
expect(store.scheduledSends()[0]).toEqual({ id: "s1", status: "pending", sendAt: 5, subject: "Later", to: "x@y.is" });
});
test("reauthenticate stashes an open draft and restoreDraft brings it back", () => {
const { store, session } = makeStore();
store.compose({ to: "a@b.is", cc: "", bcc: "", subject: "", body: "", attachments: [] });
store.composeOpen(true);
navigated.length = 0;
store.reauthenticate();
expect(navigated[0]).toBe("/auth/login?return_to=%2Fwebmail");
const fresh = createStore({ session: { email: "me@example.com", admin: false }, hostname: "h", api: fakeApi(), sessionStorage: session });
expect(fresh.restoreDraft()).toBe(true);
expect(fresh.compose().to).toBe("a@b.is");
expect(fresh.composeOpen()).toBe(true);
});
test("attachments resolve to a data link; delete is confirmed through the dialog state", async () => {
const { store, api } = makeStore();
await store.loadMailbox();
await store.readMessage("m0");
expect(await store.downloadAttachment({ index: 0 })).toEqual({ href: "data:text/plain;base64,aGk=", filename: "a.txt" });
store.deletePending(true);
await store.confirmDelete();
expect(store.deletePending()).toBe(false);
expect(api.calls.at(-3)?.body).toEqual({ action: "message", id: "m0", messageAction: "delete" });
});
test("config saves reload the mailbox; theme and shortcuts merge into preferences", async () => {
const { store, api } = makeStore();
await store.loadMailbox();
await store.setShortcutsEnabled(false);
expect(api.calls.find((c) => c.body?.kind === "preferences")?.body.value).toEqual({ theme: "dark", shortcuts: false });
await store.setTheme("light");
expect(api.calls.filter((c) => c.body?.kind === "preferences").at(-1)?.body.value.theme).toBe("light");
});
});