diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index cad82a8..c8f26be 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -40,6 +40,7 @@ describe("subscription billing", () => { beforeEach(() => { vi.clearAllMocks(); kvGetMock.mockResolvedValue(null); + kvPutMock.mockResolvedValue(undefined); }); it("checks the paid plan entitlement", async () => { @@ -87,4 +88,48 @@ describe("subscription billing", () => { expect(result).toEqual({ id: "org_123" }); expect(getOrCreateMock).not.toHaveBeenCalled(); }); + + it("falls back to Autumn when the customer cache read fails", async () => { + const cacheError = new Error("KV read unavailable"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + kvGetMock.mockRejectedValue(cacheError); + getOrCreateMock.mockResolvedValue({ id: "cust_123" }); + + await expect( + getOrCreateOrganizationCustomer({ + organizationId: "org_123", + userId: "user_123", + userEmail: "alice@example.com", + }), + ).resolves.toEqual({ id: "cust_123" }); + + expect(getOrCreateMock).toHaveBeenCalledWith({ + customerId: "org_123", + email: "alice@example.com", + }); + expect(console.warn).toHaveBeenCalledWith( + "billing.customer-cache-read failed:", + cacheError, + ); + }); + + it("returns the resolved customer when the customer cache write fails", async () => { + const cacheError = new Error("KV write unavailable"); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + getOrCreateMock.mockResolvedValue({ id: "cust_123" }); + kvPutMock.mockRejectedValue(cacheError); + + await expect( + getOrCreateOrganizationCustomer({ + organizationId: "org_123", + userId: "user_123", + userEmail: "alice@example.com", + }), + ).resolves.toEqual({ id: "cust_123" }); + + expect(console.warn).toHaveBeenCalledWith( + "billing.customer-cache-write failed:", + cacheError, + ); + }); }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 918de8b..c3b1310 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -35,8 +35,12 @@ export async function getOrCreateOrganizationCustomer( context: BillingCustomerContext, ): Promise<{ id: string }> { const cacheKey = customerEnsuredKey(context.organizationId); - if (await env.KV.get(cacheKey)) { - return { id: context.organizationId }; + try { + if (await env.KV.get(cacheKey)) { + return { id: context.organizationId }; + } + } catch (error) { + console.warn("billing.customer-cache-read failed:", error); } const customer = await autumn.customers.getOrCreate({ @@ -48,9 +52,13 @@ export async function getOrCreateOrganizationCustomer( throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer"); } - await env.KV.put(cacheKey, "1", { - expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS, - }); + try { + await env.KV.put(cacheKey, "1", { + expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS, + }); + } catch (error) { + console.warn("billing.customer-cache-write failed:", error); + } return { id: customer.id }; } diff --git a/src/server/lib/audit/lighthouse.test.ts b/src/server/lib/audit/lighthouse.test.ts new file mode 100644 index 0000000..87e6807 --- /dev/null +++ b/src/server/lib/audit/lighthouse.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/server/lib/dataforseo", () => ({ + createDataforseoClient: vi.fn(), +})); + +vi.mock("@/server/lib/r2", () => ({ + putTextToR2: vi.fn(), +})); + +import { selectLighthouseSample } from "./lighthouse"; + +describe("selectLighthouseSample", () => { + it("includes a start page reached through a trailing-slash redirect", () => { + const pages = [ + ...Array.from({ length: 10 }, (_, index) => ({ + url: `https://example.com/section${index}`, + statusCode: 200, + })), + { url: "https://example.com/services/", statusCode: 200 }, + ]; + + const selected = selectLighthouseSample( + pages, + "https://example.com/services", + "auto", + ); + + expect(selected).toHaveLength(10); + expect(selected[0]).toBe("https://example.com/services/"); + }); + + it("prefers an exact start page when both slash forms return 2xx", () => { + const selected = selectLighthouseSample( + [ + { url: "https://example.com/services/", statusCode: 200 }, + { url: "https://example.com/services", statusCode: 200 }, + ], + "https://example.com/services", + "auto", + ); + + expect(selected[0]).toBe("https://example.com/services"); + }); +}); diff --git a/src/server/lib/audit/lighthouse.ts b/src/server/lib/audit/lighthouse.ts index 7cf016e..0590775 100644 --- a/src/server/lib/audit/lighthouse.ts +++ b/src/server/lib/audit/lighthouse.ts @@ -9,6 +9,14 @@ interface LighthouseSamplePage { statusCode: number; } +function canonicalUrlKeyWithoutTrailingSlash(url: string): string { + const parsed = new URL(canonicalUrlKey(url)); + if (parsed.pathname !== "/") { + parsed.pathname = parsed.pathname.replace(/\/$/, ""); + } + return parsed.toString(); +} + type LighthouseFetchResult = { result: LighthouseResult; payloadJson: string | null; @@ -130,12 +138,17 @@ export function selectLighthouseSample( // strategy === "auto": homepage + 1 per URL pattern, capped at 10 const selected = new Set(); - // Always include the start URL / homepage. Compare with canonicalUrlKey on - // both sides so the match survives the redirects a site uses to reach its - // canonical homepage: trailing-slash (/ -> // no, e.g. example.com -> - // example.com/), www <-> non-www, and http -> https. + // Always include the start URL / homepage. Prefer an exact canonical match + // so distinct 2xx `/path` and `/path/` pages stay distinct, then tolerate a + // trailing-slash redirect when the exact start URL was not crawled as 2xx. const startKey = canonicalUrlKey(startUrl); - const startPage = validPages.find((p) => canonicalUrlKey(p.url) === startKey); + const startPage = + validPages.find((p) => canonicalUrlKey(p.url) === startKey) ?? + validPages.find( + (p) => + canonicalUrlKeyWithoutTrailingSlash(p.url) === + canonicalUrlKeyWithoutTrailingSlash(startUrl), + ); if (startPage) selected.add(startPage.url); // Group by URL template pattern