Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading