Files
route-commerce/tests/unit/email-service.test.ts
Tyler 7e665ea43e
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Email service surfaces real Resend errors instead of silent false
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.

Switch the public return type to a structured result:

  export type EmailSendResult = { ok: true } | { ok: false; error: string };

- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
  Add it to .env.local (or your hosting dashboard) and restart the
  server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
  domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.

Callers updated:
  - src/actions/admin/users.ts (createAdminUser): now propagates the
    real emailError into the modal UI — no more generic 'sendWelcome
    Email returned false'.
  - src/actions/checkout.ts: logs the real error.
  - src/app/api/cron/send-scheduled/route.ts: per-recipient error
    logged for the scheduled-campaigns cron.

Tests:
  - tests/unit/email-service.test.ts (new, 5 tests): covers happy
    path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
    body, and network failure.
  - tests/unit/create-admin-user.test.ts: updated to use the new
    { ok, error? } return shape; new assertion that the action
    surfaces the real emailError string into the result.
2026-06-17 12:10:04 -06:00

135 lines
3.9 KiB
TypeScript

/**
* Unit tests for the email service's structured `{ ok, error? }` return.
*
* The shared `sendEmail` helper is private, but `sendWelcomeEmail`
* calls it and is exported, so we exercise the happy path + the two
* new error paths (no API key, Resend 4xx/5xx with JSON body) through
* the public surface.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const originalFetch = global.fetch;
const originalKey = process.env.RESEND_API_KEY;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
if (originalKey === undefined) {
delete process.env.RESEND_API_KEY;
} else {
process.env.RESEND_API_KEY = originalKey;
}
});
async function importFresh() {
// Re-import the module after toggling RESEND_API_KEY / mocking fetch
// so the module-level constants pick up the new env.
vi.resetModules();
return await import("@/lib/email-service");
}
describe("sendWelcomeEmail — structured error reporting", () => {
it("returns ok=true when Resend accepts the message", async () => {
process.env.RESEND_API_KEY = "re_test_key";
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: "email-abc" }),
} as Response);
const { sendWelcomeEmail } = await importFresh();
const r = await sendWelcomeEmail({
to: "u@example.com",
name: "U",
role: "brand_admin",
brandName: "Tuxedo",
tempPassword: "TempPass123!",
});
expect(r.ok).toBe(true);
});
it("returns a clear error when RESEND_API_KEY is missing", async () => {
delete process.env.RESEND_API_KEY;
const { sendWelcomeEmail } = await importFresh();
const r = await sendWelcomeEmail({
to: "u@example.com",
name: "U",
role: "brand_admin",
brandName: "Tuxedo",
tempPassword: "TempPass123!",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toMatch(/RESEND_API_KEY is not set/i);
}
});
it("surfaces Resend's actual error message on 4xx/5xx", async () => {
process.env.RESEND_API_KEY = "re_test_key";
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
json: async () => ({
name: "validation_error",
message: "The gmail.com domain is not verified",
}),
} as Response);
const { sendWelcomeEmail } = await importFresh();
const r = await sendWelcomeEmail({
to: "u@gmail.com",
name: "U",
role: "brand_admin",
brandName: "Tuxedo",
tempPassword: "TempPass123!",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("Resend 422");
expect(r.error).toContain("validation_error");
expect(r.error).toContain("gmail.com domain is not verified");
}
});
it("surfaces the Resend message even when the response body is not JSON", async () => {
process.env.RESEND_API_KEY = "re_test_key";
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => {
throw new Error("not json");
},
} as unknown as Response);
const { sendWelcomeEmail } = await importFresh();
const r = await sendWelcomeEmail({
to: "u@example.com",
name: "U",
role: "brand_admin",
brandName: "Tuxedo",
tempPassword: "TempPass123!",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe("Resend 500");
}
});
it("surfaces network errors verbatim", async () => {
process.env.RESEND_API_KEY = "re_test_key";
global.fetch = vi.fn().mockRejectedValue(new Error("fetch failed: ECONNREFUSED"));
const { sendWelcomeEmail } = await importFresh();
const r = await sendWelcomeEmail({
to: "u@example.com",
name: "U",
role: "brand_admin",
brandName: "Tuxedo",
tempPassword: "TempPass123!",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe("fetch failed: ECONNREFUSED");
}
});
});