Files
cfw-autumn/server/src/external/vercel/vercelTestApiRouter.ts

214 lines
6.0 KiB
TypeScript

/**
* Test-only mock of the subset of Vercel's marketplace API that we hit from
* `processVercelInvoice`. Mounted at `/__test/vercel/api/*` and consumed
* only by tests that set `ctx.testOptions.mockVercelApi`.
*
* Each captured call is pushed into a Redis list keyed by the integration
* configuration id (`__test:vercel:captures:{installationId}`). Integration
* tests in another process read that list to assert what the SDK sent.
*
* In production this router is not mounted (see `initHono.ts`).
*/
import { Hono } from "hono";
import { resolveRequestRedisV2 } from "@/external/redis/requestRedisV2Resolver.js";
import type { CacheStore } from "@/external/storage/cache/index.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { logCaughtError } from "@/utils/logging/logCaughtError.js";
export const VERCEL_TEST_CAPTURE_PREFIX = "__test:vercel:captures:";
const CAPTURE_TTL_SECONDS = 600; // 10 min
const captureCacheKey = (installationId: string) =>
`${VERCEL_TEST_CAPTURE_PREFIX}${installationId}`;
type CapturedCall = {
method: string;
path: string;
installationId: string;
body: unknown;
receivedAt: number;
};
export const recordVercelTestCapture = async ({
call,
cacheStore,
env,
}: {
call: CapturedCall;
cacheStore?: CacheStore;
env?: Env;
}) => {
const key = captureCacheKey(call.installationId);
if (cacheStore) {
const existing = (await cacheStore.getJson<CapturedCall[]>(key)) || [];
await cacheStore.putJson(key, [...existing, call], {
ttlSeconds: CAPTURE_TTL_SECONDS,
});
return;
}
if (!env) throw new Error("Worker env is required without cacheStore");
const redis = resolveRequestRedisV2({ env });
await redis.rpush(key, JSON.stringify(call));
await redis.expire(key, CAPTURE_TTL_SECONDS);
};
export const getVercelTestCaptures = async ({
installationId,
cacheStore,
env,
}: {
installationId: string;
cacheStore?: CacheStore;
env?: Env;
}): Promise<CapturedCall[]> => {
const key = captureCacheKey(installationId);
if (cacheStore) return (await cacheStore.getJson<CapturedCall[]>(key)) || [];
if (!env) throw new Error("Worker env is required without cacheStore");
const redis = resolveRequestRedisV2({ env });
const raw = await redis.lrange(key, 0, -1);
return raw
.map((entry) => {
try {
return JSON.parse(entry) as CapturedCall;
} catch {
return null;
}
})
.filter((entry): entry is CapturedCall => Boolean(entry));
};
export const clearVercelTestCaptures = async ({
installationId,
cacheStore,
env,
}: {
installationId: string;
cacheStore?: CacheStore;
env?: Env;
}): Promise<void> => {
const key = captureCacheKey(installationId);
if (cacheStore) {
await cacheStore.delete(key);
return;
}
if (!env) throw new Error("Worker env is required without cacheStore");
const redis = resolveRequestRedisV2({ env });
await redis.del(key);
};
const parseJsonOrEmpty = async (c: any, route: string) => {
try {
return await c.req.json();
} catch (error) {
logCaughtError({
logger: c.get?.("ctx")?.logger,
message: "[vercel/test-api] Failed to parse request body as JSON",
error,
data: { route },
level: "warn",
});
return {};
}
};
export const vercelTestApiRouter = new Hono<HonoEnv>();
// POST /v1/installations/:integrationConfigurationId/billing
vercelTestApiRouter.post(
"/v1/installations/:integrationConfigurationId/billing",
async (c) => {
const installationId = c.req.param("integrationConfigurationId");
const body = await parseJsonOrEmpty(c, "submitBillingData");
await recordVercelTestCapture({
env: c.env,
cacheStore: c.get("ctx")?.cacheStore,
call: {
method: "POST",
path: `/v1/installations/${installationId}/billing`,
installationId,
body,
receivedAt: Date.now(),
},
});
// Vercel's submitBillingData returns 201 with an empty body (the SDK
// matches `M.nil(201, z.void())` — see
// `node_modules/@vercel/sdk/esm/funcs/marketplaceSubmitBillingData.js`).
// Returning anything else makes the SDK throw
// `Unexpected Status or Content-Type`.
return c.body(null, 201);
},
);
// POST /v1/installations/:integrationConfigurationId/billing/invoices
vercelTestApiRouter.post(
"/v1/installations/:integrationConfigurationId/billing/invoices",
async (c) => {
const installationId = c.req.param("integrationConfigurationId");
const body = await parseJsonOrEmpty(c, "submitInvoice");
const externalId = (body as { externalId?: string })?.externalId;
await recordVercelTestCapture({
env: c.env,
cacheStore: c.get("ctx")?.cacheStore,
call: {
method: "POST",
path: `/v1/installations/${installationId}/billing/invoices`,
installationId,
body,
receivedAt: Date.now(),
},
});
// Vercel's submitInvoice returns the created invoice id + price.
return c.json(
{
invoiceId: `vi_test_${Date.now()}`,
validationErrors: [],
totalUsd: (body as { items?: { total: string }[] })?.items
?.map((item) => Number(item?.total ?? 0))
.reduce((sum, n) => sum + n, 0)
.toFixed(2),
externalId,
},
200,
);
},
);
// Inspector endpoints used by tests --------------------------------------
// GET /__captures/:installationId → recorded calls in insertion order
vercelTestApiRouter.get("/__captures/:installationId", async (c) => {
const installationId = c.req.param("installationId");
const captures = await getVercelTestCaptures({
installationId,
env: c.env,
cacheStore: c.get("ctx")?.cacheStore,
});
return c.json({ captures }, 200);
});
// DELETE /__captures/:installationId → clear (per-test teardown)
vercelTestApiRouter.delete("/__captures/:installationId", async (c) => {
const installationId = c.req.param("installationId");
await clearVercelTestCaptures({
installationId,
env: c.env,
cacheStore: c.get("ctx")?.cacheStore,
});
return c.json({ cleared: true }, 200);
});
// Anything else → 404 with a clear message so the SDK error surfaces
// instead of silently passing.
vercelTestApiRouter.all("*", (c) => {
return c.json(
{
error: "vercel_test_api_route_not_implemented",
method: c.req.method,
path: c.req.path,
},
404,
);
});