import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { createServer, type IncomingHttpHeaders } from "node:http"; import type { AddressInfo } from "node:net"; import { test } from "node:test"; test("action chat sends measurements and annotated references on every message and preserves access checks", { timeout: 30_000 }, async () => { const owner = "11111111-1111-4111-8111-111111111111"; const other = "22222222-2222-4222-8222-222222222222"; const sessionId = "33333333-3333-4333-8333-333333333333"; const actionId = "44444444-4444-4444-8444-444444444444"; const otherActionId = "55555555-5555-4555-8555-555555555555"; const referenceSessionId = "77777777-7777-4777-8777-777777777777"; const foreignSessionId = "88888888-8888-4888-8888-888888888888"; const analytics = { foot_region: "Toe", active_foot_speed: 1232.04, standing_foot_contact_seconds: 0, ball_speed: 1303.31, touch_frame: 766, minimum_knee_angle: null, future_metric: false }; const measured = { id: actionId, label: "Pass", rawAnalytics: analytics, inActionMetrics: Object.entries(analytics).map(([name, value]) => ({ name, value, unit: name === "active_foot_speed" ? "cm/s" : "" })) }; const otherMeasured = { id: otherActionId, label: "Shoot", rawAnalytics: { ball_speed: 1303.31, active_foot_speed: 1232.04 }, inActionMetrics: [{ name: "Ball Speed", value: 1303.31, unit: "cm/s" }, { name: "Active Foot Speed", value: 1232.04, unit: "cm/s" }] }; let role = "user"; let sessionOwner = owner; let resultsAvailable = true; let analysisStatus = 200; let analysisReads = 0; let analysisHeaders: IncomingHttpHeaders = {}; let savedFeedbackAvailable = false; let referencesAvailable = true; let annotationsFail = false; let referenceResultsAvailable = true; let referenceReads = 0; let mutations = 0; const referenceAnalytics = { active_foot: "left", ball_speed: 1303.31, active_foot_speed: 1232.04, standing_foot_contact_seconds: 0, touch_frame: 999999 }; const offsets: Record = { "00-blank": 0, "02-reference": 0, "00-foreign": 5, "03-reference": 300, "04-reference": 200, "05-reference": 100, "06-reference": 50, "07-reference": 20, "08-reference": 10, "zz-late": 1, "zz-wrong-report": 0 }; const referenceUsers = new Set(); const reference = (id: string, type = "Pass", userId = owner, score: number | null = 7, notes = ["Reference coach note."]) => ({ action_id: id, score, annotations_notes: notes.map((note) => ({ note })), actions: { action_id: id, session_id: userId === owner ? referenceSessionId : foreignSessionId, type, sessions: { user_id: userId, players: { gender: "female", bdate: "2000-01-01", height: 170, weight: 65, position: "Forward" } } }, }); const examples = [reference(actionId), reference("00-foreign", "Pass", other, null), reference("shoot-reference", "Shoot"), reference("00-blank", "Pass", owner, null, [" "]), reference("01-empty"), reference("01-missing"), reference("02-reference", "Pass", owner, 0, []), reference("03-reference", "Pass", owner, null), ...Array.from({ length: 5 }, (_, index) => reference(`${String(index + 4).padStart(2, "0")}-reference`)), ...Array.from({ length: 110 }, (_, index) => reference(`far-${String(index).padStart(3, "0")}`)), reference("zz-late", "Pass", other, 0), reference("zz-no-common"), reference("zz-wrong-report")]; const referenceReport = (item: ReturnType) => { const rawAnalytics: Record = item.action_id === "01-empty" ? {} : item.action_id === "zz-no-common" ? { unrelated_metric: 42 } : { ...referenceAnalytics, active_foot_speed: referenceAnalytics.active_foot_speed + (offsets[item.action_id] ?? 500) }; return { id: item.action_id, label: item.action_id === "zz-wrong-report" ? "Shoot" : item.actions.type, rawAnalytics, inActionMetrics: Object.entries(rawAnalytics).map(([name, value]) => ({ name, value, unit: "" })) }; }; type Reference = { session_id: string; action_id: string; action_type: string; player: { gender: string }; score: number | null; notes: string[]; results: { metrics: unknown[]; analytics: unknown } }; type ChatPayload = { actions: unknown[]; message: string; history: unknown[]; context: { action?: { action_id: string; results: { metrics: unknown[]; analytics: unknown }; references: Reference[]; feedback: string[]; annotations: unknown }; actions?: unknown[] } }; const chats: ChatPayload[] = []; const generations: { actions: { action_id: string; references: Reference[] }[] }[] = []; let generationOverride: unknown; const upstream = createServer(async (req, res) => { const url = new URL(req.url!, "http://stub"); res.setHeader("Content-Type", "application/json"); if (url.pathname === "/auth/v1/user") return res.end(JSON.stringify({ id: owner, email: "test@example.com" })); if (url.pathname === `/api/sessions/${sessionId}`) { analysisReads++; analysisHeaders = req.headers; res.statusCode = analysisStatus; return res.end(JSON.stringify({ id: sessionId, actions: resultsAvailable ? [otherMeasured, measured] : [] })); } if ([`/api/sessions/${referenceSessionId}`, `/api/sessions/${foreignSessionId}`].includes(url.pathname)) { referenceReads++; const id = url.pathname.split("/").at(-1); const expectedOwner = id === referenceSessionId ? owner : other; referenceUsers.add(String(req.headers["x-sporalize-user-id"])); if (req.headers["x-sporalize-user-id"] !== expectedOwner) { res.statusCode = 403; return res.end("{}"); } res.statusCode = referenceResultsAvailable ? 200 : 503; return res.end(JSON.stringify({ id, actions: examples.filter((item) => item.action_id !== "01-missing" && item.actions.session_id === id).map(referenceReport) })); } if (url.pathname === "/api/chat") { let body = ""; for await (const chunk of req) body += chunk; chats.push(JSON.parse(body)); return res.end(JSON.stringify({ reply: "Measured coaching reply." })); } if (url.pathname === "/api/session/generate") { let body = ""; for await (const chunk of req) body += chunk; const input = JSON.parse(body); generations.push(input); return res.end(JSON.stringify(generationOverride ?? { actions: input.actions.map((action: { action_id: string }) => ({ action_id: action.action_id, feedback: ["Generated feedback."] })), session_feedback: ["Session summary."] })); } const table = url.pathname.split("/").at(-1); if (req.method !== "GET") { mutations++; return res.end("[]"); } if (table === "annotations" && annotationsFail) { res.statusCode = 400; return res.end(JSON.stringify({ message: "Annotations unavailable." })); } let rows: Record[] = table === "users" ? [{ user_id: owner, account_role: role }] : table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", plan: { subscription_id: "plan" } }] : table === "sessions" ? [{ session_id: sessionId, user_id: sessionOwner, name: "Practice", players: { fname: "Player", gender: "male", bdate: "2000-01-01" } }] : table === "actions" ? [actionId, otherActionId].map((id) => ({ action_id: id, session_id: sessionId, type: id === actionId ? "Pass" : "Shoot", start_frame: 750, end_frame: 780, annotations: [{ score: 7, annotations_notes: [{ note: "Coach note." }] }] })) : table === "annotations" ? referencesAvailable ? examples : [] : table === "actions_feedbacks" ? savedFeedbackAvailable ? [{ action_id: actionId, feedback: "Saved action evaluation." }] : [] : table === "sessions_feedbacks" ? [{ session_id: sessionId, feedback: "Saved session evaluation." }] : []; const value = (row: Record, key: string): unknown => key.split(".").reduce((item, part) => (item as Record | undefined)?.[part], row); for (const [key, filter] of url.searchParams) { if (filter.startsWith("eq.")) rows = rows.filter((row) => String(value(row, key)) === filter.slice(3)); if (filter.startsWith("neq.")) rows = rows.filter((row) => String(value(row, key)) !== filter.slice(4)); } if (url.searchParams.get("order") === "action_id.asc") rows = [...rows].sort((a, b) => String(a.action_id).localeCompare(String(b.action_id))); const from = Number(url.searchParams.get("offset") ?? 0); const limit = Number(url.searchParams.get("limit") ?? rows.length); res.end(JSON.stringify(rows.slice(from, from + limit))); }); upstream.listen(0, "127.0.0.1"); await once(upstream, "listening"); const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; const portProbe = createServer(); portProbe.listen(0, "127.0.0.1"); await once(portProbe, "listening"); const port = (portProbe.address() as AddressInfo).port; await new Promise((resolve) => portProbe.close(() => resolve())); const origin = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], { env: { ...process.env, NODE_ENV: "test", PORT: String(port), FRONTEND_URL: origin, BACKEND_URL: origin, SUPABASE_URL: upstreamOrigin, SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_API_URL: upstreamOrigin, ANALYSIS_PROXY_SECRET: "x".repeat(32), AGENT_API_URL: upstreamOrigin, AGENT_API_KEY: "a".repeat(32), HF_TOKEN: "" }, stdio: ["ignore", "pipe", "pipe"], }); try { let stderr = ""; child.stderr.on("data", (data) => { stderr += String(data); }); await new Promise((resolve, reject) => { child.stdout.on("data", (data) => { if (String(data).includes("API listening")) resolve(); }); child.once("exit", (code) => reject(new Error(`API exited: ${code}\n${stderr}`))); child.once("error", reject); }); const request = (input: Record, authenticated = true) => fetch(`${origin}/api/agent/chat`, { method: "POST", signal: AbortSignal.timeout(10000), headers: { "Content-Type": "application/json", Origin: origin, ...(authenticated ? { Cookie: "sp_access=access" } : {}) }, body: JSON.stringify({ message: "Explain these measurements.", ...input }), }); const selected = { sessionId, actionId }; assert.equal((await request(selected, false)).status, 401); assert.equal(analysisReads, 0); assert.equal((await request(selected)).status, 200); assert.equal(analysisReads, 1); assert.deepEqual(chats[0].context.action?.results, { metrics: measured.inActionMetrics, analytics }); assert.deepEqual(chats[0].context.action?.feedback, []); const expectedReferences = ["zz-late", "00-foreign", "08-reference", "07-reference", "06-reference"]; assert.deepEqual(chats[0].context.action?.references.map((item) => item.action_id), expectedReferences); assert.ok(chats[0].context.action?.references.every((item) => item.action_type === "Pass" && item.player.gender === "female")); const nearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!); assert.deepEqual(chats[0].context.action?.references[0].results, { metrics: nearest.inActionMetrics, analytics: nearest.rawAnalytics }); assert.equal(chats[0].context.action?.references[0].session_id, foreignSessionId, "comparisons include other accounts with their real session IDs"); assert.equal(chats[0].context.action?.references[0].score, 0); assert.equal(chats[0].context.action?.references[1].score, null); assert.deepEqual(chats[0].context.action?.references[0].notes, ["Reference coach note."]); assert.equal(referenceReads, 2, "each session is fetched once across all candidate pages"); assert.deepEqual(referenceUsers, new Set([owner, other])); assert.deepEqual(chats[0].context.action?.annotations, [{ score: 7, annotations_notes: [{ note: "Coach note." }] }]); assert.equal(analysisHeaders["x-sporalize-user-id"], owner); assert.equal(analysisHeaders["x-sporalize-proxy-secret"], "x".repeat(32)); assert.equal(analysisHeaders["x-sporalize-admin"], undefined); analytics.ball_speed = 1500.123456; measured.inActionMetrics.find((metric) => metric.name === "ball_speed")!.value = analytics.ball_speed; savedFeedbackAvailable = true; referenceAnalytics.ball_speed = 1400.75; const history = [{ role: "user", content: "Explain these measurements." }, { role: "assistant", content: "Previous reply." }]; assert.equal((await request({ ...selected, message: "What changed?", history })).status, 200); assert.equal(analysisReads, 2); assert.deepEqual(chats[1].context.action?.results, { metrics: measured.inActionMetrics, analytics }); assert.deepEqual(chats[1].context.action?.feedback, ["Saved action evaluation."]); const updatedNearest = referenceReport(examples.find((item) => item.action_id === "zz-late")!); assert.deepEqual(chats[1].context.action?.references[0].results, { metrics: updatedNearest.inActionMetrics, analytics: updatedNearest.rawAnalytics }); assert.equal(referenceReads, 4, "each message gets current reference measurements"); assert.deepEqual(chats[1].history, history); assert.equal((await request({ sessionId, actionId: otherActionId })).status, 200); assert.deepEqual(chats[2].context.action?.results, { metrics: otherMeasured.inActionMetrics, analytics: otherMeasured.rawAnalytics }); assert.deepEqual(chats[2].context.action?.references.map((item) => item.action_id), ["shoot-reference"]); const chatCount = chats.length; const readCount = analysisReads; sessionOwner = other; assert.equal((await request(selected)).status, 404); assert.equal(analysisReads, readCount, "unauthorized requests must not load measurements"); assert.equal(chats.length, chatCount); role = "admin"; assert.equal((await request(selected)).status, 200); assert.equal(analysisHeaders["x-sporalize-admin"], "true"); assert.equal(analysisHeaders["x-sporalize-user-id"], owner); assert.deepEqual(chats.at(-1)?.context.action?.results, { metrics: measured.inActionMetrics, analytics }); role = "user"; sessionOwner = owner; referencesAvailable = false; assert.equal((await request(selected)).status, 200); assert.deepEqual(chats.at(-1)?.context.action?.references, []); referencesAvailable = true; referenceResultsAvailable = false; const readsBeforeMissing = referenceReads; assert.equal((await request(selected)).status, 200); assert.deepEqual(chats.at(-1)?.context.action?.references, []); assert.equal(referenceReads, readsBeforeMissing + 2, "unavailable reference results are not retried for each candidate page"); referenceResultsAvailable = true; const chatsBeforeFailure = chats.length; annotationsFail = true; assert.equal((await request(selected)).status, 500); assert.equal(chats.length, chatsBeforeFailure, "a DB failure must not be presented as an empty search"); annotationsFail = false; const successfulChats = chats.length; resultsAvailable = false; assert.equal((await request(selected)).status, 404); analysisStatus = 503; assert.equal((await request(selected)).status, 500); assert.equal(chats.length, successfulChats, "missing results must not produce an ungrounded reply"); const failedReads = analysisReads; assert.equal((await request({ actionId })).status, 400); assert.equal((await request({ ...selected, actionId: "66666666-6666-4666-8666-666666666666" })).status, 404); assert.equal(analysisReads, failedReads); assert.equal((await request({ sessionId })).status, 200); assert.equal((await request({})).status, 200); assert.equal(analysisReads, failedReads, "general and session chats do not need action measurements"); assert.equal(chats.at(-1)?.context.action, undefined); assert.equal(mutations, 0, "chat must not save or replace feedback"); analysisStatus = 200; resultsAvailable = true; Object.assign(process.env, { FRONTEND_URL: origin, BACKEND_URL: origin, SUPABASE_URL: upstreamOrigin, SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_API_URL: upstreamOrigin, ANALYSIS_PROXY_SECRET: "x".repeat(32), AGENT_API_URL: upstreamOrigin, AGENT_API_KEY: "a".repeat(32), HF_TOKEN: "" }); const { createClient } = await import("@supabase/supabase-js"); const { actionDistance, generateSessionFeedback } = await import("../src/agent-session.js"); const sample = (rawAnalytics: Record) => ({ id: "sample", label: "Pass", rawAnalytics }); assert.equal(actionDistance(sample({ speed: 0, touch_frame: 1 }), sample({ speed: 0, touch_frame: 9999 })), 0); assert.equal(actionDistance(sample({ missing: null, flag: false, region: "Toe" }), sample({ speed: 0 })), null); assert.equal(actionDistance(sample({ speed: 10 }), sample({ speed: "10" })), null); assert.equal(actionDistance(sample({ speed: 10 }), sample({ unrelated: 10 })), null); assert.ok(actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1010, angle: 101 }))! < actionDistance(sample({ speed: 1000, angle: 100 }), sample({ speed: 1000 }))!, "missing values must not outrank complete close measurements"); assert.equal(actionDistance(sample({ ball_speed: 10, touch_frame: 1 }), sample({ ball_speed: 9999, touch_frame: 9999 })), null); assert.equal(actionDistance(sample({ active_foot_speed: 10, ball_speed: 10, touch_frame: 1 }), { id: "metric", label: "Pass", inActionMetrics: [{ name: "Active Foot Speed", value: 10 }, { name: "Ball Speed", value: 9999 }, { name: "Touch Frame", value: 9999 }] }), 0); const db = createClient(upstreamOrigin, "test-service", { auth: { persistSession: false } }); const readsBeforeGeneration = referenceReads; await generateSessionFeedback(db, sessionId, owner); assert.equal(referenceReads - readsBeforeGeneration, 2, "all actions in a generation share reference session downloads"); assert.deepEqual(generations[0].actions.find((item) => item.action_id === actionId)?.references.map((item) => item.action_id), expectedReferences); assert.deepEqual(generations[0].actions.find((item) => item.action_id === otherActionId)?.references.map((item) => item.action_id), ["shoot-reference"]); assert.equal(mutations, 6, "automatic generation still persists feedback for both actions and the session"); await generateSessionFeedback(db, sessionId, owner); assert.equal(referenceReads - readsBeforeGeneration, 4, "a new generation fetches fresh reference measurements"); const mutationsBeforeInvalid = mutations; for (const invalid of [ { actions: [{ action_id: "foreign-action", feedback: ["Bad."] }, { action_id: otherActionId, feedback: ["Good."] }], session_feedback: ["Summary."] }, { actions: [{ action_id: actionId, feedback: ["Bad."] }, { action_id: actionId, feedback: ["Duplicate."] }], session_feedback: ["Summary."] }, { actions: [{ action_id: actionId, feedback: ["Bad."] }], session_feedback: ["Summary."] }, { actions: [{ action_id: actionId, feedback: ["Bad."] }, { action_id: otherActionId, feedback: [" "] }], session_feedback: ["Summary."] }, ]) { generationOverride = invalid; await assert.rejects(generateSessionFeedback(db, sessionId, owner), /Agent generation failed/); assert.equal(mutations, mutationsBeforeInvalid, "invalid agent output cannot change stored feedback"); } generationOverride = undefined; } finally { if (child.exitCode === null && child.signalCode === null) { const exited = once(child, "exit"); child.kill(); await exited; } upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); } });