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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ TRIGGER_CALLBACK_URL=
# Redis
REDIS_URL=

# Stripe (subscription checkout; required when routes load `lib/stripe/client`)
STRIPE_SK=

# ── Tier 3: Deployment (Vercel sets these automatically) ──
# VERCEL_ENV=
# VERCEL_URL=
Expand Down
14 changes: 14 additions & 0 deletions app/api/subscriptions/sessions/__tests__/route.options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import "./routeTestMocks";
import { describe, it, expect } from "vitest";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";

const { OPTIONS } = await import("../route");

describe("OPTIONS /api/subscriptions/sessions", () => {
it("returns 200 with CORS headers", async () => {
const res = await OPTIONS();
expect(res.status).toBe(200);
expect(getCorsHeaders).toHaveBeenCalled();
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateCreateSubscriptionSessionRequest } from "@/lib/stripe/validateCreateSubscriptionSessionRequest";
import { createStripeSession } from "@/lib/stripe/createStripeSession";

const { POST } = await import("../route");

const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001";

describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionSessionRequest).mockReset();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
vi.mocked(console.error).mockRestore();
});

it("returns validation response unchanged", async () => {
const err = NextResponse.json({ error: "bad" }, { status: 400 });
vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue(err);
const req = new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
body: "{}",
});
expect(await POST(req)).toBe(err);
expect(createStripeSession).not.toHaveBeenCalled();
});

it("returns 200 with id and url", async () => {
vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
successUrl: "https://chat.recoupable.com/ok",
});
vi.mocked(createStripeSession).mockResolvedValue({
id: "cs_test_abc",
url: "https://checkout.stripe.com/pay/cs_test_abc",
} as Awaited<ReturnType<typeof createStripeSession>>);

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
body: "{}",
}),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: "cs_test_abc",
url: "https://checkout.stripe.com/pay/cs_test_abc",
});
});

it("returns 400 when session.url is null", async () => {
vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
successUrl: "https://chat.recoupable.com/ok",
});
vi.mocked(createStripeSession).mockResolvedValue({
id: "cs_test_abc",
url: null,
} as Awaited<ReturnType<typeof createStripeSession>>);

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
body: "{}",
}),
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "Checkout session URL missing" });
});

it("returns 500 when createStripeSession throws", async () => {
vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
successUrl: "https://chat.recoupable.com/ok",
});
vi.mocked(createStripeSession).mockRejectedValue(new Error("Stripe down"));

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
body: "{}",
}),
);
expect(res.status).toBe(500);
await expect(res.json()).resolves.toEqual({ error: "Internal server error" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateCreateSubscriptionSessionRequest } from "@/lib/stripe/validateCreateSubscriptionSessionRequest";
import { createStripeSession } from "@/lib/stripe/createStripeSession";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

const { POST } = await import("../route");

async function loadRealValidate() {
const mod = await vi.importActual<
typeof import("@/lib/stripe/validateCreateSubscriptionSessionRequest")
>("@/lib/stripe/validateCreateSubscriptionSessionRequest");
return mod.validateCreateSubscriptionSessionRequest;
}

describe("POST /api/subscriptions/sessions (validation)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionSessionRequest).mockReset();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
vi.mocked(console.error).mockRestore();
});

it("returns 400 when body is invalid JSON", async () => {
vi.mocked(validateCreateSubscriptionSessionRequest).mockImplementationOnce(
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
}),
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "Invalid JSON body" });
expect(createStripeSession).not.toHaveBeenCalled();
});

it("returns 400 when successUrl is missing", async () => {
vi.mocked(validateCreateSubscriptionSessionRequest).mockImplementationOnce(
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body).toEqual({ error: expect.stringMatching(/successUrl|Invalid input/i) });
expect(createStripeSession).not.toHaveBeenCalled();
});

it("returns 401 when not authenticated", async () => {
vi.mocked(validateAuthContext).mockResolvedValueOnce(
NextResponse.json(
{ status: "error", error: "Exactly one of x-api-key or Authorization must be provided" },
{ status: 401 },
),
);
vi.mocked(validateCreateSubscriptionSessionRequest).mockImplementationOnce(
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ successUrl: "https://chat.recoupable.com/ok" }),
}),
);
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({
error: "Exactly one of x-api-key or Authorization must be provided",
});
expect(createStripeSession).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions app/api/subscriptions/sessions/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import "./routeTestMocks";
import { describe, it, expect } from "vitest";

const { POST, OPTIONS } = await import("../route");

describe("app/api/subscriptions/sessions/route", () => {
it("exports POST and OPTIONS handlers", () => {
expect(typeof POST).toBe("function");
expect(typeof OPTIONS).toBe("function");
});
});
17 changes: 17 additions & 0 deletions app/api/subscriptions/sessions/__tests__/routeTestMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { vi } from "vitest";

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/stripe/validateCreateSubscriptionSessionRequest", () => ({
validateCreateSubscriptionSessionRequest: vi.fn(),
}));

vi.mock("@/lib/stripe/createStripeSession", () => ({
createStripeSession: vi.fn(),
}));
29 changes: 29 additions & 0 deletions app/api/subscriptions/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createSubscriptionSessionHandler } from "@/lib/stripe/createSubscriptionSessionHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST /api/subscriptions/sessions: creates a Stripe subscription checkout session.
*
* @param request - The incoming HTTP request.
* @returns A NextResponse with session `id` and `url`, or an error body.
*/
export async function POST(request: NextRequest) {
return createSubscriptionSessionHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
36 changes: 36 additions & 0 deletions lib/stripe/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, afterEach } from "vitest";

describe("lib/stripe/client", () => {
afterEach(() => {
vi.resetModules();
});

it("throws when STRIPE_SK is not set", async () => {
vi.resetModules();
vi.doUnmock("@/lib/stripe/client");
const saved = process.env.STRIPE_SK;
delete process.env.STRIPE_SK;
await expect(import("@/lib/stripe/client")).rejects.toThrow(
"STRIPE_SK environment variable is required",
);
if (saved === undefined) {
delete process.env.STRIPE_SK;
} else {
process.env.STRIPE_SK = saved;
}
});

it("loads a Stripe client when STRIPE_SK is set", async () => {
vi.resetModules();
vi.doUnmock("@/lib/stripe/client");
const saved = process.env.STRIPE_SK;
process.env.STRIPE_SK = "stripe_test_key_placeholder";
const mod = await import("@/lib/stripe/client");
expect(mod.default).toBeDefined();
if (saved === undefined) {
delete process.env.STRIPE_SK;
} else {
process.env.STRIPE_SK = saved;
}
});
});
49 changes: 49 additions & 0 deletions lib/stripe/__tests__/createStripeSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createStripeSession } from "@/lib/stripe/createStripeSession";
import {
STRIPE_SUBSCRIPTION_PRICE_ID,
STRIPE_SUBSCRIPTION_TRIAL_PERIOD_DAYS,
} from "@/lib/stripe/config";

const { checkoutSessionsCreate } = vi.hoisted(() => ({
checkoutSessionsCreate: vi.fn(),
}));

vi.mock("@/lib/stripe/client", () => ({
default: {
checkout: { sessions: { create: checkoutSessionsCreate } },
},
}));

describe("createStripeSession", () => {
beforeEach(() => {
vi.clearAllMocks();
checkoutSessionsCreate.mockResolvedValue({ id: "cs_x", url: "https://checkout.stripe.com/x" });
});

it("creates subscription checkout with expected params", async () => {
await createStripeSession("acc-1", "https://example.com/success");

expect(checkoutSessionsCreate).toHaveBeenCalledWith({
line_items: [{ price: STRIPE_SUBSCRIPTION_PRICE_ID, quantity: 1 }],
mode: "subscription",
client_reference_id: "acc-1",
metadata: { accountId: "acc-1" },
subscription_data: {
metadata: { accountId: "acc-1" },
trial_period_days: STRIPE_SUBSCRIPTION_TRIAL_PERIOD_DAYS,
},
success_url: "https://example.com/success",
});
});

it("does not set cancel_url, customer_email, promo or billing-collection fields", async () => {
await createStripeSession("acc-1", "https://example.com/success");
const params = checkoutSessionsCreate.mock.calls[0][0] as Record<string, unknown>;
expect(params).not.toHaveProperty("cancel_url");
expect(params).not.toHaveProperty("customer_email");
expect(params).not.toHaveProperty("allow_promotion_codes");
expect(params).not.toHaveProperty("billing_address_collection");
expect(params.client_reference_id).toBe("acc-1");
});
});
Loading
Loading