fix(report): stop listing reports via PostgREST JSON paths (BUG-574)
Staging PostgREST rejects the executiveSummary JSON-path alias, so GET /api/reports 500s. Persist a plain card_summary column from the Markdown excerpt instead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
}).status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const USER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const USER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const REPORT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
const REQUEST_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
const HASH = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const COMMIT = "2222222222222222222222222222222222222222";
|
||||
const SKILL_NAME = "jyotish-personal-report";
|
||||
const SKILL_VERSION = "1.0.0";
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function selectAsAuthenticated(userId: string, sql: string): string {
|
||||
return `
|
||||
set role authenticated;
|
||||
select set_config('request.jwt.claim.sub', '${userId}', true);
|
||||
${sql}
|
||||
`;
|
||||
}
|
||||
|
||||
function serviceSql(sql: string): string {
|
||||
return `set role service_role;\n${sql}`;
|
||||
}
|
||||
|
||||
function documentFor(reportId: string, summary: string): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: "report_document.v2",
|
||||
reportId,
|
||||
provenance: {
|
||||
calculationHash: HASH,
|
||||
evidenceHash: HASH,
|
||||
skillName: SKILL_NAME,
|
||||
skillVersion: SKILL_VERSION,
|
||||
skillSourceCommit: COMMIT,
|
||||
skillSnapshotSha256: HASH,
|
||||
},
|
||||
executiveSummary: { summary },
|
||||
});
|
||||
}
|
||||
|
||||
test("personal_reports.card_summary is nullable, owner-read, service-written, and length-capped", { skip: skipWithoutDocker }, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260907010000_personal_report_card_summary\.sql/);
|
||||
|
||||
fixture.psql(`
|
||||
insert into identity.users (id, name, email, email_verified, email_verified_at)
|
||||
values
|
||||
('${USER_A}', 'Card Summary User A', 'card-a@example.com', true, now()),
|
||||
('${USER_B}', 'Card Summary User B', 'card-b@example.com', true, now());
|
||||
insert into public.personal_reports (
|
||||
id, user_id, request_id, request_fingerprint, report_type, status,
|
||||
schema_version, presentation_mode, requested_themes, depth,
|
||||
skill_name, skill_version, skill_source_commit, skill_snapshot_sha256
|
||||
) values (
|
||||
'${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', '${HASH}', 'personal_full', 'generating',
|
||||
'report_document.v2', 'default', array['career']::text[], 'standard',
|
||||
'${SKILL_NAME}', '${SKILL_VERSION}', '${COMMIT}', '${HASH}'
|
||||
);
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select (card_summary is null)::text from public.personal_reports where id = '${REPORT_ID}'
|
||||
`),
|
||||
"true",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => fixture.psqlAs(
|
||||
"app_runtime",
|
||||
"app-runtime-test-password",
|
||||
selectAsAuthenticated(USER_A, `
|
||||
update public.personal_reports
|
||||
set card_summary = 'owner write'
|
||||
where id = '${REPORT_ID}'
|
||||
`),
|
||||
),
|
||||
/permission denied for table personal_reports/,
|
||||
);
|
||||
|
||||
const jobId = fixture.psql(`
|
||||
select id from public.personal_report_jobs
|
||||
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}'
|
||||
`);
|
||||
assert.match(jobId, /^[0-9a-f-]{36}$/);
|
||||
const claim = fixture.psqlAs(
|
||||
"service_runtime",
|
||||
"service-runtime-test-password",
|
||||
`set role service_role;
|
||||
select lease_token::text
|
||||
from public.claim_personal_report_job('card-summary-worker', 60, '${jobId}');`,
|
||||
);
|
||||
const leaseToken = claim.split("\n").at(-1)!;
|
||||
assert.match(leaseToken, /^[0-9a-f-]{36}$/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psqlAs(
|
||||
"service_runtime",
|
||||
"service-runtime-test-password",
|
||||
serviceSql(`
|
||||
select status
|
||||
from public.complete_personal_report_job(
|
||||
'${jobId}', '${leaseToken}', '${USER_A}', '${REPORT_ID}',
|
||||
'${REQUEST_ID}', '${HASH}', 'report_document.v2',
|
||||
${sqlLiteral(documentFor(REPORT_ID, "事业方向保持观察"))}::jsonb,
|
||||
'${HASH}', '${HASH}', '${SKILL_NAME}',
|
||||
'${SKILL_VERSION}', '${COMMIT}', '${HASH}'
|
||||
);
|
||||
`),
|
||||
),
|
||||
"SET\nready",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select card_summary from public.personal_reports where id = '${REPORT_ID}'`),
|
||||
"事业方向保持观察",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psqlAs(
|
||||
"app_runtime",
|
||||
"app-runtime-test-password",
|
||||
selectAsAuthenticated(USER_A, `
|
||||
select card_summary from public.personal_reports where id = '${REPORT_ID}'
|
||||
`),
|
||||
),
|
||||
`SET\n${USER_A}\n事业方向保持观察`,
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psqlAs(
|
||||
"app_runtime",
|
||||
"app-runtime-test-password",
|
||||
selectAsAuthenticated(USER_B, `
|
||||
select count(*) from public.personal_reports where id = '${REPORT_ID}'
|
||||
`),
|
||||
),
|
||||
`SET\n${USER_B}\n0`,
|
||||
);
|
||||
|
||||
fixture.psqlAs(
|
||||
"service_runtime",
|
||||
"service-runtime-test-password",
|
||||
serviceSql(`
|
||||
update public.personal_reports
|
||||
set card_summary = '${"测".repeat(500)}'
|
||||
where id = '${REPORT_ID}'
|
||||
`),
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select char_length(card_summary) from public.personal_reports where id = '${REPORT_ID}'
|
||||
`),
|
||||
"500",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => fixture.psqlAs(
|
||||
"service_runtime",
|
||||
"service-runtime-test-password",
|
||||
serviceSql(`
|
||||
update public.personal_reports
|
||||
set card_summary = '${"测".repeat(501)}'
|
||||
where id = '${REPORT_ID}'
|
||||
`),
|
||||
),
|
||||
/personal_reports_card_summary_length_check/,
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -1311,11 +1311,9 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi
|
||||
createRoute.indexOf("function sanitizedErrorCode"),
|
||||
);
|
||||
assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/);
|
||||
assert.match(listColumns, /card_summary:report_document->executiveSummary->>summary/);
|
||||
assert.doesNotMatch(
|
||||
listColumns.replace("card_summary:report_document->executiveSummary->>summary", ""),
|
||||
/report_document/,
|
||||
);
|
||||
assert.match(listColumns, /"card_summary"/);
|
||||
assert.doesNotMatch(listColumns, /->|->>|report_document/);
|
||||
assert.match(createRoute, /sanitizedErrorReason/);
|
||||
assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/);
|
||||
assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/);
|
||||
assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
|
||||
} from "../src/lib/personal-report-longform-copy.ts";
|
||||
import {
|
||||
PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS,
|
||||
buildLongformOutline,
|
||||
extractLongformSummary,
|
||||
personalReportMarkdownFilename,
|
||||
@@ -62,12 +63,14 @@ const UNSAFE_MARKDOWN = [
|
||||
|
||||
test("report centre cards read the stored Markdown excerpt, not a writer summary field name", () => {
|
||||
const listRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
assert.match(listRoute, /card_summary:report_document->executiveSummary->>summary/);
|
||||
assert.match(listRoute, /"card_summary"/);
|
||||
assert.doesNotMatch(listRoute, /report_document->|->>summary/);
|
||||
const coverSource = readFileSync(
|
||||
new URL("../src/lib/personal-report-longform-cover.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(coverSource, /extractLongformSummary/);
|
||||
assert.match(coverSource, /PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS/);
|
||||
});
|
||||
|
||||
test("writer pipeline stays in the tree but is feature-off", () => {
|
||||
@@ -89,6 +92,7 @@ test("outline lifts navigation and summary to the first screen", () => {
|
||||
|
||||
test("card excerpt comes from the Markdown 摘要 section", () => {
|
||||
const excerpt = extractLongformSummary(SAMPLE_MARKDOWN, 80);
|
||||
assert.equal(PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS, 500);
|
||||
assert.match(excerpt, /事业方向保持观察/);
|
||||
assert.doesNotMatch(excerpt, /executiveSummary/);
|
||||
assert.equal(personalReportMarkdownFilename("2026-09-06T08:00:00.000Z"), "个人报告-2026-09-06");
|
||||
|
||||
@@ -228,3 +228,16 @@ test("least privilege: anon/public revoked and no direct admin_runtime body acce
|
||||
assert.doesNotMatch(localMigration, /to admin_runtime/);
|
||||
assert.doesNotMatch(supabaseMigration, /to admin_runtime/);
|
||||
});
|
||||
|
||||
test("card_summary is a plain nullable column written by complete_personal_report_job", () => {
|
||||
const sql = readFileSync(
|
||||
new URL("../supabase/migrations/20260907010000_personal_report_card_summary.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(sql, /add column if not exists card_summary text/);
|
||||
assert.match(sql, /char_length\(card_summary\) <= 500/);
|
||||
assert.match(sql, /card_summary = nullif\(/);
|
||||
assert.match(sql, /p_report_document #>> '\{executiveSummary,summary\}'/);
|
||||
assert.doesNotMatch(sql, /update public\.personal_reports[\s\S]*set card_summary = /);
|
||||
assert.match(sql, /complete_personal_report_job\(/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { sanitizedErrorReason } from "../src/lib/safe-error-reason.ts";
|
||||
|
||||
test("sanitizedErrorReason reads PostgREST code/hint from non-Error objects", () => {
|
||||
assert.equal(sanitizedErrorReason("boom"), "UnknownError");
|
||||
assert.equal(sanitizedErrorReason(new Error("secret row")), "Error");
|
||||
assert.equal(
|
||||
sanitizedErrorReason({ code: "42703", hint: "column does not exist" }),
|
||||
"UnknownError code=42703 hint=column does not exist",
|
||||
);
|
||||
assert.equal(
|
||||
sanitizedErrorReason({ name: "PostgrestError", code: "PGRST204", hint: "" }),
|
||||
"PostgrestError code=PGRST204 hint=none",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
sanitizedErrorReason({ code: "42703", message: "row body must not be logged", details: "secret" }),
|
||||
/row body|secret/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user