From a617b6832c01783cbe3c27c6a84958236d93a361 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 20:40:00 +0700 Subject: [PATCH 1/5] feat(api): add GET /api/subscriptions/status Implements OpenAPI subscription status: required query accountId (UUID), x-api-key or Bearer auth, validateAccountIdOverride, and isPro from Stripe subscription metadata plus org coverage. Adds route, handler, validation, helpers, and unit tests. --- app/api/subscriptions/status/route.ts | 33 ++++++ .../getSubscriptionStatusHandler.test.ts | 69 +++++++++++ ...lidateGetSubscriptionStatusRequest.test.ts | 107 ++++++++++++++++++ lib/stripe/getActiveSubscriptionDetails.ts | 14 +++ lib/stripe/getActiveSubscriptions.ts | 23 ++++ lib/stripe/getOrgSubscription.ts | 21 ++++ lib/stripe/getSubscriptionIsPro.ts | 12 ++ lib/stripe/getSubscriptionStatusHandler.ts | 22 ++++ lib/stripe/isActiveSubscription.ts | 8 ++ .../validateGetSubscriptionStatusRequest.ts | 49 ++++++++ 10 files changed, 358 insertions(+) create mode 100644 app/api/subscriptions/status/route.ts create mode 100644 lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts create mode 100644 lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts create mode 100644 lib/stripe/getActiveSubscriptionDetails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/getOrgSubscription.ts create mode 100644 lib/stripe/getSubscriptionIsPro.ts create mode 100644 lib/stripe/getSubscriptionStatusHandler.ts create mode 100644 lib/stripe/isActiveSubscription.ts create mode 100644 lib/stripe/validateGetSubscriptionStatusRequest.ts diff --git a/app/api/subscriptions/status/route.ts b/app/api/subscriptions/status/route.ts new file mode 100644 index 000000000..b9d7619d7 --- /dev/null +++ b/app/api/subscriptions/status/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; + +/** + * 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/subscriptions/status?accountId= + * + * Returns whether the account has an active paid subscription (direct or via organization). + * Requires `x-api-key` or `Authorization: Bearer`; the caller must be allowed to access + * the requested account (same rules as account_id override on other routes). + * + * @param request - The incoming HTTP request (query `accountId`, auth headers). + * @returns JSON `{ isPro }`, or 400/401/403 with `{ error }` per API docs. + */ +export async function GET(request: NextRequest) { + return getSubscriptionStatusHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts new file mode 100644 index 000000000..57585370d --- /dev/null +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ + validateGetSubscriptionStatusRequest: vi.fn(), +})); + +vi.mock("@/lib/stripe/getSubscriptionIsPro", () => ({ + getSubscriptionIsPro: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("getSubscriptionStatusHandler", () => { + 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: "accountId is required" }, { status: 400 }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(err); + const req = new NextRequest(`http://localhost/api/subscriptions/status`); + expect(await getSubscriptionStatusHandler(req)).toBe(err); + expect(getSubscriptionIsPro).not.toHaveBeenCalled(); + }); + + it("returns 200 { isPro: true }", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockResolvedValue(true); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + expect(getSubscriptionIsPro).toHaveBeenCalledWith(ACCOUNT); + }); + + it("returns 200 { isPro: false }", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockResolvedValue(false); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: false }); + }); + + it("returns 500 when getSubscriptionIsPro throws", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockRejectedValue(new Error("stripe down")); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Internal server error" }); + }); +}); diff --git a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts new file mode 100644 index 000000000..9cad86188 --- /dev/null +++ b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ + validateAccountIdOverride: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("validateGetSubscriptionStatusRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 { error } when accountId is missing", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 400 { error } when accountId is not a valid UUID", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(400); + const j = await (res as NextResponse).json(); + expect(j).toEqual({ error: expect.stringMatching(/uuid|UUID|valid/i) }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("maps auth failure to { error }", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: {}, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(401); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + }); + + it("maps account override denial to { error }", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "t", + }); + vi.mocked(validateAccountIdOverride).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); + const other = "123e4567-e89b-12d3-a456-426614174001"; + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${other}`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(403); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Access denied to specified account_id", + }); + }); + + it("returns accountId when auth and override succeed", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "t", + }); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: ACCOUNT }); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: { "x-api-key": "k" }, + }); + const out = await validateGetSubscriptionStatusRequest(req); + expect(out).toEqual({ accountId: ACCOUNT }); + expect(validateAccountIdOverride).toHaveBeenCalledWith({ + currentAccountId: ACCOUNT, + targetAccountId: ACCOUNT, + }); + }); +}); diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..2fac70178 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,14 @@ +import type Stripe from "stripe"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; + +export async function getActiveSubscriptionDetails( + accountId: string, +): Promise { + try { + const activeSubscriptions = await getActiveSubscriptions(accountId); + return activeSubscriptions[0] ?? null; + } catch (error) { + console.error("[getActiveSubscriptionDetails]", error); + return null; + } +} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..470027dce --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,23 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +export async function getActiveSubscriptions(accountId: string): Promise { + try { + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { + gt: Math.floor(Date.now() / 1000), + }, + }); + + const activeSubscriptions = + subscriptions?.data?.filter( + (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, + ) ?? []; + + return activeSubscriptions; + } catch (error) { + console.error("[getActiveSubscriptions]", error); + return []; + } +} diff --git a/lib/stripe/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts new file mode 100644 index 000000000..8ef4e0406 --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,21 @@ +import type Stripe from "stripe"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +/** + * Returns an active Stripe subscription for one of the account's organizations, if any. + */ +export async function getOrgSubscription(accountId: string): Promise { + if (!accountId) return null; + + const accountOrgs = await getAccountOrganizations({ accountId }); + if (accountOrgs.length === 0) return null; + + const orgIds = accountOrgs + .map(org => org.organization_id) + .filter((id): id is string => id !== null); + + const subscriptions = await Promise.all(orgIds.map(orgId => getActiveSubscriptionDetails(orgId))); + + return subscriptions.find(sub => sub !== null) ?? null; +} diff --git a/lib/stripe/getSubscriptionIsPro.ts b/lib/stripe/getSubscriptionIsPro.ts new file mode 100644 index 000000000..f681c6b60 --- /dev/null +++ b/lib/stripe/getSubscriptionIsPro.ts @@ -0,0 +1,12 @@ +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { isActiveSubscription } from "@/lib/stripe/isActiveSubscription"; + +export async function getSubscriptionIsPro(accountId: string): Promise { + const [accountSubscription, orgSubscription] = await Promise.all([ + getActiveSubscriptionDetails(accountId), + getOrgSubscription(accountId), + ]); + + return isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); +} diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts new file mode 100644 index 000000000..8f73963a7 --- /dev/null +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; + +export async function getSubscriptionStatusHandler(request: NextRequest): Promise { + try { + const validated = await validateGetSubscriptionStatusRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const isPro = await getSubscriptionIsPro(validated.accountId); + return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[getSubscriptionStatusHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..00ea9e296 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,8 @@ +import type Stripe from "stripe"; + +export function isActiveSubscription(subscription?: Stripe.Subscription | null): boolean { + if (!subscription) return false; + const isTrial = subscription.status === "trialing"; + const isCanceledTrial = isTrial && subscription.canceled_at; + return !isCanceledTrial; +} diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateGetSubscriptionStatusRequest.ts new file mode 100644 index 000000000..e12aa8eca --- /dev/null +++ b/lib/stripe/validateGetSubscriptionStatusRequest.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +export type ValidatedGetSubscriptionStatusRequest = { + accountId: string; +}; + +/** + * Validates GET /api/subscriptions/status: required query `accountId` (UUID), + * auth, and access to the target account (same rules as body account_id override). + */ +export async function validateGetSubscriptionStatusRequest( + request: NextRequest, +): Promise { + const raw = request.nextUrl.searchParams.get("accountId"); + if (raw === null || raw.trim() === "") { + return NextResponse.json( + { error: "accountId is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsedUuid = z.string().uuid("accountId must be a valid UUID").safeParse(raw); + if (!parsedUuid.success) { + const first = parsedUuid.error.issues[0]; + return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); + } + + const accountId = parsedUuid.data; + + const authContext = await validateAuthContext(request); + if (authContext instanceof NextResponse) { + return await mapToSubscriptionSessionError(authContext); + } + + const override = await validateAccountIdOverride({ + currentAccountId: authContext.accountId, + targetAccountId: accountId, + }); + if (override instanceof NextResponse) { + return await mapToSubscriptionSessionError(override); + } + + return { accountId: override.accountId }; +} From eeeb22ad3fb58fbe7c3e6d33cf03686bd53ad8ef Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 21:04:51 +0700 Subject: [PATCH 2/5] refactor(stripe): update subscription handling and validation - Refactored `getActiveSubscriptions` to use `autoPagingEach` for improved performance and clarity in fetching active subscriptions. - Renamed `validateGetSubscriptionStatusRequest` to `validateSubscriptionStatusQuery` for consistency with naming conventions. - Enhanced `isActiveSubscription` logic to clarify the conditions under which a subscription is considered active. - Removed obsolete validation file and associated tests, streamlining the codebase. --- .../__tests__/getActiveSubscriptions.test.ts | 42 ++++++++++ .../getSubscriptionStatusHandler.test.ts | 14 ++-- .../__tests__/isActiveSubscription.test.ts | 41 ++++++++++ ...> validateSubscriptionStatusQuery.test.ts} | 80 +++++++------------ lib/stripe/getActiveSubscriptions.ts | 22 ++--- lib/stripe/getSubscriptionStatusHandler.ts | 4 +- lib/stripe/isActiveSubscription.ts | 12 ++- ....ts => validateSubscriptionStatusQuery.ts} | 35 ++++---- 8 files changed, 159 insertions(+), 91 deletions(-) create mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts create mode 100644 lib/stripe/__tests__/isActiveSubscription.test.ts rename lib/stripe/__tests__/{validateGetSubscriptionStatusRequest.test.ts => validateSubscriptionStatusQuery.test.ts} (52%) rename lib/stripe/{validateGetSubscriptionStatusRequest.ts => validateSubscriptionStatusQuery.ts} (58%) diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts new file mode 100644 index 000000000..7d17c7f3c --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import stripeClient from "@/lib/stripe/client"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { subscriptions: { list: vi.fn() } }, +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("getActiveSubscriptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + it("returns subscriptions matching accountId across pages", async () => { + const match = { metadata: { accountId: ACCOUNT } } as Stripe.Subscription; + const skip = { metadata: { accountId: "other" } } as Stripe.Subscription; + async function* twoPages() { + yield skip; + yield match; + } + vi.mocked(stripeClient.subscriptions.list).mockReturnValue({ + autoPagingEach: () => twoPages(), + } as ReturnType); + + const out = await getActiveSubscriptions(ACCOUNT); + expect(out).toEqual([match]); + expect(stripeClient.subscriptions.list).toHaveBeenCalledWith({ + current_period_end: { gt: expect.any(Number) }, + }); + }); + + it("returns [] when Stripe throws", async () => { + vi.mocked(stripeClient.subscriptions.list).mockImplementation(() => { + throw new Error("stripe api error"); + }); + await expect(getActiveSubscriptions(ACCOUNT)).resolves.toEqual([]); + }); +}); diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts index 57585370d..cccd35429 100644 --- a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ - validateGetSubscriptionStatusRequest: vi.fn(), +vi.mock("@/lib/stripe/validateSubscriptionStatusQuery", () => ({ + validateSubscriptionStatusQuery: vi.fn(), })); vi.mock("@/lib/stripe/getSubscriptionIsPro", () => ({ @@ -27,14 +27,14 @@ describe("getSubscriptionStatusHandler", () => { it("returns validation response unchanged", async () => { const err = NextResponse.json({ error: "accountId is required" }, { status: 400 }); - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(err); + vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue(err); const req = new NextRequest(`http://localhost/api/subscriptions/status`); expect(await getSubscriptionStatusHandler(req)).toBe(err); expect(getSubscriptionIsPro).not.toHaveBeenCalled(); }); it("returns 200 { isPro: true }", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockResolvedValue(true); const res = await getSubscriptionStatusHandler( @@ -46,7 +46,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns 200 { isPro: false }", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockResolvedValue(false); const res = await getSubscriptionStatusHandler( @@ -57,7 +57,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns 500 when getSubscriptionIsPro throws", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockRejectedValue(new Error("stripe down")); const res = await getSubscriptionStatusHandler( diff --git a/lib/stripe/__tests__/isActiveSubscription.test.ts b/lib/stripe/__tests__/isActiveSubscription.test.ts new file mode 100644 index 000000000..778ebf041 --- /dev/null +++ b/lib/stripe/__tests__/isActiveSubscription.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import { isActiveSubscription } from "@/lib/stripe/isActiveSubscription"; + +function sub( + partial: Partial & { status: Stripe.Subscription.Status }, +): Stripe.Subscription { + return partial as Stripe.Subscription; +} + +describe("isActiveSubscription", () => { + it("returns false for null/undefined", () => { + expect(isActiveSubscription(null)).toBe(false); + expect(isActiveSubscription(undefined)).toBe(false); + }); + + it("returns true for active", () => { + expect(isActiveSubscription(sub({ status: "active" }))).toBe(true); + }); + + it("returns true for trialing without canceled_at", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: null }))).toBe(true); + }); + + it("returns false for trialing with canceled_at", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: 1 }))).toBe(false); + }); + + it("returns false for non-billable statuses", () => { + for (const status of [ + "canceled", + "incomplete", + "incomplete_expired", + "past_due", + "unpaid", + "paused", + ] as const) { + expect(isActiveSubscription(sub({ status }))).toBe(false); + } + }); +}); diff --git a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts b/lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts similarity index 52% rename from lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts rename to lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts index 9cad86188..fd963f3f3 100644 --- a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts +++ b/lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts @@ -1,44 +1,36 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ - validateAccountIdOverride: vi.fn(), -})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ validateAccountIdOverride: vi.fn() })); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; +const url = (q: string) => `http://localhost/api/subscriptions/status${q}`; +const hdr = (k: boolean) => ({ headers: k ? { "x-api-key": "k" } : {} }); -describe("validateGetSubscriptionStatusRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +describe("validateSubscriptionStatusQuery", () => { + beforeEach(() => vi.clearAllMocks()); - it("returns 400 { error } when accountId is missing", async () => { - const req = new NextRequest(`http://localhost/api/subscriptions/status`, { - headers: { "x-api-key": "k" }, - }); - const res = await validateGetSubscriptionStatusRequest(req); + it("returns 400 when accountId is missing", async () => { + const res = await validateSubscriptionStatusQuery( + new NextRequest(url(""), hdr(true) as RequestInit), + ); expect(res).toBeInstanceOf(NextResponse); expect((res as NextResponse).status).toBe(400); await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); expect(validateAuthContext).not.toHaveBeenCalled(); }); - it("returns 400 { error } when accountId is not a valid UUID", async () => { - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`, { - headers: { "x-api-key": "k" }, - }); - const res = await validateGetSubscriptionStatusRequest(req); + it("returns 400 when accountId is not a UUID", async () => { + const res = await validateSubscriptionStatusQuery( + new NextRequest(url("?accountId=not-a-uuid"), hdr(true) as RequestInit), + ); expect((res as NextResponse).status).toBe(400); const j = await (res as NextResponse).json(); expect(j).toEqual({ error: expect.stringMatching(/uuid|UUID|valid/i) }); @@ -46,20 +38,15 @@ describe("validateGetSubscriptionStatusRequest", () => { }); it("maps auth failure to { error }", async () => { + const msg = "Exactly one of x-api-key or Authorization must be provided"; vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json( - { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, - { status: 401 }, - ), + NextResponse.json({ status: "error", error: msg }, { status: 401 }), + ); + const res = await validateSubscriptionStatusQuery( + new NextRequest(url(`?accountId=${ACCOUNT}`), hdr(false) as RequestInit), ); - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { - headers: {}, - }); - const res = await validateGetSubscriptionStatusRequest(req); expect((res as NextResponse).status).toBe(401); - await expect((res as NextResponse).json()).resolves.toEqual({ - error: "Exactly one of x-api-key or Authorization must be provided", - }); + await expect((res as NextResponse).json()).resolves.toEqual({ error: msg }); expect(validateAccountIdOverride).not.toHaveBeenCalled(); }); @@ -69,21 +56,16 @@ describe("validateGetSubscriptionStatusRequest", () => { orgId: null, authToken: "t", }); + const denied = "Access denied to specified account_id"; vi.mocked(validateAccountIdOverride).mockResolvedValue( - NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403 }, - ), + NextResponse.json({ status: "error", error: denied }, { status: 403 }), ); const other = "123e4567-e89b-12d3-a456-426614174001"; - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${other}`, { - headers: { "x-api-key": "k" }, - }); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateSubscriptionStatusQuery( + new NextRequest(url(`?accountId=${other}`), hdr(true) as RequestInit), + ); expect((res as NextResponse).status).toBe(403); - await expect((res as NextResponse).json()).resolves.toEqual({ - error: "Access denied to specified account_id", - }); + await expect((res as NextResponse).json()).resolves.toEqual({ error: denied }); }); it("returns accountId when auth and override succeed", async () => { @@ -93,11 +75,9 @@ describe("validateGetSubscriptionStatusRequest", () => { authToken: "t", }); vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: ACCOUNT }); - - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { - headers: { "x-api-key": "k" }, - }); - const out = await validateGetSubscriptionStatusRequest(req); + const out = await validateSubscriptionStatusQuery( + new NextRequest(url(`?accountId=${ACCOUNT}`), hdr(true) as RequestInit), + ); expect(out).toEqual({ accountId: ACCOUNT }); expect(validateAccountIdOverride).toHaveBeenCalledWith({ currentAccountId: ACCOUNT, diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 470027dce..273164c18 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -3,19 +3,19 @@ import stripeClient from "@/lib/stripe/client"; export async function getActiveSubscriptions(accountId: string): Promise { try { - const subscriptions = await stripeClient.subscriptions.list({ - limit: 100, - current_period_end: { - gt: Math.floor(Date.now() / 1000), - }, - }); + const now = Math.floor(Date.now() / 1000); + const listParams = { + current_period_end: { gt: now }, + }; + const matches: Stripe.Subscription[] = []; - const activeSubscriptions = - subscriptions?.data?.filter( - (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, - ) ?? []; + for await (const subscription of stripeClient.subscriptions.list(listParams).autoPagingEach()) { + if (subscription.metadata?.accountId === accountId) { + matches.push(subscription); + } + } - return activeSubscriptions; + return matches; } catch (error) { console.error("[getActiveSubscriptions]", error); return []; diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts index 8f73963a7..1adc39c9e 100644 --- a/lib/stripe/getSubscriptionStatusHandler.ts +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; export async function getSubscriptionStatusHandler(request: NextRequest): Promise { try { - const validated = await validateGetSubscriptionStatusRequest(request); + const validated = await validateSubscriptionStatusQuery(request); if (validated instanceof NextResponse) { return validated; } diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts index 00ea9e296..4db4263a0 100644 --- a/lib/stripe/isActiveSubscription.ts +++ b/lib/stripe/isActiveSubscription.ts @@ -1,8 +1,14 @@ import type Stripe from "stripe"; +/** + * True when Stripe considers the subscription billable pro access: `active`, + * or `trialing` without cancellation. All other statuses are false. + */ export function isActiveSubscription(subscription?: Stripe.Subscription | null): boolean { if (!subscription) return false; - const isTrial = subscription.status === "trialing"; - const isCanceledTrial = isTrial && subscription.canceled_at; - return !isCanceledTrial; + if (subscription.status === "active") return true; + if (subscription.status === "trialing") { + return !subscription.canceled_at; + } + return false; } diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateSubscriptionStatusQuery.ts similarity index 58% rename from lib/stripe/validateGetSubscriptionStatusRequest.ts rename to lib/stripe/validateSubscriptionStatusQuery.ts index e12aa8eca..575f5cbef 100644 --- a/lib/stripe/validateGetSubscriptionStatusRequest.ts +++ b/lib/stripe/validateSubscriptionStatusQuery.ts @@ -5,32 +5,31 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; -export type ValidatedGetSubscriptionStatusRequest = { - accountId: string; -}; +export const subscriptionStatusQuerySchema = z.object({ + accountId: z + .string({ message: "accountId is required" }) + .min(1, "accountId is required") + .uuid("accountId must be a valid UUID"), +}); + +export type ValidatedSubscriptionStatusQuery = z.infer; /** - * Validates GET /api/subscriptions/status: required query `accountId` (UUID), - * auth, and access to the target account (same rules as body account_id override). + * Validates GET /api/subscriptions/status: query `accountId`, auth, and account access. */ -export async function validateGetSubscriptionStatusRequest( +export async function validateSubscriptionStatusQuery( request: NextRequest, -): Promise { +): Promise { const raw = request.nextUrl.searchParams.get("accountId"); - if (raw === null || raw.trim() === "") { - return NextResponse.json( - { error: "accountId is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const parsedUuid = z.string().uuid("accountId must be a valid UUID").safeParse(raw); - if (!parsedUuid.success) { - const first = parsedUuid.error.issues[0]; + const parsed = subscriptionStatusQuerySchema.safeParse({ + accountId: raw ?? "", + }); + if (!parsed.success) { + const first = parsed.error.issues[0]; return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); } - const accountId = parsedUuid.data; + const { accountId } = parsed.data; const authContext = await validateAuthContext(request); if (authContext instanceof NextResponse) { From 55c73eaa78b1c83b944b9889d7b125cba826db18 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 22:10:53 +0700 Subject: [PATCH 3/5] refactor(sandbox): update sandbox property references to use sandboxId - Changed all instances of `sandbox.name` to `sandbox.sandboxId` across multiple files to align with the updated API structure. - Updated the `SandboxCreatedResponse` interface to reflect the new property type for `sandboxId`. - Adjusted related tests to ensure consistency with the new property naming. --- lib/sandbox/__tests__/createSandbox.test.ts | 2 +- .../__tests__/createSandboxFromSnapshot.test.ts | 2 +- lib/sandbox/__tests__/getActiveSandbox.test.ts | 6 +++--- lib/sandbox/__tests__/getOrCreateSandbox.test.ts | 6 +++--- lib/sandbox/__tests__/getSandboxStatus.test.ts | 8 ++++---- lib/sandbox/__tests__/processCreateSandbox.test.ts | 2 +- lib/sandbox/createSandbox.ts | 10 ++++++---- lib/sandbox/createSandboxFromSnapshot.ts | 2 +- lib/sandbox/getActiveSandbox.ts | 2 +- lib/sandbox/getOrCreateSandbox.ts | 4 ++-- lib/sandbox/getSandboxStatus.ts | 4 ++-- lib/sandbox/processCreateSandbox.ts | 4 ++-- lib/stripe/__tests__/getActiveSubscriptions.test.ts | 6 +++--- lib/stripe/getActiveSubscriptions.ts | 2 +- 14 files changed, 31 insertions(+), 29 deletions(-) diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index b80dbc44b..1530afade 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -4,7 +4,7 @@ import { createSandbox } from "../createSandbox"; import { Sandbox } from "@vercel/sandbox"; const mockSandbox = { - name: "sbx_test123", + sandboxId: "sbx_test123", status: "running", timeout: 1800000, createdAt: new Date("2024-01-01T00:00:00Z"), diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index f3fc2b46a..d7a975072 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ describe("createSandboxFromSnapshot", () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", runCommand: vi.fn(), } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts index 0f6ca2e6f..581337076 100644 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ b/lib/sandbox/__tests__/getActiveSandbox.test.ts @@ -24,7 +24,7 @@ describe("getActiveSandbox", () => { mockSelectAccountSandboxes.mockResolvedValue([{ sandbox_id: "sbx_123", account_id: "acc_1" }]); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", runCommand: vi.fn(), }; @@ -35,7 +35,7 @@ describe("getActiveSandbox", () => { expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ accountIds: ["acc_1"], }); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toBe(mockSandbox); }); @@ -54,7 +54,7 @@ describe("getActiveSandbox", () => { ]); const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", }; vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts index 063b87927..56e641bc9 100644 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts @@ -21,7 +21,7 @@ describe("getOrCreateSandbox", () => { it("returns existing sandbox with created=false and fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_existing", + sandboxId: "sbx_existing", status: "running", } as unknown as Sandbox; @@ -40,7 +40,7 @@ describe("getOrCreateSandbox", () => { it("creates new sandbox from snapshot with created=true, fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", } as unknown as Sandbox; @@ -63,7 +63,7 @@ describe("getOrCreateSandbox", () => { it("creates fresh sandbox with created=true, fromSnapshot=false", async () => { const mockSandbox = { - name: "sbx_fresh", + sandboxId: "sbx_fresh", status: "running", } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getSandboxStatus.test.ts b/lib/sandbox/__tests__/getSandboxStatus.test.ts index 949032362..3f84615a6 100644 --- a/lib/sandbox/__tests__/getSandboxStatus.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatus.test.ts @@ -16,7 +16,7 @@ describe("getSandboxStatus", () => { it("returns sandbox status when sandbox exists", async () => { const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -25,7 +25,7 @@ describe("getSandboxStatus", () => { const result = await getSandboxStatus("sbx_123"); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toEqual({ sandboxId: "sbx_123", sandboxStatus: "running", @@ -52,7 +52,7 @@ describe("getSandboxStatus", () => { it("handles stopped sandbox status", async () => { const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", timeout: 0, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -71,7 +71,7 @@ describe("getSandboxStatus", () => { it("handles pending sandbox status", async () => { const mockSandbox = { - name: "sbx_pending", + sandboxId: "sbx_pending", status: "pending", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index 160cf287c..5f8b33e2f 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ })); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index d6c732128..a09ad37c7 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -1,10 +1,12 @@ import ms from "ms"; import { Sandbox } from "@vercel/sandbox"; +type VercelSandbox = InstanceType; + export interface SandboxCreatedResponse { - sandboxId: Sandbox["name"]; - sandboxStatus: Sandbox["status"]; - timeout: Sandbox["timeout"]; + sandboxId: VercelSandbox["sandboxId"]; + sandboxStatus: VercelSandbox["status"]; + timeout: VercelSandbox["timeout"]; createdAt: string; } @@ -54,7 +56,7 @@ export async function createSandbox( return { sandbox, response: { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 2dcfb7ef4..1b6da57a6 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -23,7 +23,7 @@ export async function createSandboxFromSnapshot( await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox.name, + sandbox_id: sandbox.sandboxId, }); return { sandbox, fromSnapshot }; diff --git a/lib/sandbox/getActiveSandbox.ts b/lib/sandbox/getActiveSandbox.ts index f47dec4f6..faa220ad9 100644 --- a/lib/sandbox/getActiveSandbox.ts +++ b/lib/sandbox/getActiveSandbox.ts @@ -19,7 +19,7 @@ export async function getActiveSandbox(accountId: string): Promise { try { - const sandbox = await Sandbox.get({ name: sandboxId }); + const sandbox = await Sandbox.get({ sandboxId }); return { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index 2863c7d7a..0ce50ed68 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -23,7 +23,7 @@ export async function processCreateSandbox( const { sandbox } = await createSandboxFromSnapshot(accountId); const result: SandboxCreatedResponse = { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), @@ -35,7 +35,7 @@ export async function processCreateSandbox( try { const handle = await triggerPromptSandbox({ prompt, - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, accountId, }); runId = handle.id; diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index 7d17c7f3c..b7fe0a773 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -22,9 +22,9 @@ describe("getActiveSubscriptions", () => { yield skip; yield match; } - vi.mocked(stripeClient.subscriptions.list).mockReturnValue({ - autoPagingEach: () => twoPages(), - } as ReturnType); + vi.mocked(stripeClient.subscriptions.list).mockReturnValue( + twoPages() as unknown as ReturnType, + ); const out = await getActiveSubscriptions(ACCOUNT); expect(out).toEqual([match]); diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 273164c18..6c124bafe 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -9,7 +9,7 @@ export async function getActiveSubscriptions(accountId: string): Promise Date: Sun, 3 May 2026 22:18:16 +0700 Subject: [PATCH 4/5] refactor(sandbox): update sandbox property references to use name - Changed all instances of `sandbox.sandboxId` to `sandbox.name` across multiple files to align with the updated API structure. - Updated related tests to ensure consistency with the new property naming. --- lib/sandbox/__tests__/createSandbox.test.ts | 2 +- .../createSandboxFromSnapshot.test.ts | 2 +- .../__tests__/getActiveSandbox.test.ts | 6 +- .../__tests__/getOrCreateSandbox.test.ts | 6 +- .../__tests__/getSandboxStatus.test.ts | 8 +- .../__tests__/processCreateSandbox.test.ts | 2 +- lib/sandbox/createSandboxFromSnapshot.ts | 2 +- lib/sandbox/getOrCreateSandbox.ts | 4 +- lib/sandbox/processCreateSandbox.ts | 2 +- .../__tests__/getActiveSubscriptions.test.ts | 42 ---------- .../getSubscriptionStatusHandler.test.ts | 14 ++-- .../__tests__/isActiveSubscription.test.ts | 41 ---------- ...idateGetSubscriptionStatusRequest.test.ts} | 80 ++++++++++++------- lib/stripe/getActiveSubscriptions.ts | 22 ++--- lib/stripe/getSubscriptionStatusHandler.ts | 4 +- lib/stripe/isActiveSubscription.ts | 12 +-- ...> validateGetSubscriptionStatusRequest.ts} | 35 ++++---- 17 files changed, 108 insertions(+), 176 deletions(-) delete mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts delete mode 100644 lib/stripe/__tests__/isActiveSubscription.test.ts rename lib/stripe/__tests__/{validateSubscriptionStatusQuery.test.ts => validateGetSubscriptionStatusRequest.test.ts} (52%) rename lib/stripe/{validateSubscriptionStatusQuery.ts => validateGetSubscriptionStatusRequest.ts} (58%) diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index 1530afade..b80dbc44b 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -4,7 +4,7 @@ import { createSandbox } from "../createSandbox"; import { Sandbox } from "@vercel/sandbox"; const mockSandbox = { - sandboxId: "sbx_test123", + name: "sbx_test123", status: "running", timeout: 1800000, createdAt: new Date("2024-01-01T00:00:00Z"), diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index d7a975072..f3fc2b46a 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ describe("createSandboxFromSnapshot", () => { const mockSandbox = { - sandboxId: "sbx_new", + name: "sbx_new", status: "running", runCommand: vi.fn(), } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts index 581337076..0f6ca2e6f 100644 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ b/lib/sandbox/__tests__/getActiveSandbox.test.ts @@ -24,7 +24,7 @@ describe("getActiveSandbox", () => { mockSelectAccountSandboxes.mockResolvedValue([{ sandbox_id: "sbx_123", account_id: "acc_1" }]); const mockSandbox = { - sandboxId: "sbx_123", + name: "sbx_123", status: "running", runCommand: vi.fn(), }; @@ -35,7 +35,7 @@ describe("getActiveSandbox", () => { expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ accountIds: ["acc_1"], }); - expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); expect(result).toBe(mockSandbox); }); @@ -54,7 +54,7 @@ describe("getActiveSandbox", () => { ]); const mockSandbox = { - sandboxId: "sbx_stopped", + name: "sbx_stopped", status: "stopped", }; vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts index 56e641bc9..063b87927 100644 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts @@ -21,7 +21,7 @@ describe("getOrCreateSandbox", () => { it("returns existing sandbox with created=false and fromSnapshot=true", async () => { const mockSandbox = { - sandboxId: "sbx_existing", + name: "sbx_existing", status: "running", } as unknown as Sandbox; @@ -40,7 +40,7 @@ describe("getOrCreateSandbox", () => { it("creates new sandbox from snapshot with created=true, fromSnapshot=true", async () => { const mockSandbox = { - sandboxId: "sbx_new", + name: "sbx_new", status: "running", } as unknown as Sandbox; @@ -63,7 +63,7 @@ describe("getOrCreateSandbox", () => { it("creates fresh sandbox with created=true, fromSnapshot=false", async () => { const mockSandbox = { - sandboxId: "sbx_fresh", + name: "sbx_fresh", status: "running", } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getSandboxStatus.test.ts b/lib/sandbox/__tests__/getSandboxStatus.test.ts index 3f84615a6..949032362 100644 --- a/lib/sandbox/__tests__/getSandboxStatus.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatus.test.ts @@ -16,7 +16,7 @@ describe("getSandboxStatus", () => { it("returns sandbox status when sandbox exists", async () => { const mockSandbox = { - sandboxId: "sbx_123", + name: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -25,7 +25,7 @@ describe("getSandboxStatus", () => { const result = await getSandboxStatus("sbx_123"); - expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); expect(result).toEqual({ sandboxId: "sbx_123", sandboxStatus: "running", @@ -52,7 +52,7 @@ describe("getSandboxStatus", () => { it("handles stopped sandbox status", async () => { const mockSandbox = { - sandboxId: "sbx_stopped", + name: "sbx_stopped", status: "stopped", timeout: 0, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -71,7 +71,7 @@ describe("getSandboxStatus", () => { it("handles pending sandbox status", async () => { const mockSandbox = { - sandboxId: "sbx_pending", + name: "sbx_pending", status: "pending", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index 5f8b33e2f..160cf287c 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ })); const mockSandbox = { - sandboxId: "sbx_123", + name: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 1b6da57a6..2dcfb7ef4 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -23,7 +23,7 @@ export async function createSandboxFromSnapshot( await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox.sandboxId, + sandbox_id: sandbox.name, }); return { sandbox, fromSnapshot }; diff --git a/lib/sandbox/getOrCreateSandbox.ts b/lib/sandbox/getOrCreateSandbox.ts index f76fef43a..e84838bd9 100644 --- a/lib/sandbox/getOrCreateSandbox.ts +++ b/lib/sandbox/getOrCreateSandbox.ts @@ -21,7 +21,7 @@ export async function getOrCreateSandbox(accountId: string): Promise ({ - default: { subscriptions: { list: vi.fn() } }, -})); - -const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; - -describe("getActiveSubscriptions", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "error").mockImplementation(() => undefined); - }); - - it("returns subscriptions matching accountId across pages", async () => { - const match = { metadata: { accountId: ACCOUNT } } as Stripe.Subscription; - const skip = { metadata: { accountId: "other" } } as Stripe.Subscription; - async function* twoPages() { - yield skip; - yield match; - } - vi.mocked(stripeClient.subscriptions.list).mockReturnValue( - twoPages() as unknown as ReturnType, - ); - - const out = await getActiveSubscriptions(ACCOUNT); - expect(out).toEqual([match]); - expect(stripeClient.subscriptions.list).toHaveBeenCalledWith({ - current_period_end: { gt: expect.any(Number) }, - }); - }); - - it("returns [] when Stripe throws", async () => { - vi.mocked(stripeClient.subscriptions.list).mockImplementation(() => { - throw new Error("stripe api error"); - }); - await expect(getActiveSubscriptions(ACCOUNT)).resolves.toEqual([]); - }); -}); diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts index cccd35429..57585370d 100644 --- a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; -import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/stripe/validateSubscriptionStatusQuery", () => ({ - validateSubscriptionStatusQuery: vi.fn(), +vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ + validateGetSubscriptionStatusRequest: vi.fn(), })); vi.mock("@/lib/stripe/getSubscriptionIsPro", () => ({ @@ -27,14 +27,14 @@ describe("getSubscriptionStatusHandler", () => { it("returns validation response unchanged", async () => { const err = NextResponse.json({ error: "accountId is required" }, { status: 400 }); - vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue(err); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(err); const req = new NextRequest(`http://localhost/api/subscriptions/status`); expect(await getSubscriptionStatusHandler(req)).toBe(err); expect(getSubscriptionIsPro).not.toHaveBeenCalled(); }); it("returns 200 { isPro: true }", async () => { - vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockResolvedValue(true); const res = await getSubscriptionStatusHandler( @@ -46,7 +46,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns 200 { isPro: false }", async () => { - vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockResolvedValue(false); const res = await getSubscriptionStatusHandler( @@ -57,7 +57,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns 500 when getSubscriptionIsPro throws", async () => { - vi.mocked(validateSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getSubscriptionIsPro).mockRejectedValue(new Error("stripe down")); const res = await getSubscriptionStatusHandler( diff --git a/lib/stripe/__tests__/isActiveSubscription.test.ts b/lib/stripe/__tests__/isActiveSubscription.test.ts deleted file mode 100644 index 778ebf041..000000000 --- a/lib/stripe/__tests__/isActiveSubscription.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type Stripe from "stripe"; -import { isActiveSubscription } from "@/lib/stripe/isActiveSubscription"; - -function sub( - partial: Partial & { status: Stripe.Subscription.Status }, -): Stripe.Subscription { - return partial as Stripe.Subscription; -} - -describe("isActiveSubscription", () => { - it("returns false for null/undefined", () => { - expect(isActiveSubscription(null)).toBe(false); - expect(isActiveSubscription(undefined)).toBe(false); - }); - - it("returns true for active", () => { - expect(isActiveSubscription(sub({ status: "active" }))).toBe(true); - }); - - it("returns true for trialing without canceled_at", () => { - expect(isActiveSubscription(sub({ status: "trialing", canceled_at: null }))).toBe(true); - }); - - it("returns false for trialing with canceled_at", () => { - expect(isActiveSubscription(sub({ status: "trialing", canceled_at: 1 }))).toBe(false); - }); - - it("returns false for non-billable statuses", () => { - for (const status of [ - "canceled", - "incomplete", - "incomplete_expired", - "past_due", - "unpaid", - "paused", - ] as const) { - expect(isActiveSubscription(sub({ status }))).toBe(false); - } - }); -}); diff --git a/lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts similarity index 52% rename from lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts rename to lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts index fd963f3f3..9cad86188 100644 --- a/lib/stripe/__tests__/validateSubscriptionStatusQuery.test.ts +++ b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts @@ -1,36 +1,44 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); -vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ validateAccountIdOverride: vi.fn() })); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ + validateAccountIdOverride: vi.fn(), +})); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; -const url = (q: string) => `http://localhost/api/subscriptions/status${q}`; -const hdr = (k: boolean) => ({ headers: k ? { "x-api-key": "k" } : {} }); -describe("validateSubscriptionStatusQuery", () => { - beforeEach(() => vi.clearAllMocks()); +describe("validateGetSubscriptionStatusRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it("returns 400 when accountId is missing", async () => { - const res = await validateSubscriptionStatusQuery( - new NextRequest(url(""), hdr(true) as RequestInit), - ); + it("returns 400 { error } when accountId is missing", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); expect(res).toBeInstanceOf(NextResponse); expect((res as NextResponse).status).toBe(400); await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); expect(validateAuthContext).not.toHaveBeenCalled(); }); - it("returns 400 when accountId is not a UUID", async () => { - const res = await validateSubscriptionStatusQuery( - new NextRequest(url("?accountId=not-a-uuid"), hdr(true) as RequestInit), - ); + it("returns 400 { error } when accountId is not a valid UUID", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); expect((res as NextResponse).status).toBe(400); const j = await (res as NextResponse).json(); expect(j).toEqual({ error: expect.stringMatching(/uuid|UUID|valid/i) }); @@ -38,15 +46,20 @@ describe("validateSubscriptionStatusQuery", () => { }); it("maps auth failure to { error }", async () => { - const msg = "Exactly one of x-api-key or Authorization must be provided"; vi.mocked(validateAuthContext).mockResolvedValue( - NextResponse.json({ status: "error", error: msg }, { status: 401 }), - ); - const res = await validateSubscriptionStatusQuery( - new NextRequest(url(`?accountId=${ACCOUNT}`), hdr(false) as RequestInit), + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), ); + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: {}, + }); + const res = await validateGetSubscriptionStatusRequest(req); expect((res as NextResponse).status).toBe(401); - await expect((res as NextResponse).json()).resolves.toEqual({ error: msg }); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); expect(validateAccountIdOverride).not.toHaveBeenCalled(); }); @@ -56,16 +69,21 @@ describe("validateSubscriptionStatusQuery", () => { orgId: null, authToken: "t", }); - const denied = "Access denied to specified account_id"; vi.mocked(validateAccountIdOverride).mockResolvedValue( - NextResponse.json({ status: "error", error: denied }, { status: 403 }), + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), ); const other = "123e4567-e89b-12d3-a456-426614174001"; - const res = await validateSubscriptionStatusQuery( - new NextRequest(url(`?accountId=${other}`), hdr(true) as RequestInit), - ); + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${other}`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); expect((res as NextResponse).status).toBe(403); - await expect((res as NextResponse).json()).resolves.toEqual({ error: denied }); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Access denied to specified account_id", + }); }); it("returns accountId when auth and override succeed", async () => { @@ -75,9 +93,11 @@ describe("validateSubscriptionStatusQuery", () => { authToken: "t", }); vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: ACCOUNT }); - const out = await validateSubscriptionStatusQuery( - new NextRequest(url(`?accountId=${ACCOUNT}`), hdr(true) as RequestInit), - ); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: { "x-api-key": "k" }, + }); + const out = await validateGetSubscriptionStatusRequest(req); expect(out).toEqual({ accountId: ACCOUNT }); expect(validateAccountIdOverride).toHaveBeenCalledWith({ currentAccountId: ACCOUNT, diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 6c124bafe..470027dce 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -3,19 +3,19 @@ import stripeClient from "@/lib/stripe/client"; export async function getActiveSubscriptions(accountId: string): Promise { try { - const now = Math.floor(Date.now() / 1000); - const listParams = { - current_period_end: { gt: now }, - }; - const matches: Stripe.Subscription[] = []; + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { + gt: Math.floor(Date.now() / 1000), + }, + }); - for await (const subscription of stripeClient.subscriptions.list(listParams)) { - if (subscription.metadata?.accountId === accountId) { - matches.push(subscription); - } - } + const activeSubscriptions = + subscriptions?.data?.filter( + (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, + ) ?? []; - return matches; + return activeSubscriptions; } catch (error) { console.error("[getActiveSubscriptions]", error); return []; diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts index 1adc39c9e..8f73963a7 100644 --- a/lib/stripe/getSubscriptionStatusHandler.ts +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; -import { validateSubscriptionStatusQuery } from "@/lib/stripe/validateSubscriptionStatusQuery"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; export async function getSubscriptionStatusHandler(request: NextRequest): Promise { try { - const validated = await validateSubscriptionStatusQuery(request); + const validated = await validateGetSubscriptionStatusRequest(request); if (validated instanceof NextResponse) { return validated; } diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts index 4db4263a0..00ea9e296 100644 --- a/lib/stripe/isActiveSubscription.ts +++ b/lib/stripe/isActiveSubscription.ts @@ -1,14 +1,8 @@ import type Stripe from "stripe"; -/** - * True when Stripe considers the subscription billable pro access: `active`, - * or `trialing` without cancellation. All other statuses are false. - */ export function isActiveSubscription(subscription?: Stripe.Subscription | null): boolean { if (!subscription) return false; - if (subscription.status === "active") return true; - if (subscription.status === "trialing") { - return !subscription.canceled_at; - } - return false; + const isTrial = subscription.status === "trialing"; + const isCanceledTrial = isTrial && subscription.canceled_at; + return !isCanceledTrial; } diff --git a/lib/stripe/validateSubscriptionStatusQuery.ts b/lib/stripe/validateGetSubscriptionStatusRequest.ts similarity index 58% rename from lib/stripe/validateSubscriptionStatusQuery.ts rename to lib/stripe/validateGetSubscriptionStatusRequest.ts index 575f5cbef..e12aa8eca 100644 --- a/lib/stripe/validateSubscriptionStatusQuery.ts +++ b/lib/stripe/validateGetSubscriptionStatusRequest.ts @@ -5,31 +5,32 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; -export const subscriptionStatusQuerySchema = z.object({ - accountId: z - .string({ message: "accountId is required" }) - .min(1, "accountId is required") - .uuid("accountId must be a valid UUID"), -}); - -export type ValidatedSubscriptionStatusQuery = z.infer; +export type ValidatedGetSubscriptionStatusRequest = { + accountId: string; +}; /** - * Validates GET /api/subscriptions/status: query `accountId`, auth, and account access. + * Validates GET /api/subscriptions/status: required query `accountId` (UUID), + * auth, and access to the target account (same rules as body account_id override). */ -export async function validateSubscriptionStatusQuery( +export async function validateGetSubscriptionStatusRequest( request: NextRequest, -): Promise { +): Promise { const raw = request.nextUrl.searchParams.get("accountId"); - const parsed = subscriptionStatusQuerySchema.safeParse({ - accountId: raw ?? "", - }); - if (!parsed.success) { - const first = parsed.error.issues[0]; + if (raw === null || raw.trim() === "") { + return NextResponse.json( + { error: "accountId is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsedUuid = z.string().uuid("accountId must be a valid UUID").safeParse(raw); + if (!parsedUuid.success) { + const first = parsedUuid.error.issues[0]; return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); } - const { accountId } = parsed.data; + const accountId = parsedUuid.data; const authContext = await validateAuthContext(request); if (authContext instanceof NextResponse) { From 24a5b5a4e7253508f7ba0abf54028d9e02c49b6c Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 22:29:06 +0700 Subject: [PATCH 5/5] refactor(sandbox): standardize sandbox property naming to sandboxId - Updated all instances of `sandbox.name` to `sandbox.sandboxId` across multiple files to ensure consistency with the updated API structure. - Adjusted related tests to reflect the new property naming for sandbox identifiers. --- lib/sandbox/__tests__/createSandbox.test.ts | 2 +- lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts | 2 +- lib/sandbox/__tests__/getActiveSandbox.test.ts | 6 +++--- lib/sandbox/__tests__/getOrCreateSandbox.test.ts | 6 +++--- lib/sandbox/__tests__/getSandboxStatus.test.ts | 8 ++++---- lib/sandbox/__tests__/processCreateSandbox.test.ts | 2 +- lib/sandbox/createSandboxFromSnapshot.ts | 2 +- lib/sandbox/getOrCreateSandbox.ts | 4 ++-- lib/sandbox/processCreateSandbox.ts | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index b80dbc44b..1530afade 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -4,7 +4,7 @@ import { createSandbox } from "../createSandbox"; import { Sandbox } from "@vercel/sandbox"; const mockSandbox = { - name: "sbx_test123", + sandboxId: "sbx_test123", status: "running", timeout: 1800000, createdAt: new Date("2024-01-01T00:00:00Z"), diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index f3fc2b46a..d7a975072 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ describe("createSandboxFromSnapshot", () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", runCommand: vi.fn(), } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts index 0f6ca2e6f..581337076 100644 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ b/lib/sandbox/__tests__/getActiveSandbox.test.ts @@ -24,7 +24,7 @@ describe("getActiveSandbox", () => { mockSelectAccountSandboxes.mockResolvedValue([{ sandbox_id: "sbx_123", account_id: "acc_1" }]); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", runCommand: vi.fn(), }; @@ -35,7 +35,7 @@ describe("getActiveSandbox", () => { expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ accountIds: ["acc_1"], }); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toBe(mockSandbox); }); @@ -54,7 +54,7 @@ describe("getActiveSandbox", () => { ]); const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", }; vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts index 063b87927..56e641bc9 100644 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts @@ -21,7 +21,7 @@ describe("getOrCreateSandbox", () => { it("returns existing sandbox with created=false and fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_existing", + sandboxId: "sbx_existing", status: "running", } as unknown as Sandbox; @@ -40,7 +40,7 @@ describe("getOrCreateSandbox", () => { it("creates new sandbox from snapshot with created=true, fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", } as unknown as Sandbox; @@ -63,7 +63,7 @@ describe("getOrCreateSandbox", () => { it("creates fresh sandbox with created=true, fromSnapshot=false", async () => { const mockSandbox = { - name: "sbx_fresh", + sandboxId: "sbx_fresh", status: "running", } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getSandboxStatus.test.ts b/lib/sandbox/__tests__/getSandboxStatus.test.ts index 949032362..3f84615a6 100644 --- a/lib/sandbox/__tests__/getSandboxStatus.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatus.test.ts @@ -16,7 +16,7 @@ describe("getSandboxStatus", () => { it("returns sandbox status when sandbox exists", async () => { const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -25,7 +25,7 @@ describe("getSandboxStatus", () => { const result = await getSandboxStatus("sbx_123"); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toEqual({ sandboxId: "sbx_123", sandboxStatus: "running", @@ -52,7 +52,7 @@ describe("getSandboxStatus", () => { it("handles stopped sandbox status", async () => { const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", timeout: 0, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -71,7 +71,7 @@ describe("getSandboxStatus", () => { it("handles pending sandbox status", async () => { const mockSandbox = { - name: "sbx_pending", + sandboxId: "sbx_pending", status: "pending", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index 160cf287c..5f8b33e2f 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ })); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 2dcfb7ef4..1b6da57a6 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -23,7 +23,7 @@ export async function createSandboxFromSnapshot( await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox.name, + sandbox_id: sandbox.sandboxId, }); return { sandbox, fromSnapshot }; diff --git a/lib/sandbox/getOrCreateSandbox.ts b/lib/sandbox/getOrCreateSandbox.ts index e84838bd9..f76fef43a 100644 --- a/lib/sandbox/getOrCreateSandbox.ts +++ b/lib/sandbox/getOrCreateSandbox.ts @@ -21,7 +21,7 @@ export async function getOrCreateSandbox(accountId: string): Promise