src/compose-send.test.tsx

import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { renderToStaticMarkup } from "react-dom/server";
import { ComposePanel, sendCompose, type Compose } from "./compose/ComposePanel";

const initial: Compose = {
  accountId: "synthetic",
  to: "recipient@example.com",
  subject: "Synthetic subject",
  body: "Synthetic body",
};

function client(
  send: Parameters<typeof sendCompose>[1]["send"],
  reload: () => Promise<unknown> = async () => {},
  initialDraft = initial,
  lifecycle: Partial<Pick<Parameters<typeof sendCompose>[1], "prepare" | "complete">> = {},
) {
  let draft: Compose | null = { ...initialDraft };
  let busy = false;
  let confirmed = false;
  const notices: string[] = [];
  const prompts: string[] = [];
  const lock = { current: false };
  const setDraft: Parameters<typeof sendCompose>[1]["setDraft"] = (value) => {
    draft = typeof value === "function" ? value(draft) : value;
  };
  return {
    get draft() { return draft; },
    get busy() { return busy; },
    notices, prompts, setDraft,
    approve: () => { confirmed = true; },
    submit: () => sendCompose(draft!, {
      lock, send: async (draft) => ({ ok: true, ...await send(draft) }), reload, setDraft,
      prepare: async () => {}, complete: async () => {}, ...lifecycle,
      setBusy: (value) => { busy = value; },
      setNotice: (value) => { notices.push(value); },
      confirm: (message) => { prompts.push(message); return confirmed; },
    }),
  };
}

const panel = (draft: Compose, onSend: () => void, setDraft = (_draft: Compose) => {}, onClose = () => {}) =>
  ComposePanel({
    draft, onSend, setDraft, onClose, onDiscard: onClose, onRetry() {}, onSaveCopy() {},
    progress: { phase: "saved", message: "Draft saved." }, busy: false,
    accounts: [{ id: "synthetic", email: "sender@example.com", provider: "imap" }],
  });

test("a lost response retains the draft and declined resend submits no second request", async (t) => {
  const requests: unknown[] = [];
  const server = createServer(async (request, response) => {
    let body = "";
    for await (const chunk of request) body += chunk;
    requests.push(JSON.parse(body));
    if (requests.length === 1) response.destroy();
    else response.end(JSON.stringify({ ok: true }));
  });
  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  t.after(() => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())));
  const address = server.address();
  assert.ok(address && typeof address === "object");
  const c = client(async (draft) => {
    const response = await fetch(`http://127.0.0.1:${address.port}/send`, {
      method: "POST", body: JSON.stringify(draft),
    });
    return response.json();
  });
  await c.submit();
  assert.equal(requests.length, 1);
  assert.deepEqual(c.draft, { ...initial, sendUnknown: true });
  assert.equal(c.busy, false);
  assert.match(c.notices.at(-1)!, /Send status unknown/);
  await c.submit();
  assert.equal(requests.length, 1);
  assert.equal(c.prompts.length, 1);
  assert.match(c.prompts[0], /already.*sent/);
  assert.match(c.prompts[0], /duplicate/);
  c.approve();
  await c.submit();
  assert.equal(requests.length, 2);
  assert.equal(c.draft, null);
  assert.equal(c.notices.at(-1), "Email sent.");
});

test("HTTP 400, parse failure and transport failure all retain an unknown draft", async (t) => {
  for (const error of [new Error("HTTP 400"), new SyntaxError("Invalid JSON"), new TypeError("fetch failed")]) {
    await t.test(error.message, async () => {
      const c = client(async () => { throw error; });
      await c.submit();
      assert.equal(c.draft?.sendUnknown, true);
      assert.equal(c.draft?.body, initial.body);
      assert.equal(c.busy, false);
      assert.match(c.notices.at(-1)!, /Send status unknown/);
      assert.doesNotMatch(c.notices.at(-1)!, /Not sent|Try again/);
    });
  }
});

test("click and Enter form submissions share the unknown-send confirmation", async () => {
  let requests = 0;
  const c = client(async () => { requests++; return {}; }, undefined, { ...initial, sendUnknown: true });
  let pending: Promise<void> | undefined;
  const form = panel(c.draft!, () => { pending = c.submit(); }).props.children;
  const actions = form.props.children.find((child: { props?: { className?: string } }) => child?.props?.className === "form-actions");
  const button = actions.props.children[1];
  assert.equal(button.props.type, "submit");
  assert.equal(button.props.onClick, undefined);
  for (const submitter of [button, null]) {
    form.props.onSubmit({ preventDefault() {}, currentTarget: { reportValidity: () => true }, nativeEvent: { submitter } });
    await pending;
    assert.equal(requests, 0);
    assert.equal(c.draft?.sendUnknown, true);
  }
  assert.equal(c.prompts.length, 2);
  c.approve();
  form.props.onSubmit({ preventDefault() {}, currentTarget: { reportValidity: () => true } });
  await pending;
  assert.equal(requests, 1);
});

test("editing an unknown draft preserves its warning and the next send guard", async () => {
  let requests = 0;
  const c = client(async () => { requests++; return {}; }, undefined, { ...initial, sendUnknown: true });
  for (const [index, value] of [[0, "synthetic"], [1, "edited@example.com"], [2, "Edited subject"], [3, "Edited body"]] as const) {
    const fields = panel(c.draft!, () => {}, c.setDraft).props.children.props.children;
    const control = index === 3 ? fields[index] : fields[index].props.children[1];
    control.props.onChange({ target: { value } });
    assert.equal(c.draft?.sendUnknown, true);
  }
  assert.equal(c.draft?.body, "Edited body");
  const html = renderToStaticMarkup(panel(c.draft!, () => {}));
  assert.match(html, /Send status unknown/);
  assert.match(html, /may already have been sent/);
  await c.submit();
  assert.equal(requests, 0);
});

test("pre-submission form validation never calls send", () => {
  let sends = 0;
  const form = panel({ ...initial, to: "invalid", body: "" }, () => { sends++; }).props.children;
  assert.equal(form.props.children[1].props.children[1].props.type, "email");
  assert.equal(form.props.children[1].props.children[1].props.required, true);
  assert.equal(form.props.children[3].props.required, true);
  form.props.onSubmit({ preventDefault() {}, currentTarget: { reportValidity: () => false } });
  assert.equal(sends, 0);
});

test("explicitly discarding an unknown draft warns about possible delivery without calling it unsent", (t) => {
  const previous = Object.getOwnPropertyDescriptor(globalThis, "confirm");
  const prompts: string[] = [];
  let approved = false;
  Object.defineProperty(globalThis, "confirm", { configurable: true, value: (message: string) => {
    prompts.push(message); return approved;
  } });
  t.after(() => {
    if (previous) Object.defineProperty(globalThis, "confirm", previous);
    else Reflect.deleteProperty(globalThis, "confirm");
  });
  let closes = 0;
  const dialog = panel({ ...initial, to: "", subject: "", body: "", sendUnknown: true }, () => {}, undefined, () => { closes++; });
  const discard = dialog.props.children.props.children.at(-1).props.children[1];
  discard.props.onClick();
  assert.equal(closes, 0);
  assert.match(prompts[0], /already.*sent/);
  assert.doesNotMatch(prompts[0], /unsent/);
  approved = true;
  discard.props.onClick();
  assert.equal(closes, 1);
});

test("send success and provider warnings survive a separate inbox refresh failure", async () => {
  for (const warning of [undefined, "Email was sent, but saving a Sent copy failed. Do not resend."]) {
    const c = client(async () => ({ warning }), async () => { throw new Error("Synthetic refresh failure"); });
    await c.submit();
    assert.equal(c.draft, null);
    assert.equal(c.busy, false);
    assert.match(c.notices.at(-1)!, /Email (was )?sent/);
    assert.match(c.notices.at(-1)!, /Inbox refresh failed/);
    if (warning) assert.ok(c.notices.at(-1)!.includes(warning));
  }
});

test("an in-flight send blocks another submission before React updates busy", async () => {
  let resolve!: (value: {}) => void;
  let sends = 0;
  const c = client(() => { sends++; return new Promise((done) => { resolve = done; }); });
  const pending = c.submit();
  assert.equal(c.busy, true);
  await c.submit();
  assert.equal(sends, 1);
  resolve({});
  await pending;
  assert.equal(c.busy, false);
});

test("a failure retains edits made while the send was pending and hides error details", async () => {
  let reject!: (error: Error) => void;
  const c = client(() => new Promise((_resolve, fail) => { reject = fail; }));
  const pending = c.submit();
  c.setDraft({ ...initial, body: "Edited while waiting" });
  await Promise.resolve();
  reject(new Error("Synthetic provider error for private@example.com"));
  await pending;
  assert.equal(c.draft?.body, "Edited while waiting");
  assert.equal(c.draft?.sendUnknown, true);
  assert.doesNotMatch(c.notices.at(-1)!, /private@example.com|Synthetic provider/);
});


test("an unacknowledged draft save blocks sending and cleanup", async () => {
  let sends = 0, cleanups = 0;
  const c = client(async () => { sends++; return {}; }, undefined, initial, {
    prepare: async () => { throw new Error("Synthetic save failure"); },
    complete: async () => { cleanups++; },
  });
  await c.submit();
  assert.equal(sends, 0);
  assert.equal(cleanups, 0);
  assert.equal(c.draft?.body, initial.body);
  assert.equal(c.busy, false);
  assert.match(c.notices.at(-1)!, /No send was attempted/);
});

test("accepted send cleanup failure never becomes a send failure or retry", async () => {
  const order: string[] = [];
  const c = client(async () => { order.push("send"); return {}; }, undefined, initial, {
    prepare: async () => { order.push("persist uncertainty"); },
    complete: async () => { order.push("cleanup"); throw new Error("Synthetic cleanup failure"); },
  });
  await c.submit();
  assert.deepEqual(order, ["persist uncertainty", "send", "cleanup"]);
  assert.equal(c.draft, null);
  assert.match(c.notices.at(-1)!, /Email sent/);
  assert.match(c.notices.at(-1)!, /Do not resend/);
  assert.doesNotMatch(c.notices.at(-1)!, /Send status unknown/);
});

test("a response without provider acceptance preserves uncertainty and skips cleanup", async () => {
  let cleanups = 0;
  const c = client(async () => ({ ok: false }), undefined, initial, {
    complete: async () => { cleanups++; },
  });
  await c.submit();
  assert.equal(cleanups, 0);
  assert.equal(c.draft?.sendUnknown, true);
  assert.match(c.notices.at(-1)!, /Send status unknown/);
});