Sporalize-api / tests /agent-chat.test.ts
Shoraky's picture
Validate agent action IDs before saving feedback
2cfbff5
Raw History Blame Contribute Delete
20.1 kB
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<string, number> = { "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<string>();
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<typeof reference>) => {
const rawAnalytics: Record<string, unknown> = 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<string, unknown>[] = 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<string, unknown>, key: string): unknown => key.split(".").reduce<unknown>((item, part) => (item as Record<string, unknown> | 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<void>((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<void>((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<string, unknown>, 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<string, unknown>) => ({ 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<void>((resolve) => upstream.close(() => resolve()));
}
});