Files
Jyotisha/frontend/tests/admin-rectification-telemetry-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 d0bfc1fc3d feat(rectification): anonymous aggregate telemetry + admin summary page
One row per rectification Case, written once when the range card is first
delivered (GET /api/rectification/cases/[caseId], fire-and-forget after the
response is built). Numbers and closed enums only: no user / case / session
id, birth data, names, text or timestamps finer than the ISO week. Dedupe via
a separate case_id ledger that cascades with the Case (and account deletion).

Migration 20260926010000 is additive: two RLS tables with no runtime table
grants, SECURITY DEFINER write (service_role), purge (service_role) and
aggregate-only summary (admin_runtime) functions; 180-day retention.

Admin: 「校正统计」 page + GET /api/admin/rectification-telemetry
(admin.customers.read), aggregates only, no per-row view or export.

TASK-rectification-telemetry-20260926. test:db not run locally (no Docker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 15:36:29 +08:00

85 lines
4.4 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
TELEMETRY_GAP_BUCKET_LABEL,
TELEMETRY_PERCENT_SHOWN_BUCKETS,
TELEMETRY_STOP_REASON_LABEL,
parseRectificationTelemetrySummary,
parseTelemetrySummaryWeeks,
} from "../src/lib/admin/rectification-telemetry-summary.ts";
import { RECTIFICATION_TELEMETRY_STOP_REASONS } from "../src/lib/rectification-agentic/v9/telemetry.ts";
import { RANGE_DELIVERY_PERCENT_MIN_GAP } from "../src/lib/rectification-agentic/user-copy.ts";
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const route = read("../src/app/api/admin/rectification-telemetry/route.ts");
const view = read("../src/components/admin/rectification-telemetry-summary.tsx");
const adminApp = read("../src/components/admin/admin-app.tsx");
const providers = read("../src/lib/admin/providers.ts");
const migration = read("../supabase/migrations/20260926010000_rectification_telemetry.sql");
test("admin telemetry route is a read-only aggregate behind admin permission", () => {
assert.match(route, /requirePermission\("admin\.customers\.read"\)/);
assert.match(route, /select public\.rectification_telemetry_summary\(\$1::integer\) as summary/);
assert.doesNotMatch(route, /export async function (?:POST|PUT|PATCH|DELETE)/);
// No per-row read: the route never selects from the table, and the admin role has no table grant.
assert.doesNotMatch(route, /from public\.rectification_telemetry\b/);
assert.doesNotMatch(migration, /grant [a-z, ]+ on table public\.rectification_telemetry[a-z_]* to admin_runtime/);
assert.match(migration, /revoke all on table public\.rectification_telemetry from admin_runtime;/);
});
test("admin telemetry page is registered with the same permission and offers no export", () => {
assert.match(adminApp, /name: "rectification-telemetry", list: "\/admin\/rectification-telemetry"/);
assert.match(providers, /"rectification-telemetry": \{ read: "admin\.customers\.read" \}/);
assert.doesNotMatch(view, /download|\.csv|Blob\(|createObjectURL|\/api\/admin\/rectification-telemetry\/rows/i);
assert.match(view, /不能逐条查看或导出/);
assert.match(view, /保留 180 天/);
});
test("summary parser keeps a fixed shape for empty and partial payloads", () => {
const empty = parseRectificationTelemetrySummary(null);
assert.equal(empty.sessions, 0);
assert.equal(empty.retentionDays, 180);
assert.deepEqual(empty.weekly, []);
assert.equal(empty.medians.rangeWidthMinutesP50, null);
const parsed = parseRectificationTelemetrySummary({
weeks: 4,
since: "2026-09-07",
retention_days: 180,
sessions: 3,
medians: { range_width_minutes_p50: 12.5, questions_total_p50: "7" },
question_types: { targeted: 2.33, guided: 1, guided_asked_sessions: 2 },
width_distribution: [{ key: "11-20", count: 2 }, { key: "unknown", count: 1 }, { nope: true }],
stop_reasons: [{ key: "converged", count: 1 }],
precision_gate: { met: 1, not_met: 2, unknown: 0 },
weekly: [{ week: "2026-09-21", sessions: 3, precision_gate_met: 1, precision_gate_known: 3 }],
versions: [{ algorithm_version: "a-1", policy_version: null, skill_version: "10.0.30", count: 3 }],
});
assert.equal(parsed.weeks, 4);
assert.equal(parsed.medians.questionsTotalP50, 7);
assert.equal(parsed.questionTypes.guidedAskedSessions, 2);
assert.deepEqual(parsed.widthDistribution, [{ key: "11-20", count: 2 }, { key: "unknown", count: 1 }]);
assert.equal(parsed.weekly[0]!.precisionGateKnown, 3);
assert.equal(parsed.versions[0]!.policyVersion, null);
});
test("week selector accepts only the offered windows", () => {
assert.equal(parseTelemetrySummaryWeeks("4"), 4);
assert.equal(parseTelemetrySummaryWeeks("26"), 26);
assert.equal(parseTelemetrySummaryWeeks("52"), 12);
assert.equal(parseTelemetrySummaryWeeks(null), 12);
});
test("labels cover every stop reason and the percent buckets follow the card rule", () => {
for (const reason of RECTIFICATION_TELEMETRY_STOP_REASONS) {
assert.ok(TELEMETRY_STOP_REASON_LABEL[reason], reason);
assert.match(migration, new RegExp(`'${reason}'`));
}
assert.equal(RANGE_DELIVERY_PERCENT_MIN_GAP, 5);
assert.deepEqual(TELEMETRY_PERCENT_SHOWN_BUCKETS, ["5-9", "10+"]);
assert.match(migration, /\('5-9', 5, 9, 3\), \('10\+', 10, null, 4\)/);
for (const key of ["0-2", "3-4", "5-9", "10+", "single"]) assert.ok(TELEMETRY_GAP_BUCKET_LABEL[key], key);
});