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
46 changes: 46 additions & 0 deletions app/api/accounts/[id]/subscription/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import "./routeTestMocks";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler";

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

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

describe("app/api/accounts/[id]/subscription/route", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("OPTIONS 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("*");
});

it("GET delegates to getAccountSubscriptionHandler with the path params", async () => {
const handlerRes = NextResponse.json(
{ isPro: true, status: "active", plan: "pro", source: "account" },
{ status: 200 },
);
vi.mocked(getAccountSubscriptionHandler).mockResolvedValue(handlerRes);

const req = new NextRequest(`http://localhost/api/accounts/${ACCOUNT_ID}/subscription`, {
headers: { "x-api-key": "test-key" },
});
const params = Promise.resolve({ id: ACCOUNT_ID });
const res = await GET(req, { params });

expect(getAccountSubscriptionHandler).toHaveBeenCalledTimes(1);
expect(getAccountSubscriptionHandler).toHaveBeenCalledWith(req, params);
expect(res).toBe(handlerRes);
await expect(res.json()).resolves.toEqual({
isPro: true,
status: "active",
plan: "pro",
source: "account",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { vi } from "vitest";

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

vi.mock("@/lib/stripe/getAccountSubscriptionHandler", () => ({
getAccountSubscriptionHandler: vi.fn(),
}));
35 changes: 35 additions & 0 deletions app/api/accounts/[id]/subscription/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler";

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

/**
* GET /api/accounts/[id]/subscription
*
* Returns the subscription resource for an account, including coverage via organization
* membership. Requires authentication via `x-api-key` or `Authorization: Bearer`; the caller
* must be the account itself or have access via organization membership.
*
* @param request - Incoming request; auth is read from headers.
* @param context - Route context from Next.js.
* @param context.params - Promise resolving to `{ id }`, the account UUID from the URL path.
* @returns A 200 NextResponse with `{ isPro, status, plan, source }`, or 4xx with `{ error }`.
*/
export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) {
return getAccountSubscriptionHandler(request, context.params);
}

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

const activeSub = (status: Stripe.Subscription.Status = "active") =>
({ status, canceled_at: null }) as unknown as Stripe.Subscription;

describe("buildSubscriptionResponse", () => {
it("returns isPro:false / none / null / null when neither subscription is active", () => {
expect(buildSubscriptionResponse({ account: null, organization: null })).toEqual({
isPro: false,
status: "none",
plan: null,
source: null,
});
});

it("prefers the account subscription when active", () => {
expect(
buildSubscriptionResponse({
account: activeSub("active"),
organization: activeSub("trialing"),
}),
).toEqual({
isPro: true,
status: "active",
plan: "pro",
source: "account",
});
});

it("falls back to the organization subscription when only org is active", () => {
expect(
buildSubscriptionResponse({
account: null,
organization: activeSub("trialing"),
}),
).toEqual({
isPro: true,
status: "trialing",
plan: "pro",
source: "organization",
});
});

it("treats trialing-with-canceled_at as inactive", () => {
const canceledTrial = {
status: "trialing",
canceled_at: 1700000000,
} as unknown as Stripe.Subscription;

expect(buildSubscriptionResponse({ account: canceledTrial, organization: null })).toEqual({
isPro: false,
status: "none",
plan: null,
source: null,
});
});

it("normalizes unsupported Stripe statuses to 'none' when somehow active", () => {
const weird = { status: "incomplete", canceled_at: null } as unknown as Stripe.Subscription;
expect(buildSubscriptionResponse({ account: weird, organization: null })).toEqual({
isPro: false,
status: "none",
plan: null,
source: null,
});
});
});
98 changes: 98 additions & 0 deletions lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler";

import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams";
import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails";
import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription";

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

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

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

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

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

const buildRequest = () => new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`);

const buildParams = () => Promise.resolve({ id: ACCOUNT });

describe("getAccountSubscriptionHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards validation/auth errors as { error } with original status", async () => {
const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(denial);

const res = await getAccountSubscriptionHandler(buildRequest(), buildParams());
expect(res.status).toBe(401);
await expect(res.json()).resolves.toEqual({ error: "Unauthorized" });
expect(getActiveSubscriptionDetails).not.toHaveBeenCalled();
});

it("returns the resource shape for an active account subscription", async () => {
vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT);
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({
id: "sub_1",
status: "active",
canceled_at: null,
} as never);
vi.mocked(getOrgSubscription).mockResolvedValue(null);

const res = await getAccountSubscriptionHandler(buildRequest(), buildParams());
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
isPro: true,
status: "active",
plan: "pro",
source: "account",
});
});

it("returns source: organization when only the org subscription is active", async () => {
vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT);
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null);
vi.mocked(getOrgSubscription).mockResolvedValue({
id: "sub_org",
status: "trialing",
canceled_at: null,
} as never);

const res = await getAccountSubscriptionHandler(buildRequest(), buildParams());
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
isPro: true,
status: "trialing",
plan: "pro",
source: "organization",
});
});

it("returns isPro:false / none / null when neither subscription is active", async () => {
vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT);
vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null);
vi.mocked(getOrgSubscription).mockResolvedValue(null);

const res = await getAccountSubscriptionHandler(buildRequest(), buildParams());
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
isPro: false,
status: "none",
plan: null,
source: null,
});
});
});
71 changes: 71 additions & 0 deletions lib/stripe/__tests__/getActiveSubscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions";
import stripeClient from "@/lib/stripe/client";
import { getActiveSubscriptionsTestHelpers } from "./getActiveSubscriptionsTestHelpers";

vi.mock("@/lib/stripe/client", () => ({
default: { subscriptions: { list: vi.fn() } },
}));

const {
testAccountId: ACC,
subscription: sub,
subscriptionListPage: apiList,
} = getActiveSubscriptionsTestHelpers();
const list = () => vi.mocked(stripeClient.subscriptions.list);

describe("getActiveSubscriptions", () => {
beforeEach(() => vi.clearAllMocks());

it("walks pages until a batch matches accountId", async () => {
list()
.mockResolvedValueOnce(apiList([sub("sub_x", "other")], true))
.mockResolvedValueOnce(apiList([sub("sub_1", ACC)], true));
const result = await getActiveSubscriptions(ACC);
expect(list()).toHaveBeenCalledTimes(2);
expect(result.map(s => s.id)).toEqual(["sub_1"]);
expect(list().mock.calls[1][0]).toMatchObject({ starting_after: "sub_x", limit: 100 });
});

it("finds a match on a later page (no artificial page limit)", async () => {
for (let i = 0; i < 52; i++)
list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], true));
list().mockResolvedValueOnce(apiList([sub("sub_late", ACC)], false));
const result = await getActiveSubscriptions(ACC);
expect(result.map(s => s.id)).toEqual(["sub_late"]);
expect(list()).toHaveBeenCalledTimes(53);
});

it("stops after the first page that includes a match", async () => {
list().mockResolvedValueOnce(
apiList([sub("sub_x", "other"), sub("sub_1", ACC), sub("sub_2", ACC)], true),
);
const result = await getActiveSubscriptions(ACC);
expect(list()).toHaveBeenCalledTimes(1);
expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]);
});

it("returns [] when nothing matches after Stripe exhausts pages", async () => {
for (let i = 0; i < 3; i++) {
list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], i < 2));
}
const result = await getActiveSubscriptions(ACC);
expect(result).toEqual([]);
expect(list()).toHaveBeenCalledTimes(3);
});

it("breaks if pagination cursor does not advance", async () => {
const s = sub("sub_stuck", "other");
list()
.mockResolvedValueOnce(apiList([s], true))
.mockResolvedValueOnce(apiList([s], true));
const result = await getActiveSubscriptions(ACC);
expect(result).toEqual([]);
expect(list()).toHaveBeenCalledTimes(2);
});

it("returns [] when Stripe throws", async () => {
list().mockRejectedValue(new Error("stripe error"));
await expect(getActiveSubscriptions(ACC)).resolves.toEqual([]);
});
});
18 changes: 18 additions & 0 deletions lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type Stripe from "stripe";

export function getActiveSubscriptionsTestHelpers() {
const testAccountId = "acc-a";

function subscription(id: string, accountId: string): Stripe.Subscription {
return { id, metadata: { accountId } } as Stripe.Subscription;
}

function subscriptionListPage(
data: Stripe.Subscription[],
hasMore: boolean,
): Stripe.Response<Stripe.ApiList<Stripe.Subscription>> {
return { data, has_more: hasMore } as Stripe.Response<Stripe.ApiList<Stripe.Subscription>>;
}

return { testAccountId, subscription, subscriptionListPage };
}
Loading
Loading