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
85 lines
4.4 KiB
TypeScript
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);
|
|
});
|