Handle billing KV failures and preserve Lighthouse start-page matching (#375)
This commit is contained in:
parent
dae0067233
commit
c11517908d
@ -40,6 +40,7 @@ describe("subscription billing", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
kvGetMock.mockResolvedValue(null);
|
kvGetMock.mockResolvedValue(null);
|
||||||
|
kvPutMock.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks the paid plan entitlement", async () => {
|
it("checks the paid plan entitlement", async () => {
|
||||||
@ -87,4 +88,48 @@ describe("subscription billing", () => {
|
|||||||
expect(result).toEqual({ id: "org_123" });
|
expect(result).toEqual({ id: "org_123" });
|
||||||
expect(getOrCreateMock).not.toHaveBeenCalled();
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,8 +35,12 @@ export async function getOrCreateOrganizationCustomer(
|
|||||||
context: BillingCustomerContext,
|
context: BillingCustomerContext,
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const cacheKey = customerEnsuredKey(context.organizationId);
|
const cacheKey = customerEnsuredKey(context.organizationId);
|
||||||
if (await env.KV.get(cacheKey)) {
|
try {
|
||||||
return { id: context.organizationId };
|
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({
|
const customer = await autumn.customers.getOrCreate({
|
||||||
@ -48,9 +52,13 @@ export async function getOrCreateOrganizationCustomer(
|
|||||||
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
|
throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer");
|
||||||
}
|
}
|
||||||
|
|
||||||
await env.KV.put(cacheKey, "1", {
|
try {
|
||||||
expirationTtl: CUSTOMER_ENSURED_TTL_SECONDS,
|
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 };
|
return { id: customer.id };
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/server/lib/audit/lighthouse.test.ts
Normal file
45
src/server/lib/audit/lighthouse.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,6 +9,14 @@ interface LighthouseSamplePage {
|
|||||||
statusCode: number;
|
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 = {
|
type LighthouseFetchResult = {
|
||||||
result: LighthouseResult;
|
result: LighthouseResult;
|
||||||
payloadJson: string | null;
|
payloadJson: string | null;
|
||||||
@ -130,12 +138,17 @@ export function selectLighthouseSample(
|
|||||||
// strategy === "auto": homepage + 1 per URL pattern, capped at 10
|
// strategy === "auto": homepage + 1 per URL pattern, capped at 10
|
||||||
const selected = new Set<string>();
|
const selected = new Set<string>();
|
||||||
|
|
||||||
// Always include the start URL / homepage. Compare with canonicalUrlKey on
|
// Always include the start URL / homepage. Prefer an exact canonical match
|
||||||
// both sides so the match survives the redirects a site uses to reach its
|
// so distinct 2xx `/path` and `/path/` pages stay distinct, then tolerate a
|
||||||
// canonical homepage: trailing-slash (/ -> // no, e.g. example.com ->
|
// trailing-slash redirect when the exact start URL was not crawled as 2xx.
|
||||||
// example.com/), www <-> non-www, and http -> https.
|
|
||||||
const startKey = canonicalUrlKey(startUrl);
|
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);
|
if (startPage) selected.add(startPage.url);
|
||||||
|
|
||||||
// Group by URL template pattern
|
// Group by URL template pattern
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user