/**
* @mandujs/core/email tests
*
* Exercises the memory adapter end-to-end (real spool, real timers) and the
* Resend adapter with a fully injected fetch — we never touch the network.
* The Resend tests also pin the exact wire format (headers, snake_case body
* field names) so a future refactor can't silently break the contract.
*/
import { describe, it, expect } from "bun:test";
import {
createMemoryEmailSender,
createResendSender,
createSmtpSender,
_validateMessage,
type EmailMessage,
} from "../index";
import { _buildResendBody } from "../resend";
// ─── Fixtures ───────────────────────────────────────────────────────────────
const VALID_MSG: EmailMessage = {
from: "sender@example.com",
to: "user@example.com",
subject: "Hello",
html: "
Hi
",
};
/**
* Builds a fetch stub that returns a JSON response with the supplied `body`
* and `status`. Records every call for assertions.
*/
interface FetchCall {
url: string;
init: RequestInit;
}
function makeFetchStub(options: {
status?: number;
body?: unknown;
rawBody?: string;
throwError?: Error;
}): { fetch: typeof globalThis.fetch; calls: FetchCall[] } {
const calls: FetchCall[] = [];
const fetchImpl = async (
input: URL | RequestInfo,
init?: RequestInit,
): Promise => {
calls.push({
url: typeof input === "string" ? input : (input as URL).toString(),
init: init ?? {},
});
if (options.throwError) throw options.throwError;
const status = options.status ?? 200;
const body =
options.rawBody !== undefined
? options.rawBody
: JSON.stringify(options.body ?? {});
return new Response(body, {
status,
headers: { "Content-Type": "application/json" },
});
};
// Bun's `typeof globalThis.fetch` has a `preconnect` callable property.
// Attach a no-op so the stub satisfies the structural type.
(fetchImpl as unknown as { preconnect: (url: string) => void }).preconnect =
() => {};
return { fetch: fetchImpl as unknown as typeof globalThis.fetch, calls };
}
// ─── Memory adapter ─────────────────────────────────────────────────────────
describe("createMemoryEmailSender", () => {
it("stores the message in .sent with id and sentAt", async () => {
const sender = createMemoryEmailSender();
const result = await sender.send(VALID_MSG);
expect(sender.sent.length).toBe(1);
expect(sender.sent[0].id).toBe(result.id);
expect(sender.sent[0].sentAt).toBe(result.sentAt);
expect(sender.sent[0].subject).toBe("Hello");
expect(typeof result.id).toBe("string");
expect(result.id.length).toBeGreaterThan(0);
expect(typeof result.sentAt).toBe("number");
});
it("returns the same id that was stored", async () => {
const sender = createMemoryEmailSender();
const r1 = await sender.send(VALID_MSG);
const r2 = await sender.send(VALID_MSG);
expect(sender.sent[0].id).toBe(r1.id);
expect(sender.sent[1].id).toBe(r2.id);
expect(r1.id).not.toBe(r2.id); // unique per send
});
it("preserves send order across multiple calls", async () => {
const sender = createMemoryEmailSender();
for (let i = 0; i < 5; i++) {
await sender.send({ ...VALID_MSG, subject: `msg-${i}` });
}
expect(sender.sent.map((m) => m.subject)).toEqual([
"msg-0",
"msg-1",
"msg-2",
"msg-3",
"msg-4",
]);
});
it("clear() empties the spool", async () => {
const sender = createMemoryEmailSender();
await sender.send(VALID_MSG);
await sender.send(VALID_MSG);
expect(sender.sent.length).toBe(2);
sender.clear();
expect(sender.sent.length).toBe(0);
});
it("waitFor(3) resolves after 3 sends", async () => {
const sender = createMemoryEmailSender();
// Drive the sends on a delay so waitFor actually has to poll.
setTimeout(() => {
void sender.send(VALID_MSG);
}, 5);
setTimeout(() => {
void sender.send(VALID_MSG);
}, 15);
setTimeout(() => {
void sender.send(VALID_MSG);
}, 25);
await sender.waitFor(3, 500);
expect(sender.sent.length).toBeGreaterThanOrEqual(3);
});
it("waitFor(3, 50) rejects with timeout if fewer than 3 sent in 50ms", async () => {
const sender = createMemoryEmailSender();
await sender.send(VALID_MSG); // only one, not three
await expect(sender.waitFor(3, 50)).rejects.toThrow(/waitFor timed out/);
});
it("waitFor() with default args resolves immediately when already satisfied", async () => {
const sender = createMemoryEmailSender();
await sender.send(VALID_MSG);
await sender.waitFor(); // default n=1 — already satisfied
expect(sender.sent.length).toBe(1);
});
it("send() throws TypeError on missing html AND text", async () => {
const sender = createMemoryEmailSender();
await expect(
sender.send({
from: "a@b.com",
to: "c@d.com",
subject: "x",
}),
).rejects.toBeInstanceOf(TypeError);
});
it("send() throws TypeError on empty to array", async () => {
const sender = createMemoryEmailSender();
await expect(
sender.send({ ...VALID_MSG, to: [] }),
).rejects.toThrow(/'to' must be a non-empty/);
});
it("send() accepts a single-string to", async () => {
const sender = createMemoryEmailSender();
await sender.send({ ...VALID_MSG, to: "one@example.com" });
expect(sender.sent[0].to).toBe("one@example.com");
});
it("send() accepts an array to", async () => {
const sender = createMemoryEmailSender();
await sender.send({ ...VALID_MSG, to: ["a@x.com", "b@x.com"] });
expect(sender.sent[0].to).toEqual(["a@x.com", "b@x.com"]);
});
it("send() accepts display-name from", async () => {
const sender = createMemoryEmailSender();
await sender.send({ ...VALID_MSG, from: "Alice " });
expect(sender.sent[0].from).toBe("Alice ");
});
it("send() throws TypeError on malformed from", async () => {
const sender = createMemoryEmailSender();
await expect(
sender.send({ ...VALID_MSG, from: "not-an-email" }),
).rejects.toBeInstanceOf(TypeError);
});
it("send() throws TypeError on empty subject", async () => {
const sender = createMemoryEmailSender();
await expect(
sender.send({ ...VALID_MSG, subject: "" }),
).rejects.toThrow(/subject/);
});
it("send() accepts text-only body", async () => {
const sender = createMemoryEmailSender();
await sender.send({
from: "a@b.com",
to: "c@d.com",
subject: "plain",
text: "hello",
});
expect(sender.sent[0].text).toBe("hello");
expect(sender.sent[0].html).toBeUndefined();
});
it("_validateMessage throws on non-object input", () => {
expect(() =>
_validateMessage(null as unknown as EmailMessage),
).toThrow(/must be an object/);
});
});
// ─── Resend adapter ─────────────────────────────────────────────────────────
describe("createResendSender", () => {
it("requires an apiKey", () => {
expect(() => createResendSender({ apiKey: "" })).toThrow(/apiKey/);
});
it("happy path: returns id + sentAt on 2xx", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "resend-123" } });
const sender = createResendSender({ apiKey: "re_test", fetch });
const result = await sender.send(VALID_MSG);
expect(result.id).toBe("resend-123");
expect(typeof result.sentAt).toBe("number");
expect(calls.length).toBe(1);
});
it("posts with correct headers and JSON body shape", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "id-1" } });
const sender = createResendSender({ apiKey: "re_abcdef", fetch });
await sender.send({
from: "Alice ",
to: "bob@b.com",
subject: "hi",
html: "hi
",
});
const call = calls[0];
expect(call.url).toBe("https://api.resend.com/emails");
expect(call.init.method).toBe("POST");
const headers = call.init.headers as Record;
expect(headers.Authorization).toBe("Bearer re_abcdef");
expect(headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(call.init.body as string);
expect(body.from).toBe("Alice ");
expect(body.to).toEqual(["bob@b.com"]);
expect(body.subject).toBe("hi");
expect(body.html).toBe("hi
");
});
it("passes array to through as array", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "id-2" } });
const sender = createResendSender({ apiKey: "k", fetch });
await sender.send({ ...VALID_MSG, to: ["a@x.com", "b@x.com"] });
const body = JSON.parse(calls[0].init.body as string);
expect(Array.isArray(body.to)).toBe(true);
expect(body.to).toEqual(["a@x.com", "b@x.com"]);
});
it("converts replyTo → reply_to (snake_case)", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "id-3" } });
const sender = createResendSender({ apiKey: "k", fetch });
await sender.send({ ...VALID_MSG, replyTo: "reply@x.com" });
const body = JSON.parse(calls[0].init.body as string);
expect(body.reply_to).toBe("reply@x.com");
expect(body.replyTo).toBeUndefined();
});
it("lowercases custom headers", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "id-4" } });
const sender = createResendSender({ apiKey: "k", fetch });
await sender.send({
...VALID_MSG,
headers: { "X-Tag": "promo", "X-Campaign": "spring" },
});
const body = JSON.parse(calls[0].init.body as string);
expect(body.headers).toEqual({ "x-tag": "promo", "x-campaign": "spring" });
});
it("throws on non-2xx with status and body excerpt", async () => {
const { fetch } = makeFetchStub({
status: 422,
rawBody: "validation failed: invalid from address",
});
const sender = createResendSender({ apiKey: "k", fetch });
await expect(sender.send(VALID_MSG)).rejects.toThrow(/status=422/);
await expect(sender.send(VALID_MSG)).rejects.toThrow(/validation failed/);
});
it("throws on network failure with cause context", async () => {
const { fetch } = makeFetchStub({
throwError: new Error("ECONNRESET"),
});
const sender = createResendSender({ apiKey: "k", fetch });
await expect(sender.send(VALID_MSG)).rejects.toThrow(/email send failed/);
await expect(sender.send(VALID_MSG)).rejects.toThrow(/ECONNRESET/);
});
it("honors baseUrl override (trailing slash stripped)", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "id-5" } });
const sender = createResendSender({
apiKey: "k",
fetch,
baseUrl: "https://private.resend.example.com/",
});
await sender.send(VALID_MSG);
expect(calls[0].url).toBe("https://private.resend.example.com/emails");
});
it("throws when provider response is missing id", async () => {
const { fetch } = makeFetchStub({ body: { somethingElse: 1 } });
const sender = createResendSender({ apiKey: "k", fetch });
await expect(sender.send(VALID_MSG)).rejects.toThrow(/missing 'id'/);
});
it("validates message before calling fetch (fast-fail)", async () => {
const { fetch, calls } = makeFetchStub({ body: { id: "x" } });
const sender = createResendSender({ apiKey: "k", fetch });
await expect(
sender.send({ ...VALID_MSG, from: "bad" }),
).rejects.toBeInstanceOf(TypeError);
expect(calls.length).toBe(0);
});
it("_buildResendBody omits undefined optional fields", () => {
const body = _buildResendBody(VALID_MSG);
expect(body.text).toBeUndefined();
expect(body.cc).toBeUndefined();
expect(body.bcc).toBeUndefined();
expect(body.reply_to).toBeUndefined();
expect(body.headers).toBeUndefined();
});
it("_buildResendBody includes cc/bcc arrays when provided", () => {
const body = _buildResendBody({
...VALID_MSG,
cc: "c@c.com",
bcc: ["d@d.com", "e@e.com"],
});
expect(body.cc).toEqual(["c@c.com"]);
expect(body.bcc).toEqual(["d@d.com", "e@e.com"]);
});
});
// ─── SMTP stub ──────────────────────────────────────────────────────────────
describe("createSmtpSender (stub)", () => {
it("throws with the planned-but-not-implemented message", () => {
expect(() =>
createSmtpSender({ host: "smtp.example.com" }),
).toThrow(/not yet implemented/);
});
});