Harden service gateway smoke coverage
This commit is contained in:
208
scripts/service-registry-smoke.mjs
Normal file
208
scripts/service-registry-smoke.mjs
Normal file
@@ -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 }));
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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) => {
|
||||
|
||||
@@ -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<string, string> | 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<T = JsonObject>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -34,7 +44,10 @@ async function serviceJson<T = JsonObject>(
|
||||
|
||||
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<JsonObject | string> }) =>
|
||||
@@ -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<JsonObject | string> }) =>
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user