214 lines
6.0 KiB
TypeScript
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,
|
|
);
|
|
});
|