AkurAI Build
Menu

Public repository / 8b66e18ce8cb51ca3e4124f89fd1429a1d4792f3

AkurAI-Mail / src/lib/webmail/compose.test.ts

Latest change 22202d1bde69be4ace8a299dffd91f8ff24378df - Recover deployed webmail frontend (frontend parity with EC2 a6afffc) by Ólafur Búi Ólafsson

import { describe, expect, test } from "bun:test";
import { emptyCompose, escapeHtml, forwardBody, hasDraftContent, parseSendAt, replyBody, secondsUntil, stashDraft, takeDraft, QUOTE_MARKER } from "./compose";

class MemoryStorage {
  data = new Map<string, string>();
  limit = Infinity;
  get length() { return this.data.size; }
  clear() { this.data.clear(); }
  key(i: number) { return [...this.data.keys()][i] ?? null; }
  getItem(k: string) { return this.data.get(k) ?? null; }
  removeItem(k: string) { this.data.delete(k); }
  setItem(k: string, v: string) {
    if (v.length > this.limit) throw new Error("QuotaExceededError");
    this.data.set(k, v);
  }
}

describe("draft recovery", () => {
  test("only drafts with content are worth keeping", () => {
    expect(hasDraftContent(emptyCompose())).toBe(false);
    expect(hasDraftContent({ ...emptyCompose(), subject: "Hi" })).toBe(true);
  });

  test("stash then take returns the draft once", () => {
    const storage = new MemoryStorage() as unknown as Storage;
    const draft = { ...emptyCompose(), to: "a@x.is" };
    expect(stashDraft(storage, "k", draft)).toBe(true);
    expect(takeDraft(storage, "k")).toEqual(draft);
    expect(takeDraft(storage, "k")).toBeNull();
  });

  test("drops attachments when storage is full", () => {
    const memory = new MemoryStorage();
    memory.limit = 200;
    const draft = { ...emptyCompose(), to: "a@x.is", attachments: [{ name: "big", type: "x", size: 1, data: "A".repeat(500) }] };
    expect(stashDraft(memory as unknown as Storage, "k", draft)).toBe(true);
    expect(takeDraft(memory as unknown as Storage, "k")?.attachments).toEqual([]);
  });
});

describe("quoting", () => {
  const message = { date: "Mon", from: "A <a@x.is>", subject: "Hi & bye", bodyText: "line 1\n<b>line 2</b>" };

  test("escapes HTML", () => {
    expect(escapeHtml(`<a href="x">&</a>`)).toBe("&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;");
  });

  test("reply quotes the escaped body in a blockquote", () => {
    expect(replyBody(message)).toBe(`<br><br>${QUOTE_MARKER}On Mon, A <a@x.is> wrote:<blockquote>line 1<br>&lt;b&gt;line 2&lt;/b&gt;</blockquote>`);
  });

  test("forward includes escaped headers", () => {
    expect(forwardBody(message)).toBe(`<br><br>${QUOTE_MARKER}Forwarded message:<br>From: A &lt;a@x.is&gt;<br>Date: Mon<br>Subject: Hi &amp; bye<br><br>line 1<br>&lt;b&gt;line 2&lt;/b&gt;`);
  });
});

describe("send timing", () => {
  test("send later requires a future time", () => {
    const now = new Date("2026-10-01T12:00:00").getTime();
    expect(parseSendAt("", now)).toEqual({ error: "Pick a send time first" });
    expect(parseSendAt("2026-10-01T11:00", now)).toEqual({ error: "Send time must be in the future" });
    expect(parseSendAt("2026-10-01T13:00", now)).toEqual({ sendAt: Math.floor(new Date("2026-10-01T13:00").getTime() / 1000) });
  });

  test("undo countdown never goes negative", () => {
    expect(secondsUntil(110, 100_000)).toBe(10);
    expect(secondsUntil(90, 100_000)).toBe(0);
  });
});