Skip to content
Open
40 changes: 39 additions & 1 deletion apps/web/__tests__/unit/auth-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import type { Account, NextAuthOptions } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EmailConfig } from "next-auth/providers/email";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

type AuthUser = {
id: string;
Expand All @@ -34,6 +35,7 @@ const mocks = vi.hoisted(() => ({
options: { getSsoIdentity: () => ValidatedSsoIdentity | null },
) => Adapter
>(() => ({})),
isCap: undefined as string | undefined,
}));

const env = vi.hoisted(() => ({
Expand All @@ -57,6 +59,11 @@ const env = vi.hoisted(() => ({

vi.mock("@cap/env", () => ({
serverEnv: () => env,
buildEnv: {
get NEXT_PUBLIC_IS_CAP() {
return mocks.isCap;
},
},
}));

vi.mock("@cap/database", () => ({
Expand Down Expand Up @@ -122,6 +129,7 @@ function providerIds(options: NextAuthOptions) {

describe("authOptions", () => {
beforeEach(() => {
mocks.isCap = undefined;
env.APPLE_CLIENT_ID = "so.cap.auth";
env.APPLE_CLIENT_SECRET = "apple-secret";
env.WORKOS_CLIENT_ID = "workos-client";
Expand Down Expand Up @@ -160,6 +168,36 @@ describe("authOptions", () => {
}));
});

afterEach(() => vi.unstubAllEnvs());

it.each([undefined, "", "false", "true"])(
"only logs undelivered codes for self-hosts (NEXT_PUBLIC_IS_CAP: %j)",
async (isCap) => {
mocks.isCap = isCap;
vi.stubEnv("NODE_ENV", "production");
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const provider = authOptions().providers.find(
(provider) => provider.id === "email",
) as EmailConfig;
const options = provider.options as EmailConfig;
const result = options.sendVerificationRequest({
identifier: "person@example.com",
token: "123456",
url: "http://localhost/verify",
expires: new Date(),
provider,
theme: {},
});
if (isCap === "true") {
await expect(result).rejects.toThrow("RESEND_API_KEY");
expect(log).not.toHaveBeenCalled();
} else {
await result;
expect(log).toHaveBeenCalledWith("🔢 Code: 123456");
}
},
);

it("enables Apple when both OAuth credentials are configured", () => {
const options = authOptions();
const providers = options.providers.map((provider) => provider.id);
Expand Down
123 changes: 123 additions & 0 deletions apps/web/__tests__/unit/email-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
env: {} as Record<string, string | undefined>,
build: {} as Record<string, string | undefined>,
send: vi.fn(),
}));

vi.mock("@cap/env", () => ({
serverEnv: () => mocks.env,
buildEnv: mocks.build,
}));

vi.mock("resend", () => ({
Resend: class {
emails = { send: mocks.send };
},
}));

import { getEmailDeliveryStatus, sendEmail } from "@cap/database/emails/config";

const message = {
email: "user@example.com",
subject: "Hello",
react: null as unknown as Parameters<typeof sendEmail>[0]["react"],
};

describe("email config", () => {
beforeEach(() => {
mocks.send.mockReset();
for (const key of Object.keys(mocks.env)) delete mocks.env[key];
for (const key of Object.keys(mocks.build)) delete mocks.build[key];
});

it("reports missing RESEND_API_KEY", () => {
const status = getEmailDeliveryStatus();
expect(status.configured).toBe(false);
expect(status.configured ? "" : status.reason).toContain(
"Email (Resend) is not configured",
);
});

it.each([undefined, "", "false"])(
"requires a sender domain with NEXT_PUBLIC_IS_CAP=%j",
async (isCap) => {
mocks.build.NEXT_PUBLIC_IS_CAP = isCap;
mocks.env.RESEND_API_KEY = "re_test";
expect(getEmailDeliveryStatus().configured).toBe(false);

const result = await sendEmail(message);
expect(mocks.send).not.toHaveBeenCalled();
expect(result?.error?.name).toBe("email_not_configured");
},
);

it("sends an explicit sender without a default from-domain", async () => {
mocks.env.RESEND_API_KEY = "re_test";
mocks.send.mockResolvedValue({ data: { id: "email_2" }, error: null });

const result = await sendEmail({
...message,
fromOverride: "Cap Support <richie@send.cap.so>",
});

expect(mocks.send.mock.calls[0]?.[0].from).toBe(
"Cap Support <richie@send.cap.so>",
);
expect(result).toEqual({ data: { id: "email_2" }, error: null });
});

it.each([undefined, "false"])(
"sends from the configured domain with NEXT_PUBLIC_IS_CAP=%j",
async (isCap) => {
mocks.build.NEXT_PUBLIC_IS_CAP = isCap;
mocks.env.RESEND_API_KEY = "re_test";
mocks.env.RESEND_FROM_DOMAIN = "example.com";
mocks.send.mockResolvedValue({ data: { id: "email_1" }, error: null });

const result = await sendEmail(message);
expect(mocks.send.mock.calls[0]?.[0].from).toBe("auth@example.com");
expect(result).toEqual({ data: { id: "email_1" }, error: null });
},
);

it("uses the Cap Cloud sender when explicitly enabled", async () => {
mocks.build.NEXT_PUBLIC_IS_CAP = "true";
mocks.env.RESEND_API_KEY = "re_test";
mocks.send.mockResolvedValue({ data: { id: "cloud_email" }, error: null });

expect(getEmailDeliveryStatus().configured).toBe(true);
await sendEmail(message);

expect(mocks.send.mock.calls[0]?.[0].from).toBe(
"Cap Auth <no-reply@auth.cap.so>",
);
});

it.each([undefined, "false"])(
"skips marketing on self-hosts with NEXT_PUBLIC_IS_CAP=%j",
async (isCap) => {
mocks.build.NEXT_PUBLIC_IS_CAP = isCap;
mocks.env.RESEND_API_KEY = "re_test";
mocks.env.RESEND_FROM_DOMAIN = "example.com";
mocks.send.mockResolvedValue({ data: { id: "marketing" }, error: null });

await sendEmail({ ...message, marketing: true });

expect(mocks.send).not.toHaveBeenCalled();
},
);

it("surfaces provider rejections instead of claiming success", async () => {
mocks.env.RESEND_API_KEY = "re_test";
mocks.env.RESEND_FROM_DOMAIN = "example.com";
mocks.send.mockResolvedValue({
data: null,
error: { name: "validation_error", message: "domain not verified" },
});

const result = await sendEmail(message);
expect(result?.error?.message).toBe("domain not verified");
});
});
159 changes: 159 additions & 0 deletions apps/web/__tests__/unit/email-delivery-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// @vitest-environment jsdom

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, createElement, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { sendOrganizationInvites } from "@/actions/organization/send-invites";
import { InviteDialog } from "@/app/(org)/dashboard/settings/organization/components/InviteDialog";
import { VerifyOTPForm } from "@/app/(org)/verify-otp/form";

const mocks = vi.hoisted(() => ({
invites: vi.fn<typeof sendOrganizationInvites>(),
signIn: vi.fn(),
refresh: vi.fn(),
close: vi.fn(),
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));

vi.mock("@/actions/organization/send-invites", () => ({
sendOrganizationInvites: mocks.invites,
}));
vi.mock("@/app/(org)/dashboard/Contexts", () => ({
useDashboardContext: () => ({
activeOrganization: { organization: { id: "org-1", name: "Team" } },
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: mocks.refresh }),
}));
vi.mock("next-auth/react", () => ({ signIn: mocks.signIn }));
vi.mock("sonner", () => ({ toast: mocks.toast }));
vi.mock("@cap/utils", async () => await import("@cap/utils/helpers"));
vi.mock("@cap/ui", async () => ({
...(await import("../../../../packages/ui/src/components/Button")),
...(await import("../../../../packages/ui/src/components/Dialog")),
...(await import("../../../../packages/ui/src/components/Select")),
...(await import("../../../../packages/ui/src/components/Switch")),
...(await import("../../../../packages/ui/src/components/input/Input")),
...(await import("../../../../packages/ui/src/components/icons/LogoBadge")),
}));

let root: Root;
let container: HTMLDivElement;
let queryClient: QueryClient;

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.invites.mockReset();
mocks.signIn.mockResolvedValue({ ok: true });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
queryClient.clear();
vi.unstubAllGlobals();
});

async function render(element: ReactElement) {
await act(async () => {
root.render(
createElement(QueryClientProvider, { client: queryClient }, element),
);
});
}

async function clickButton(label: string) {
const button = Array.from(document.querySelectorAll("button")).find(
(element) => element.textContent?.trim() === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
await act(async () => {
button.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

describe("verification email delivery messages", () => {
it.each([false, true])(
"reports the correct resend destination when email configured is %s",
async (emailDeliveryConfigured) => {
await render(
createElement(VerifyOTPForm, {
email: "person@example.com",
emailDeliveryConfigured,
}),
);
expect(container.textContent).toContain(
emailDeliveryConfigured
? "We sent a 6-digit code"
: "was printed to the server logs",
);
await clickButton("Didn't receive the code? Resend");
expect(mocks.signIn).toHaveBeenCalledWith("email", {
email: "person@example.com",
redirect: false,
});
expect(mocks.toast.success).toHaveBeenCalledExactlyOnceWith(
emailDeliveryConfigured
? "A new code has been sent to your email!"
: "A new code was printed to the server logs",
);
},
);
});

describe("invitation email delivery messages", () => {
it.each([false, true])(
"does not claim skipped emails were sent when delivery failures are %s",
async (hasFailures) => {
mocks.invites.mockResolvedValue({
success: true,
failedEmails: hasFailures ? ["failed@example.com"] : [],
skippedEmails: ["skipped@example.com"],
});
await render(
createElement(InviteDialog, { isOpen: true, setIsOpen: mocks.close }),
);
const input = document.querySelector<HTMLInputElement>(
"input[placeholder='name@company.com']",
);
if (!input) throw new Error("Missing invitation email input");
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set?.call(
input,
hasFailures
? "skipped@example.com, failed@example.com"
: "skipped@example.com",
);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await clickButton("Send Invites");
expect(mocks.invites).toHaveBeenCalledOnce();
expect(mocks.toast.warning).toHaveBeenCalledWith(
"Invites created but not emailed because email is not configured. Share the invite links with: skipped@example.com",
);
if (hasFailures) {
expect(mocks.toast.warning).toHaveBeenCalledWith(
"Invite email delivery failed for: failed@example.com",
);
}
expect(mocks.toast.warning).not.toHaveBeenCalledWith(
expect.stringContaining("Invites sent"),
);
expect(mocks.toast.success).not.toHaveBeenCalled();
expect(mocks.close).toHaveBeenCalledWith(false);
expect(mocks.refresh).toHaveBeenCalledOnce();
},
);
});
26 changes: 26 additions & 0 deletions apps/web/__tests__/unit/messenger-support-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,32 @@ describe("sendMessengerSupportEmail", () => {
expect(valuesMock).not.toHaveBeenCalled();
});

it.each([
["email_not_configured", "Email is not configured"],
["validation_error", "Sender domain is not verified"],
])("does not report a returned %s as sent", async (name, message) => {
whereMock.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ value: 0 }]);
sendEmailMock.mockResolvedValueOnce({
data: null,
error: { name, message },
});
const { sendMessengerSupportEmail } = await import(
"@/lib/messenger/support-email"
);

await expect(
sendMessengerSupportEmail({
user: {
id: User.UserId.make("user-123"),
email: "user@example.com",
},
conversationId: "conversation-123",
subject: "Upload issue",
message: "Uploads keep failing.",
}),
).rejects.toThrow(message);
});

it("does not send when the audit reservation fails", async () => {
whereMock.mockReturnValueOnce(mockDb).mockResolvedValueOnce([{ value: 1 }]);
valuesMock.mockRejectedValueOnce(new Error("insert failed"));
Expand Down
Loading