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/subscriptions/portal/__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/portal", () => {
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,58 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody";
import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession";
import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails";

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

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

describe("POST /api/subscriptions/portal (handler outcomes — validation & no subscription)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionPortalBody).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(validateCreateSubscriptionPortalBody).mockResolvedValue(err);
const req = new NextRequest("http://localhost/api/subscriptions/portal", {
method: "POST",
body: "{}",
});
expect(await POST(req)).toBe(err);
expect(getActiveSubscriptionDetails).not.toHaveBeenCalled();
});

it("returns 400 when no active subscription", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "No active subscription found" });
expect(createBillingPortalSession).not.toHaveBeenCalled();
});

it("returns 500 when subscription lookup fails", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockRejectedValue(new Error("stripe down"));
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", { 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,53 @@
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.

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

Test file exceeds the repository's 100-line limit.

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

<comment>Test file exceeds the repository's 100-line limit.</comment>

<file context>
@@ -0,0 +1,120 @@
+
+const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001";
+
+describe("POST /api/subscriptions/portal (handler outcomes)", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

already implemented.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the update!

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody";
import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession";
import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails";

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

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

function mockValidated() {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({
customer: "cus_test_123",
} as Awaited<ReturnType<typeof getActiveSubscriptionDetails>>);
}

describe("POST /api/subscriptions/portal (portal session errors)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCreateSubscriptionPortalBody).mockReset();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

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

it("returns 400 when session.url is null", async () => {
mockValidated();
vi.mocked(createBillingPortalSession).mockResolvedValue({
id: "bps_test_abc",
url: null,
} as Awaited<ReturnType<typeof createBillingPortalSession>>);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({ error: "Billing portal URL missing" });
});

it("returns 500 when createBillingPortalSession throws", async () => {
mockValidated();
vi.mocked(createBillingPortalSession).mockRejectedValue(new Error("Stripe down"));
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", { 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,42 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody";
import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession";
import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails";

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

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

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

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

it("returns id and url when portal session is created", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({
customer: "cus_test_123",
} as Awaited<ReturnType<typeof getActiveSubscriptionDetails>>);
vi.mocked(createBillingPortalSession).mockResolvedValue({
id: "bps_test_abc",
url: "https://billing.example.com/session/abc",
} as Awaited<ReturnType<typeof createBillingPortalSession>>);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: "bps_test_abc",
url: "https://billing.example.com/session/abc",
});
});
});
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 { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody";
import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

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

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

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

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

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

it("returns 400 when returnUrl is missing", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockImplementationOnce(
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", {
method: "POST",
headers: { "content-type": "application/json", "x-api-key": "k" },
body: JSON.stringify({}),
}),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body).toEqual({ error: expect.stringMatching(/returnUrl|Invalid input/i) });
expect(createBillingPortalSession).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(validateCreateSubscriptionPortalBody).mockImplementationOnce(
await loadRealValidate(),
);
const res = await POST(
new NextRequest("http://localhost/api/subscriptions/portal", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/billing" }),
}),
);
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({
error: "Exactly one of x-api-key or Authorization must be provided",
});
expect(createBillingPortalSession).not.toHaveBeenCalled();
});
});
21 changes: 21 additions & 0 deletions app/api/subscriptions/portal/__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/validateCreateSubscriptionPortalBody", () => ({
validateCreateSubscriptionPortalBody: vi.fn(),
}));

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

vi.mock("@/lib/stripe/createBillingPortalSession", () => ({
createBillingPortalSession: vi.fn(),
}));
29 changes: 29 additions & 0 deletions app/api/subscriptions/portal/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 { createSubscriptionPortalHandler } from "@/lib/stripe/createSubscriptionPortalHandler";

/**
* 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/portal: creates a subscription management (billing portal) session.
*
* @param request - The incoming HTTP request.
* @returns A NextResponse with portal session `id` and `url`, or an error body.
*/
export async function POST(request: NextRequest) {
return createSubscriptionPortalHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
83 changes: 83 additions & 0 deletions lib/stripe/__tests__/createSubscriptionPortalHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { createSubscriptionPortalHandler } from "@/lib/stripe/createSubscriptionPortalHandler";
import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody";
import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession";
import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails";

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

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

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

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

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

describe("createSubscriptionPortalHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
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(validateCreateSubscriptionPortalBody).mockResolvedValue(err);
const req = new NextRequest("http://localhost/api/subscriptions/portal", {
method: "POST",
body: "{}",
});
expect(await createSubscriptionPortalHandler(req)).toBe(err);
expect(getActiveSubscriptionDetails).not.toHaveBeenCalled();
});

it("returns 200 with id and url", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({
customer: "cus_test_123",
} as Awaited<ReturnType<typeof getActiveSubscriptionDetails>>);
vi.mocked(createBillingPortalSession).mockResolvedValue({
id: "bps_test_abc",
url: "https://billing.example.com/session/abc",
} as Awaited<ReturnType<typeof createBillingPortalSession>>);

const res = await createSubscriptionPortalHandler(
new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
id: "bps_test_abc",
url: "https://billing.example.com/session/abc",
});
});

it("returns 500 when createBillingPortalSession throws", async () => {
vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({
accountId: ACCOUNT,
returnUrl: "https://chat.recoupable.com/billing",
});
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({
customer: "cus_test_123",
} as Awaited<ReturnType<typeof getActiveSubscriptionDetails>>);
vi.mocked(createBillingPortalSession).mockRejectedValue(new Error("Stripe down"));

const res = await createSubscriptionPortalHandler(
new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }),
);
expect(res.status).toBe(500);
await expect(res.json()).resolves.toEqual({ error: "Internal server error" });
});
});
Loading
Loading