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
146 changes: 146 additions & 0 deletions app/api/newsletter/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const hashIp = vi.fn(() => "hashed-ip");
const getClientIp = vi.fn(() => "127.0.0.1");
const consumeSharedRateLimit = vi.fn(async () => ({
allowed: true,
remaining: 2,
resetAt: new Date().toISOString(),
currentCount: 1,
}));
const from = vi.fn();
const sendNewsletterWelcome = vi.fn(async () => {});

vi.mock("@/lib/supabase/admin", () => ({
createSupabaseAdminClient: () => ({ from }),
}));

vi.mock("@/lib/utils/hash", () => ({
hashIp,
getClientIp,
}));

vi.mock("@/lib/rate-limit/shared", () => ({
consumeSharedRateLimit,
}));

vi.mock("@/lib/observability/events", () => ({
logEvent: vi.fn(),
}));

vi.mock("@/lib/resend/newsletter", () => ({
sendNewsletterWelcome,
}));

function createRequest(body: unknown) {
return new NextRequest("http://localhost/api/newsletter", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

function mockNewSubscriber() {
from.mockReturnValue({
select: () => ({
eq: () => ({
maybeSingle: async () => ({ data: null }),
}),
}),
insert: () => ({
select: () => ({
single: async () => ({
data: { unsubscribe_token: "token-123" },
error: null,
}),
}),
}),
});
}

function mockExistingSubscriber(status: string) {
const update = vi.fn(() => ({
eq: async () => ({ error: null }),
}));
from.mockReturnValue({
select: () => ({
eq: () => ({
maybeSingle: async () => ({
data: { id: "sub-1", status, unsubscribe_token: "token-existing" },
}),
}),
}),
update,
});
return update;
}

describe("POST /api/newsletter", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
consumeSharedRateLimit.mockResolvedValue({
allowed: true,
remaining: 2,
resetAt: new Date().toISOString(),
currentCount: 1,
});
});

it("subscribes a new email and sends the welcome email", async () => {
mockNewSubscriber();

const { POST } = await import("./route");
const response = await POST(createRequest({ email: "new@example.com" }));

expect(response.status).toBe(200);
expect(sendNewsletterWelcome).toHaveBeenCalledWith(
"new@example.com",
"token-123",
);
});

it("does not resend the welcome email to an already-active subscriber", async () => {
mockExistingSubscriber("active");

const { POST } = await import("./route");
const response = await POST(
createRequest({ email: "existing@example.com" }),
);

expect(response.status).toBe(200);
expect(sendNewsletterWelcome).not.toHaveBeenCalled();
});

it("resends the welcome email when reactivating an unsubscribed address", async () => {
mockExistingSubscriber("unsubscribed");

const { POST } = await import("./route");
const response = await POST(
createRequest({ email: "existing@example.com" }),
);

expect(response.status).toBe(200);
expect(sendNewsletterWelcome).toHaveBeenCalledWith(
"existing@example.com",
"token-existing",
);
});

it("returns 429 and skips the database and email when rate limited", async () => {
consumeSharedRateLimit.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date().toISOString(),
currentCount: 3,
});

const { POST } = await import("./route");
const response = await POST(createRequest({ email: "spam@example.com" }));

expect(response.status).toBe(429);
expect(from).not.toHaveBeenCalled();
expect(sendNewsletterWelcome).not.toHaveBeenCalled();
});
});
38 changes: 34 additions & 4 deletions app/api/newsletter/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,36 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
import { sendNewsletterWelcome } from "@/lib/resend/newsletter";
import { hashIp, getClientIp } from "@/lib/utils/hash";
import { logEvent } from "@/lib/observability/events";
import { consumeSharedRateLimit } from "@/lib/rate-limit/shared";

const schema = z.object({ email: z.string().email() });

const WINDOW_SECONDS = 60 * 60;
const MAX_REQUESTS = 3;

export async function POST(req: NextRequest) {
const ipHash = hashIp(getClientIp(req.headers));

const rateLimit = await consumeSharedRateLimit(
`newsletter:${ipHash}`,
WINDOW_SECONDS,
MAX_REQUESTS,
);

if (!rateLimit.allowed) {
logEvent({
event: "newsletter.rate_limited",
level: "warn",
ipHash,
});
return NextResponse.json(
{ error: "Too many signup requests. Try again later." },
{ status: 429 },
);
}

let body: unknown;
try {
body = await req.json();
Expand All @@ -31,13 +57,15 @@ export async function POST(req: NextRequest) {
.maybeSingle();

let token: string | undefined = existing?.unsubscribe_token;
let shouldSendWelcome = !existing;

if (existing) {
if (existing.status !== "active") {
await supabase
.from("newsletter_subscribers")
.update({ status: "active" })
.eq("id", existing.id);
shouldSendWelcome = true;
}
} else {
const { data: inserted, error } = await supabase
Expand All @@ -56,10 +84,12 @@ export async function POST(req: NextRequest) {
}

// Welcome email is best-effort; subscription already succeeded.
try {
if (token) await sendNewsletterWelcome(email, token);
} catch (e) {
console.error("[newsletter] welcome email failed:", e);
if (shouldSendWelcome) {
try {
if (token) await sendNewsletterWelcome(email, token);
} catch (e) {
console.error("[newsletter] welcome email failed:", e);
}
}

return NextResponse.json({ ok: true });
Expand Down
Loading