Skip to content
Open
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
14 changes: 14 additions & 0 deletions app/api/stripe/checkout-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/stripe/checkout-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
Expand Up @@ -8,7 +8,7 @@ const { POST } = await import("../route");

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

describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
describe("POST /api/stripe/checkout-sessions (handler outcomes)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionSessionRequest).mockReset();
Expand All @@ -22,7 +22,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
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", {
const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
body: "{}",
});
Expand All @@ -41,7 +41,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
} as Awaited<ReturnType<typeof createStripeSession>>);

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
body: "{}",
}),
Expand All @@ -64,7 +64,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
} as Awaited<ReturnType<typeof createStripeSession>>);

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
body: "{}",
}),
Expand All @@ -81,7 +81,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => {
vi.mocked(createStripeSession).mockRejectedValue(new Error("Stripe down"));

const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
body: "{}",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function loadRealValidate() {
return mod.validateCreateSubscriptionSessionRequest;
}

describe("POST /api/subscriptions/sessions (validation)", () => {
describe("POST /api/stripe/checkout-sessions (validation)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionSessionRequest).mockReset();
Expand All @@ -30,7 +30,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => {
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
Expand All @@ -46,7 +46,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => {
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
Expand All @@ -69,7 +69,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => {
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/sessions", {
new NextRequest("http://localhost/api/stripe/checkout-sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ successUrl: "https://chat.recoupable.com/ok" }),
Expand Down
11 changes: 11 additions & 0 deletions app/api/stripe/checkout-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/stripe/checkout-sessions/route", () => {
it("exports POST and OPTIONS handlers", () => {
expect(typeof POST).toBe("function");
expect(typeof OPTIONS).toBe("function");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function OPTIONS() {
}

/**
* POST /api/subscriptions/sessions: creates a Stripe subscription checkout session.
* POST /api/stripe/checkout-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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";

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

describe("OPTIONS /api/subscriptions/sessions", () => {
describe("OPTIONS /api/stripe/portal-sessions", () => {
it("returns 200 with CORS headers", async () => {
const res = await OPTIONS();
expect(res.status).toBe(200);
Expand Down
119 changes: 119 additions & 0 deletions app/api/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import "./routeTestMocks";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

File exceeds the repository’s 100-line limit for maintainability.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts, line 1:

<comment>File exceeds the repository’s 100-line limit for maintainability.</comment>

<file context>
@@ -0,0 +1,119 @@
+import "./routeTestMocks";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
</file context>

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateCreatePortalSessionRequest } from "@/lib/stripe/validateCreatePortalSessionRequest";
import { createPortalSession } from "@/lib/stripe/createPortalSession";
import { getStripeCustomerIdByAccountId } from "@/lib/supabase/billing_customers/getStripeCustomerIdByAccountId";

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

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

describe("POST /api/stripe/portal-sessions (handler outcomes)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreatePortalSessionRequest).mockReset();
vi.mocked(getStripeCustomerIdByAccountId).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(validateCreatePortalSessionRequest).mockResolvedValue(err);
const req = new NextRequest("http://localhost/api/stripe/portal-sessions", {
method: "POST",
body: "{}",
});
expect(await POST(req)).toBe(err);
expect(getStripeCustomerIdByAccountId).not.toHaveBeenCalled();
expect(createPortalSession).not.toHaveBeenCalled();
});

it("returns 404 when account has no Stripe customer", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/back",
});
vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue(null);

const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-sessions", {
method: "POST",
body: "{}",
}),
);
expect(res.status).toBe(404);
await expect(res.json()).resolves.toEqual({
error: "No Stripe customer found for this account",
});
expect(createPortalSession).not.toHaveBeenCalled();
});

it("returns 200 with id and url", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/back",
});
vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123");
vi.mocked(createPortalSession).mockResolvedValue({
id: "bps_test_abc",
url: "https://billing.stripe.com/p/session/abc",
} as Awaited<ReturnType<typeof createPortalSession>>);

const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-sessions", {
method: "POST",
body: "{}",
}),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: "bps_test_abc",
url: "https://billing.stripe.com/p/session/abc",
});
expect(createPortalSession).toHaveBeenCalledWith("cus_123", "https://chat.recoupable.com/back");
});

it("returns 400 when session.url is missing", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/back",
});
vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123");
vi.mocked(createPortalSession).mockResolvedValue({
id: "bps_test_abc",
url: null,
} as unknown as Awaited<ReturnType<typeof createPortalSession>>);

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

it("returns 500 when createPortalSession throws", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/back",
});
vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123");
vi.mocked(createPortalSession).mockRejectedValue(new Error("Stripe down"));

const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-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,78 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateCreatePortalSessionRequest } from "@/lib/stripe/validateCreatePortalSessionRequest";
import { createPortalSession } from "@/lib/stripe/createPortalSession";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

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

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

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

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

it("returns 400 when body is invalid JSON", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate());
const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-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(createPortalSession).not.toHaveBeenCalled();
});

it("returns 400 when returnUrl is missing", async () => {
vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate());
const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-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(/returnUrl|Invalid input/i) });
expect(createPortalSession).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(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate());
const res = await POST(
new NextRequest("http://localhost/api/stripe/portal-sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/back" }),
}),
);
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({
error: "Exactly one of x-api-key or Authorization must be provided",
});
expect(createPortalSession).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";

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

describe("app/api/subscriptions/sessions/route", () => {
describe("app/api/stripe/portal-sessions/route", () => {
it("exports POST and OPTIONS handlers", () => {
expect(typeof POST).toBe("function");
expect(typeof OPTIONS).toBe("function");
Expand Down
21 changes: 21 additions & 0 deletions app/api/stripe/portal-sessions/__tests__/routeTestMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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/validateCreatePortalSessionRequest", () => ({
validateCreatePortalSessionRequest: vi.fn(),
}));

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

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

/**
* 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/stripe/portal-sessions: creates a Stripe billing portal
* session for the authenticated account's existing Stripe customer.
*
* @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 createPortalSessionHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
Loading
Loading