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
81 changes: 54 additions & 27 deletions apps/web/src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,46 +149,73 @@ describe("fetchWithAuth refresh handling", () => {
});

describe("payment-required redirect", () => {
test("bills the workspace the request was sent for", async () => {
// Switching workspaces can change the active workspace while a request is
// in flight. Its 402 belongs to the workspace in its X-Company-ID header.
const replaced: string[] = [];
const originalWindow = (globalThis as { window?: unknown }).window;
const originalBillingUrl = process.env.VITE_BILLING_URL;
const originalWindow = (globalThis as { window?: unknown }).window;
const originalBillingUrl = process.env.VITE_BILLING_URL;
let replaced: string[] = [];
let respond: (response: Response) => void = () => undefined;
let sentCompanyId = null as string | null;

function stubBrowser(pathname: string): void {
replaced = [];
(globalThis as { window?: unknown }).window = {
location: {
origin: "https://app.example.com",
pathname,
replace: (url: string) => replaced.push(url),
},
};
process.env.VITE_BILLING_URL = "/billing";

let respond: (response: Response) => void = () => undefined;
let sentCompanyId = null as string | null;
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
sentCompanyId = (init?.headers as Record<string, string>)["X-Company-ID"];
return new Promise<Response>((resolve) => {
respond = resolve;
});
}) as unknown as typeof fetch;
}

async function answerWith402(request: Promise<unknown>): Promise<void> {
respond(
Response.json({ error: "SUBSCRIPTION_RESTRICTED" }, { status: 402 }),
);
await expect(request).rejects.toThrow();
}

afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
if (originalBillingUrl === undefined) delete process.env.VITE_BILLING_URL;
else process.env.VITE_BILLING_URL = originalBillingUrl;
});

test("bills the workspace the request was sent for", async () => {
stubBrowser("/w/new-workspace/chat");
setCompanyId("new-workspace");
await answerWith402(fetchWithAuth("/companies"));

expect(sentCompanyId).toBe("new-workspace");
expect(replaced).toEqual([
"/billing?companyId=new-workspace&mode=onboarding",
]);
});

test("ignores a 402 for a workspace the user has left", async () => {
// Switching away from a workspace that needs payment must not be undone by
// a request that was still in flight for it.
stubBrowser("/w/paid-workspace/chat");
setCompanyId("unpaid-workspace");
const request = fetchWithAuth("/notifications/count");
setCompanyId("paid-workspace");
await answerWith402(request);

expect(sentCompanyId).toBe("unpaid-workspace");
expect(replaced).toEqual([]);
});

test("never redirects away from the workspace chooser", async () => {
// The chooser is how a user leaves a workspace that needs payment.
stubBrowser("/workspaces");
setCompanyId("unpaid-workspace");
await answerWith402(fetchWithAuth("/notifications/count"));

try {
setCompanyId("new-workspace");
const request = fetchWithAuth("/companies");
setCompanyId("previous-workspace");
respond(
Response.json({ error: "SUBSCRIPTION_RESTRICTED" }, { status: 402 }),
);

await expect(request).rejects.toThrow();
expect(sentCompanyId).toBe("new-workspace");
expect(replaced).toEqual([
"/billing?companyId=new-workspace&mode=onboarding",
]);
} finally {
(globalThis as { window?: unknown }).window = originalWindow;
if (originalBillingUrl === undefined) delete process.env.VITE_BILLING_URL;
else process.env.VITE_BILLING_URL = originalBillingUrl;
}
expect(replaced).toEqual([]);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { getWorkspaceBillingUrl } from "../billing-url.js";
import { isWorkspaceChooserPath } from "../workspace-routes.js";
import type { RefreshResponse } from "./types.js";

// API Configuration
Expand All @@ -30,6 +31,14 @@ function redirectToBillingOnPaymentRequired(
) {
return;
}
// A 402 for a workspace the user has since left is stale: following it
// would pull them back into the workspace they just switched away from.
// This relies on WorkspaceRouteGuard not reverting a switch in flight;
// otherwise a new workspace's 402 could arrive after a revert and be dropped.
if (workspaceId !== companyId) return;
// The chooser is the way out of a workspace that needs payment, so it must
// never redirect back into that workspace's billing page.
if (isWorkspaceChooserPath(window.location.pathname)) return;

const billingUrl = getWorkspaceBillingUrl(workspaceId, { onboarding: true });
if (!billingUrl) return;
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/workspace-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { MemberPermissions } from "@wateaminbox/shared";
import {
chatViewPath,
getWorkspaceDestination,
isWorkspaceChooserPath,
parseChatView,
resolveInitialWorkspaceId,
resolveWorkspaceDestination,
Expand Down Expand Up @@ -139,3 +140,12 @@ describe("chat view query param", () => {
});
});
});

describe("isWorkspaceChooserPath", () => {
test("matches the chooser with or without a trailing slash", () => {
expect(isWorkspaceChooserPath("/workspaces")).toBe(true);
expect(isWorkspaceChooserPath("/workspaces/")).toBe(true);
expect(isWorkspaceChooserPath("/w/abc/chat")).toBe(false);
expect(isWorkspaceChooserPath("/workspaces-old")).toBe(false);
});
});
7 changes: 7 additions & 0 deletions apps/web/src/lib/workspace-routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { MemberPermissions } from "@wateaminbox/shared";

/** Account-level page for choosing a workspace; it belongs to no workspace. */
export const WORKSPACE_CHOOSER_PATH = "/workspaces";

export function isWorkspaceChooserPath(pathname: string): boolean {
return pathname.replace(/\/+$/, "") === WORKSPACE_CHOOSER_PATH;
}

export function resolveInitialWorkspaceId(
availableWorkspaceIds: string[],
routeWorkspaceId: string | null,
Expand Down
Loading