diff --git a/scripts/deadline-hierarchy.test.ts b/scripts/deadline-hierarchy.test.ts new file mode 100644 index 0000000..d7aa6ec --- /dev/null +++ b/scripts/deadline-hierarchy.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { INTERACTIVE_SERVICE_TIMEOUT_MS, serviceJson, roleplayService } from "../src/services/product-service-clients.js"; + +assert.equal(INTERACTIVE_SERVICE_TIMEOUT_MS, 240_000, "interactive service deadline must stay inside the 300s proxy budget"); + +const originalFetch = globalThis.fetch; +const originalTimeout = AbortSignal.timeout; +const requests: Request[] = []; +let capturedTimeout = 0; +AbortSignal.timeout = ((ms: number) => { + capturedTimeout = ms; + return new AbortController().signal; +}) as typeof AbortSignal.timeout; +globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); +}) as typeof fetch; +try { + await roleplayService.plan("session-1", "user-1"); + assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/roleplays/sessions/session-1/plan"); + assert.equal(capturedTimeout, INTERACTIVE_SERVICE_TIMEOUT_MS, "interactive plan calls must use the inner deadline"); + + requests.length = 0; + capturedTimeout = 0; + await serviceJson("http://service.test", "/slow", { timeoutMs: 17 }); + assert.equal(capturedTimeout, 17); +} finally { + AbortSignal.timeout = originalTimeout; + globalThis.fetch = originalFetch; +} +console.log("deadline hierarchy tests passed"); diff --git a/scripts/roleplay-drafts.test.ts b/scripts/roleplay-drafts.test.ts index 3f08aaf..d7749ec 100644 --- a/scripts/roleplay-drafts.test.ts +++ b/scripts/roleplay-drafts.test.ts @@ -12,9 +12,13 @@ globalThis.fetch = (async (input, init) => { ? { drafts: [{ session_id: "draft-1" }] } : url.pathname.endsWith("/plan") ? { session_id: "draft-1", status: "draft" } - : url.pathname.endsWith("/configure/approve") - ? { session_id: "draft-1", status: "configured", approved: true } - : { deleted: true, session_id: "draft-1" }; + : url.pathname.endsWith("/video-usage") + ? { used: 1, limit: 3, remaining: 2 } + : url.pathname.endsWith("/vip-check") + ? { valid: true } + : url.pathname.endsWith("/configure/approve") + ? { session_id: "draft-1", status: "configured", approved: true } + : { deleted: true, session_id: "draft-1" }; return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, @@ -42,13 +46,16 @@ try { assert.equal(deleted.status, 200); assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" }); - assert.equal(requests.length, 4); - assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/); - assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/); - assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/); - assert.equal(requests[3]!.method, "DELETE"); - assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/); - for (const request of requests) { + const usage = await app.request("http://backend.test/roleplay/video-usage"); + assert.equal(usage.status, 200); + assert.deepEqual(await usage.json(), { used: 1, limit: 3, remaining: 2 }); + const vip = await app.request("http://backend.test/roleplay/vip-check?code=grow26"); + assert.equal(vip.status, 200); + assert.deepEqual(await vip.json(), { valid: true }); + assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/vip-check"))?.url ?? "", /code=grow26&user_id=user_test$/); + assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/video-usage"))?.url ?? "", /\/api\/v1\/roleplays\/video-usage\?user_id=user_test$/); + assert.ok(requests.length >= 5); + for (const request of requests.filter((request) => request.url.includes("/api/v1/"))) { assert.equal(request.headers.get("x-growqr-user"), "user_test"); assert.match(request.headers.get("authorization") ?? "", /^Bearer /); } diff --git a/src/routes/services.ts b/src/routes/services.ts index 7bf909d..f598fec 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1045,6 +1045,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) { const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 50) || 50)); return c.json(await roleplayService.drafts(c.get("userId"), limit).catch(serviceErrorResponse)); }); + app.get("/roleplay/video-usage", async (c) => + c.json(await roleplayService.videoUsage(c.get("userId")).catch(serviceErrorResponse))); + app.get("/roleplay/vip-check", async (c) => + c.json(await roleplayService.vipCheck(c.req.query("code") ?? "", c.get("userId")).catch(serviceErrorResponse))); app.delete("/roleplay/drafts/:sessionId", async (c) => c.json(await roleplayService.deleteDraft(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse))); app.post("/roleplay/assignments", async (c) => c.json(await roleplayService.createAssignments(await c.req.json()))); diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index 9a66ab6..114c6a4 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -21,8 +21,8 @@ export class ProductServiceError extends Error { } } -const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500); -const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000); +export const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500); +export const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 240000); function userHeader(userId?: string): Record | undefined { return userId ? { "x-growqr-user": userId } : undefined; @@ -118,6 +118,7 @@ export const roleplayService = { plan: (sessionId: string, userId?: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions/${encodeURIComponent(sessionId)}/plan`, { headers: userHeader(userId), + timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS, }), drafts: (userId: string, limit = 50) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, { @@ -141,6 +142,14 @@ export const roleplayService = { headers: userHeader(userId), }), leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"), + videoUsage: (userId: string) => + serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/video-usage?${new URLSearchParams({ user_id: userId })}`, { + headers: userHeader(userId), + }), + vipCheck: (code: string, userId: string) => + serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/vip-check?${new URLSearchParams({ code, user_id: userId })}`, { + headers: userHeader(userId), + }), artifact: (sessionId: string, artifactType: string, userId?: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, { headers: userHeader(userId),