Files
route-commerce/tests/unit/create-admin-user.test.ts
T
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

455 lines
15 KiB
TypeScript

/**
* Unit tests for `createAdminUser` in `src/actions/admin/users.ts`.
*
* The action does three things, in order:
* 1. Authorize the caller (platform_admin only) — tested via
* `getAdminUser` mock.
* 2. Create a Neon Auth account (via `auth.admin.createUser` first,
* with a public sign-up fallback).
* 3. Insert the local `admin_users` row + brand link inside a tx,
* send a welcome email (best-effort), and return the result.
*
* These tests mock the Neon Auth SDK, the DB layer, and the email
* service so the orchestration logic can be exercised without a real
* network or DB.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Use vi.hoisted to make mock fns available inside the hoisted
// vi.mock factories.
const {
mockGetAdminUser,
mockNeonAuthCreateUser,
mockQuery,
mockWithTx,
mockSendWelcomeEmail,
mockGetBrandSettings,
} = vi.hoisted(() => ({
mockGetAdminUser: vi.fn(),
mockNeonAuthCreateUser: vi.fn(),
mockQuery: vi.fn(),
mockWithTx: vi.fn(),
mockSendWelcomeEmail: vi.fn(),
mockGetBrandSettings: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/admin-permissions", () => ({
getAdminUser: mockGetAdminUser,
}));
vi.mock("@/lib/auth", () => ({
createUser: mockNeonAuthCreateUser,
}));
vi.mock("@/lib/db", () => ({
query: mockQuery,
withTx: mockWithTx,
}));
vi.mock("@/lib/email-service", () => ({
sendWelcomeEmail: mockSendWelcomeEmail,
}));
vi.mock("@/actions/brand-settings", () => ({
getBrandSettings: mockGetBrandSettings,
}));
vi.mock("next/headers", () => ({
cookies: () => Promise.resolve({ get: () => undefined }),
}));
// `fetch` is used by the signup fallback. Default to a harmless
// 200 — tests that exercise the fallback override this.
const originalFetch = global.fetch;
import { createAdminUser } from "@/actions/admin/users";
const PLATFORM_ADMIN = {
id: "platform-1",
email: "root@example.com",
role: "platform_admin" as const,
brand_id: null,
brand_slug: null,
brand_ids: [],
display_name: "Root",
active: true,
auth_provider: "neon_auth" as const,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
must_change_password: false,
user_id: "platform-1",
};
const BRAND_ADMIN = { ...PLATFORM_ADMIN, role: "brand_admin" as const, brand_id: "brand-tux" };
const baseInput = {
email: "newuser@example.com",
password: "TempPass123!",
role: "brand_admin" as const,
brand_id: "brand-tux",
display_name: "New User",
flags: { can_manage_orders: true },
mustChangePassword: true,
};
beforeEach(() => {
vi.clearAllMocks();
// Default: platform admin caller, no-op fetch, happy email path.
mockGetAdminUser.mockResolvedValue(PLATFORM_ADMIN);
mockSendWelcomeEmail.mockResolvedValue({ ok: true });
mockGetBrandSettings.mockResolvedValue({
success: true,
settings: { brand_name: "Tuxedo Citrus", logo_url: null },
});
// Set env vars the action reads at call time.
process.env.NEON_AUTH_BASE_URL = "https://test.neonauth.example/auth";
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:4000";
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ user: { id: "neon-user-from-signup" } }),
} as Response);
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("createAdminUser — authorization", () => {
it("rejects unauthenticated callers", async () => {
mockGetAdminUser.mockResolvedValue(null);
const r = await createAdminUser(baseInput);
expect(r.user).toBeNull();
expect(r.error).toMatch(/not authenticated/i);
expect(mockNeonAuthCreateUser).not.toHaveBeenCalled();
expect(mockWithTx).not.toHaveBeenCalled();
});
it("rejects non-platform-admin callers", async () => {
mockGetAdminUser.mockResolvedValue(BRAND_ADMIN);
const r = await createAdminUser(baseInput);
expect(r.user).toBeNull();
expect(r.error).toMatch(/only platform admins/i);
expect(mockNeonAuthCreateUser).not.toHaveBeenCalled();
});
});
describe("createAdminUser — happy path via auth.admin.createUser", () => {
it("uses the privileged admin endpoint when it succeeds", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: { user: { id: "neon-user-abc" } },
error: null,
});
mockWithTx.mockImplementation(async (fn) => {
// Simulate the tx client: record what the action called, then
// return a fresh admin row.
return fn({
query: vi
.fn()
.mockResolvedValueOnce({
rows: [
{
id: "admin-row-1",
user_id: "neon-user-abc",
display_name: "New User",
email: "newuser@example.com",
phone_number: null,
role: "brand_admin",
brand_id: "brand-tux",
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
active: true,
must_change_password: true,
created_at: "2026-06-17T00:00:00Z",
last_login: null,
},
],
})
// Second query inside the tx: admin_user_brands link.
.mockResolvedValueOnce({ rows: [] }),
} as never);
});
const r = await createAdminUser(baseInput);
expect(r.error).toBeNull();
expect(r.user?.email).toBe("newuser@example.com");
expect(r.tempPassword).toBe(baseInput.password);
expect(r.emailSent).toBe(true);
expect(r.authPath).toBe("admin");
expect(mockNeonAuthCreateUser).toHaveBeenCalledWith({
email: "newuser@example.com",
password: "TempPass123!",
name: "New User",
});
// Fallback should NOT have been called.
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe("createAdminUser — fallback to public sign-up", () => {
it("falls back to /sign-up/email when the admin endpoint fails with FORBIDDEN", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: null,
error: { code: "FORBIDDEN", message: "Admin role required" },
});
mockWithTx.mockImplementation(async (fn) =>
fn({
query: vi
.fn()
.mockResolvedValueOnce({
rows: [
{
id: "admin-row-2",
user_id: "neon-user-from-signup",
display_name: "New User",
email: "newuser@example.com",
phone_number: null,
role: "brand_admin",
brand_id: "brand-tux",
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
active: true,
must_change_password: true,
created_at: "2026-06-17T00:00:00Z",
last_login: null,
},
],
})
.mockResolvedValueOnce({ rows: [] }),
} as never),
);
// The fallback calls `query` to flip emailVerified=true. Pre-set
// a default that satisfies that call (and any future ones).
mockQuery.mockResolvedValue({ rows: [] });
const r = await createAdminUser(baseInput);
expect(r.error).toBeNull();
expect(r.authPath).toBe("signup");
expect(r.user?.user_id).toBe("neon-user-from-signup");
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toMatch(/\/sign-up\/email$/);
expect(JSON.parse(init.body)).toEqual({
email: "newuser@example.com",
password: "TempPass123!",
name: "New User",
callbackURL: "/admin",
});
});
it("rejects with a clear error when the email is already in use", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: null,
error: {
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
message: "User already exists",
},
});
const r = await createAdminUser(baseInput);
expect(r.user).toBeNull();
expect(r.error).toMatch(/already exists/i);
// We should not have called the fallback or the DB.
expect(global.fetch).not.toHaveBeenCalled();
expect(mockWithTx).not.toHaveBeenCalled();
});
it("surfaces the fallback error if the public sign-up fails", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: null,
error: { code: "INTERNAL_SERVER_ERROR", message: "boom" },
});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ code: "INTERNAL_SERVER_ERROR", message: "sign-up down" }),
} as Response);
const r = await createAdminUser(baseInput);
expect(r.user).toBeNull();
expect(r.error).toMatch(/sign-up down/i);
expect(mockWithTx).not.toHaveBeenCalled();
});
});
describe("createAdminUser — DB failure after auth success", () => {
it("returns an error explaining the orphaned Neon Auth user", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: { user: { id: "neon-user-orphaned" } },
error: null,
});
mockWithTx.mockRejectedValue(new Error("FK violation on brand_id"));
const r = await createAdminUser(baseInput);
expect(r.user).toBeNull();
expect(r.error).toMatch(/orphaned/i);
expect(r.error).toMatch(/FK violation/);
});
});
describe("createAdminUser — email is best-effort", () => {
it("still returns success when the welcome email fails", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: { user: { id: "neon-user-abc" } },
error: null,
});
mockWithTx.mockImplementation(async (fn) =>
fn({
query: vi.fn().mockResolvedValueOnce({
rows: [
{
id: "admin-row-3",
user_id: "neon-user-abc",
display_name: "New User",
email: "newuser@example.com",
phone_number: null,
role: "brand_admin",
brand_id: "brand-tux",
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
active: true,
must_change_password: true,
created_at: "2026-06-17T00:00:00Z",
last_login: null,
},
],
}),
} as never),
);
mockSendWelcomeEmail.mockResolvedValue({ ok: false, error: "Resend 422: domain not verified" });
const r = await createAdminUser(baseInput);
expect(r.error).toBeNull();
expect(r.user).not.toBeNull();
expect(r.emailSent).toBe(false);
expect(r.emailError).toBe("Resend 422: domain not verified");
expect(r.tempPassword).toBe(baseInput.password);
});
it("records the email error message when sendWelcomeEmail throws", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: { user: { id: "neon-user-abc" } },
error: null,
});
mockWithTx.mockImplementation(async (fn) =>
fn({
query: vi.fn().mockResolvedValueOnce({
rows: [
{
id: "admin-row-4",
user_id: "neon-user-abc",
display_name: "New User",
email: "newuser@example.com",
phone_number: null,
role: "brand_admin",
brand_id: "brand-tux",
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
active: true,
must_change_password: true,
created_at: "2026-06-17T00:00:00Z",
last_login: null,
},
],
}),
} as never),
);
mockSendWelcomeEmail.mockRejectedValue(new Error("Resend 401"));
const r = await createAdminUser(baseInput);
expect(r.error).toBeNull();
expect(r.emailSent).toBe(false);
expect(r.emailError).toMatch(/Resend 401/);
});
});
describe("createAdminUser — no brand", () => {
it("creates a platform admin without inserting an admin_user_brands link", async () => {
mockNeonAuthCreateUser.mockResolvedValue({
data: { user: { id: "neon-user-platform" } },
error: null,
});
const txClient = {
query: vi.fn().mockResolvedValueOnce({
rows: [
{
id: "admin-row-platform",
user_id: "neon-user-platform",
display_name: "New Platform",
email: "platform2@example.com",
phone_number: null,
role: "platform_admin",
brand_id: null,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
active: true,
must_change_password: true,
created_at: "2026-06-17T00:00:00Z",
last_login: null,
},
],
}),
};
mockWithTx.mockImplementation(async (fn) => fn(txClient as never));
const r = await createAdminUser({
...baseInput,
email: "platform2@example.com",
role: "platform_admin",
brand_id: null,
});
expect(r.error).toBeNull();
expect(r.user?.role).toBe("platform_admin");
// Only one query inside the tx: the INSERT. No brand link INSERT.
expect(txClient.query).toHaveBeenCalledTimes(1);
});
});