AkurAI Build
Menu

Public repository / master

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");
  });
});