diff --git a/scripts/service-registry-smoke.mjs b/scripts/service-registry-smoke.mjs new file mode 100644 index 0000000..9c67a39 --- /dev/null +++ b/scripts/service-registry-smoke.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node + +const args = new Map(); +for (let i = 2; i < process.argv.length; i += 1) { + const key = process.argv[i]; + if (!key.startsWith("--")) continue; + const next = process.argv[i + 1]; + args.set(key.slice(2), next && !next.startsWith("--") ? next : "true"); + if (next && !next.startsWith("--")) i += 1; +} + +const baseUrl = (args.get("base-url") || process.env.BACKEND_BASE_URL || "http://127.0.0.1:4000").replace(/\/$/, ""); +const userId = args.get("user-id") || process.env.SMOKE_USER_ID || "registry-smoke"; +const iterations = Number(args.get("iterations") || process.env.SMOKE_ITERATIONS || 1); +const serviceToken = process.env.SERVICE_TOKEN; + +if (!serviceToken) { + throw new Error("SERVICE_TOKEN is required for authenticated backend smoke probes."); +} + +const requiredServices = [ + "interview-service", + "roleplay-service", + "resume-service", + "cover-letter-service", + "courses-service", + "assessment-service", + "matchmaking-service", + "pathways-service", + "qscore-service", + "social-branding-service", +]; + +const directHealth = [ + ["interview", "http://127.0.0.1:8007/health"], + ["roleplay", "http://127.0.0.1:8008/health"], + ["resume", "http://127.0.0.1:8002/health"], + ["qscore", "http://127.0.0.1:8000/health"], + ["courses", "http://127.0.0.1:8060/api/v1/health"], + ["assessment", "http://127.0.0.1:8070/api/v1/health"], + ["matchmaking", "http://127.0.0.1:8006/api/v1/health"], + ["pathways", "http://127.0.0.1:8009/api/v1/health"], + ["social", "http://127.0.0.1:8015/health"], +]; + +function assert(condition, message, detail) { + if (condition) return; + const suffix = detail === undefined ? "" : `\n${JSON.stringify(detail, null, 2).slice(0, 3000)}`; + throw new Error(`${message}${suffix}`); +} + +function authHeaders(extra = {}) { + return { + authorization: `Bearer ${serviceToken}`, + "x-growqr-user": userId, + ...extra, + }; +} + +async function request(name, url, init = {}, timeoutMs = 15000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const started = Date.now(); + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : {}; + } catch { + json = undefined; + } + const durationMs = Date.now() - started; + assert(res.ok, `${name} returned HTTP ${res.status}`, { text, durationMs }); + return { json, text, durationMs }; + } finally { + clearTimeout(timer); + } +} + +function rejectFallbackLike(name, value) { + if (value && typeof value === "object") { + assert(!("error" in value), `${name} contains error field`, value); + assert(!("detail" in value && /internal|fallback|not implemented/i.test(String(value.detail))), `${name} contains error detail`, value); + } + const text = JSON.stringify(value).toLowerCase(); + const bad = ["placeholder", "dummy", "not implemented", "fallback"]; + const found = bad.find((needle) => text.includes(needle)); + assert(!found, `${name} contains fallback/error-like marker: ${found}`, value); +} + +function assertGeneratedPreview(name, json) { + rejectFallbackLike(name, json); + assert(typeof json.session_id === "string" && json.session_id.length > 12, `${name} missing session_id`, json); + assert(json.status === "draft", `${name} should create draft preview`, json); + assert(json.needs_approval === true, `${name} should require approval`, json); + + const outline = Array.isArray(json.question_outline) ? json.question_outline : json.prompt_outline; + assert(Array.isArray(outline) && outline.length >= 2, `${name} missing generated outline`, json); + assert(Boolean(json.opening_prompt), `${name} missing opening_prompt`, json); + assert(Boolean(json.candidate_brief), `${name} missing candidate_brief`, json); +} + +async function runIteration(iteration) { + const prefix = `[smoke ${iteration}]`; + const health = await request(`${prefix} backend health`, `${baseUrl}/healthz`); + assert(health.json?.ok === true, `${prefix} backend health payload invalid`, health.json); + + const catalog = await request(`${prefix} services catalog`, `${baseUrl}/services/catalog`, { + headers: authHeaders(), + }); + const services = catalog.json?.services; + assert(Array.isArray(services), `${prefix} catalog missing services`, catalog.json); + for (const id of requiredServices) { + assert(services.some((service) => service.id === id), `${prefix} catalog missing ${id}`, catalog.json); + } + assert(!services.some((service) => service.backend?.baseUrl), `${prefix} catalog leaks internal backend baseUrl`, catalog.json); + assert( + services.find((service) => service.id === "courses-service")?.backend?.healthPath === "/api/v1/health", + `${prefix} courses health path is not canonical`, + catalog.json, + ); + + for (const [name, url] of directHealth) { + const res = await request(`${prefix} ${name} direct health`, url, {}, 8000); + rejectFallbackLike(`${prefix} ${name} direct health`, res.json ?? res.text); + } + + for (const service of ["interview", "roleplay", "resume", "social"]) { + const res = await request(`${prefix} ${service} gateway health`, `${baseUrl}/services/${service}/health`, { + headers: authHeaders(), + }); + rejectFallbackLike(`${prefix} ${service} gateway health`, res.json ?? res.text); + } + + const interviewState = await request(`${prefix} interview page-state`, `${baseUrl}/services/interview/page-state`, { + headers: authHeaders(), + }); + assert(Array.isArray(interviewState.json?.recent_sessions), `${prefix} interview page-state missing recent_sessions`, interviewState.json); + + const roleplayState = await request(`${prefix} roleplay page-state`, `${baseUrl}/services/roleplay/page-state`, { + headers: authHeaders(), + }); + assert(Array.isArray(roleplayState.json?.recent_sessions), `${prefix} roleplay page-state missing recent_sessions`, roleplayState.json); + + const qscore = await request(`${prefix} qscore current`, `${baseUrl}/services/qscore/current`, { + headers: authHeaders(), + }); + assert("signals" in qscore.json && Array.isArray(qscore.json.signals), `${prefix} qscore current missing signals`, qscore.json); + + const interviewPayload = { + user_id: userId, + org_id: "growqr", + persona_id: "emma", + interview_type: "behavioral", + duration_minutes: 5, + context: { + target_role: "Product Manager", + company_name: "GrowQR Smoke Test", + difficulty: "medium", + source: "registry-smoke", + personalize: false, + }, + }; + const interviewPreview = await request(`${prefix} interview preview generation`, `${baseUrl}/services/interview/preview`, { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify(interviewPayload), + }, 90000); + assertGeneratedPreview(`${prefix} interview preview generation`, interviewPreview.json); + + const roleplayPayload = { + user_id: userId, + org_id: "growqr", + persona_id: "emma", + duration_minutes: 5, + roleplay_type: "custom", + brief: "Practice a concise salary negotiation opening for a product manager offer.", + metadata: { + target_role: "Product Manager", + difficulty: "medium", + source: "registry-smoke", + personalize: false, + }, + }; + const roleplayPreview = await request(`${prefix} roleplay preview generation`, `${baseUrl}/services/roleplay/preview`, { + method: "POST", + headers: authHeaders({ "content-type": "application/json" }), + body: JSON.stringify(roleplayPayload), + }, 90000); + assertGeneratedPreview(`${prefix} roleplay preview generation`, roleplayPreview.json); + + return { + iteration, + catalogCount: services.length, + interviewSession: interviewPreview.json.session_id, + roleplaySession: roleplayPreview.json.session_id, + }; +} + +const results = []; +for (let i = 1; i <= iterations; i += 1) { + const result = await runIteration(i); + results.push(result); + console.log(JSON.stringify(result)); +} + +console.log(JSON.stringify({ ok: true, iterations, results })); diff --git a/src/routes/services.ts b/src/routes/services.ts index 601aa03..a7ad7f6 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -603,7 +603,7 @@ export function serviceRoutes() { app.get("/interview/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await interviewService.review(sessionId); + const result = await interviewService.review(sessionId, userId); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -615,9 +615,9 @@ export function serviceRoutes() { return c.json(result); }); app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard())); - app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId")))); - app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId")))); + app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId")))); + app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId")))); + app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId")))); app.get("/roleplay/page-state", async (c) => { const userId = c.get("userId"); @@ -668,7 +668,7 @@ export function serviceRoutes() { app.get("/roleplay/review/:sessionId", async (c) => { const userId = c.get("userId"); const sessionId = c.req.param("sessionId"); - const result = await roleplayService.review(sessionId); + const result = await roleplayService.review(sessionId, userId); const resultObj = result as Record; await recordGatewayEvent({ userId, @@ -680,9 +680,9 @@ export function serviceRoutes() { return c.json(result); }); app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard())); - app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType")))); - app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId")))); - app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId")))); + app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId")))); + app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId")))); + app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId")))); app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId")))); app.post("/resume/tasks", async (c) => { diff --git a/src/services/product-service-clients.ts b/src/services/product-service-clients.ts index 28d882a..bf6113f 100644 --- a/src/services/product-service-clients.ts +++ b/src/services/product-service-clients.ts @@ -12,6 +12,16 @@ export type ServiceCallOptions = { 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); +function userHeader(userId?: string): Record | undefined { + return userId ? { "x-growqr-user": userId } : undefined; +} + +function resolveUserPayload(userIdOrPayload?: string | JsonObject, payload?: JsonObject) { + return typeof userIdOrPayload === "string" + ? { userId: userIdOrPayload, payload } + : { userId: undefined, payload: userIdOrPayload }; +} + async function serviceJson( baseUrl: string, path: string, @@ -34,7 +44,10 @@ async function serviceJson( export const interviewService = { health: () => serviceJson(config.interviewServiceUrl, "/health"), - pageState: (userId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`), + pageState: (userId: string) => + serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, { + headers: userHeader(userId), + }), configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }), preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }), editQuestions: (payload: { session_id: string; questions: Array }) => @@ -49,25 +62,39 @@ export const interviewService = { serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments/unassign", { body: payload }), resultsBulk: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/interviews/results:bulk", { body: payload }), - review: (sessionId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`), + review: (sessionId: string, userId?: string) => + serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, { + headers: userHeader(userId), + }), leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"), - artifact: (sessionId: string, artifactType: string) => - serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`), - createVideoUploadUrl: (sessionId: string, payload?: JsonObject) => - serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { - method: "POST", - ...(payload === undefined ? {} : { body: payload }), + artifact: (sessionId: string, artifactType: string, userId?: string) => + serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, { + headers: userHeader(userId), }), - markVideoUploaded: (sessionId: string, payload?: JsonObject) => - serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => { + const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput); + return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { method: "POST", + headers: userHeader(userId), ...(payload === undefined ? {} : { body: payload }), - }), + }); + }, + markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => { + const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput); + return serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + method: "POST", + headers: userHeader(userId), + ...(payload === undefined ? {} : { body: payload }), + }); + }, }; export const roleplayService = { health: () => serviceJson(config.roleplayServiceUrl, "/health"), - pageState: (userId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`), + pageState: (userId: string) => + serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, { + headers: userHeader(userId), + }), configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }), preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }), editQuestions: (payload: { session_id: string; questions: Array }) => @@ -82,20 +109,31 @@ export const roleplayService = { serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments/unassign", { body: payload }), resultsBulk: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/results:bulk", { body: payload }), - review: (sessionId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`), + review: (sessionId: string, userId?: string) => + serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, { + headers: userHeader(userId), + }), leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"), - artifact: (sessionId: string, artifactType: string) => - serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`), - createVideoUploadUrl: (sessionId: string, payload?: JsonObject) => - serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { - method: "POST", - ...(payload === undefined ? {} : { body: payload }), + artifact: (sessionId: string, artifactType: string, userId?: string) => + serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, { + headers: userHeader(userId), }), - markVideoUploaded: (sessionId: string, payload?: JsonObject) => - serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + createVideoUploadUrl: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => { + const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput); + return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { method: "POST", + headers: userHeader(userId), ...(payload === undefined ? {} : { body: payload }), - }), + }); + }, + markVideoUploaded: (sessionId: string, userIdOrPayload?: string | JsonObject, payloadInput?: JsonObject) => { + const { userId, payload } = resolveUserPayload(userIdOrPayload, payloadInput); + return serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { + method: "POST", + headers: userHeader(userId), + ...(payload === undefined ? {} : { body: payload }), + }); + }, }; export const resumeService = {