diff --git a/deploy/README.md b/deploy/README.md index a02dfde4..d7571589 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -374,6 +374,11 @@ profile time is not changed without an explicit current acceptance. Do not recor birth data, narrative, account identifiers, tokens, or model prompts in smoke evidence. +After a report-centre deployment, also run the authenticated list/detail/create +smoke in `docs/operations/personal-report-staging.md` (section “Post-deploy +report list / detail / create smoke”): `GET /api/reports` 200, owned +`GET /api/reports/` 200, and one new `POST /api/reports` through ready. + ## Common operations ```bash diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 61220f3d..ae6e41b9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8838,4 +8838,20 @@ - 复发自:unknown-time `block_scan`(`814c924e`)按时段长度偏置 - 修复版本:`517df002` +## BUG-574 | 报告列表 JSON 路径 select 在 staging PostgREST 上 500 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`GET /api/reports`、`personal_reports` 列表查询、报告中心卡片 +- 用户现象:登录后报告中心无法打开列表,接口稳定返回 500 `{"error":"报告列表暂时无法读取","code":"report_generation_failed"}`。同一会话读取已有报告详情 200。 +- 触发条件:部署含 `cfcd369d` 的应用后打开报告中心。自托管 PostgREST 解析 `card_summary:report_document->executiveSummary->>summary` 失败。 +- 根因:列表 `select` 增加了 PostgREST JSON 路径别名。该语法未被 `test:db` 或真实 PostgREST 覆盖;supabase-js 返回的 PostgrestError 不是 `Error` 实例,列表日志只记 `UnknownError`。 +- 修复:列表改为普通列 `card_summary`;迁移增加可空 `text` 列(≤500 字符);`complete_personal_report_job` 从封面文档 `executiveSummary.summary`(MD「摘要」截取)写入。旧行不回填。列表 catch 记录 `code`/`hint`,不记行数据。 +- 验证:`frontend/tests/database-personal-report-card-summary.test.ts`(可空、authenticated 只读、service_role 可写、超长拒绝、complete RPC 写入);列表/观测单测;部署后需按 `docs/operations/personal-report-staging.md` 用真实会话跑列表 GET + 详情 GET + 创建 POST。 +- 防复发:新增 PostgREST JSON 路径、嵌套 embed 或别名必须有 `test:db` 真查询或部署后 smoke;禁止再用未经验证的 JSON 路径变体赌列表查询。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待提交(`codex/report-list-500-20260907`) + diff --git a/docs/operations/personal-report-staging.md b/docs/operations/personal-report-staging.md index 1d29724e..e91ea966 100644 --- a/docs/operations/personal-report-staging.md +++ b/docs/operations/personal-report-staging.md @@ -116,6 +116,20 @@ curl -sS -o /dev/null -w '%{http_code}\n' https://staging.jyotisha.chat/api/repo Expected logged-out status is `401`. Authenticated owner/non-owner checks must be performed in the browser or with ephemeral local credentials that are never pasted into logs. +### Post-deploy report list / detail / create smoke (required) + +Apply `20260907010000_personal_report_card_summary.sql` before deploying application code that selects `personal_reports.card_summary`. After `/api/health` shows the target SHA, use a real logged-in staging session (do not paste cookies, JWTs, or birth data): + +1. `GET /api/reports` → **200** with `{ reports: [...] }`. Missing `cardSummary` on old rows is success, not an error. Must not return `report_generation_failed`. +2. `GET /api/reports/` → **200** for an owned report (regression: list 500 while detail 200 was the 2026-09-07 incident). +3. `POST /api/reports` create one new longform report, then poll until `status=ready` (about 10–30s). Confirm: + - detail page TOC and wide tables render from Markdown + - export downloads `.md` + - billing settles `inputTokens=0 outputTokens=0` on the catalog model + - the new list card shows `cardSummary` from the Markdown 摘要 excerpt + +Do not record report bodies, birth facts, user ids, or tokens in the smoke log. Record only HTTP status, `status=ready` latency band, and whether `cardSummary` was present. + On the host, confirm no product PDF browser runtime exists: ```bash diff --git a/docs/tasks/PROGRESS-report-list-500-20260907.md b/docs/tasks/PROGRESS-report-list-500-20260907.md new file mode 100644 index 00000000..bf633733 --- /dev/null +++ b/docs/tasks/PROGRESS-report-list-500-20260907.md @@ -0,0 +1,43 @@ +# PROGRESS · 报告列表 500:PostgREST JSON 路径(2026-09-07) + +工作树:`.worktrees/report-list-500-20260907` +分支:`codex/report-list-500-20260907` +基线:`origin/staging` @ `ad9283d1`(任务书写 `5c644f92`,开工 fetch 后 HEAD 为准) +任务书:`TASK-report-list-500-20260907.md` +未改 `.gitea/workflows/**`,不提升 main。 + +## 开工回执 + +- 目标:`GET /api/reports` 恢复 200;卡片摘要改普通列;列表错误日志能看到 PostgREST `code`/`hint`。 +- 最大风险:应用若在迁移前部署,`select card_summary` 会再次 500。必须先 `Migrate Staging Database` 再部署 web。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 止血:去掉 JSON 路径 select | 完成 | `REPORT_LIST_COLUMNS` 只含普通列 `card_summary` | +| 2 `card_summary` 列 + worker 写入 | 完成 | 迁移 `20260907010000`;`complete_personal_report_job` 从 `executiveSummary.summary` 写入(与 MD「摘要」截取同源);旧行不回填 | +| 3 观测 | 完成 | `sanitizedErrorReason` 读取非 Error 的 `code`/`hint`,不记 message/details | +| 4 部署后 smoke | blocked | 清单已写入 `docs/operations/personal-report-staging.md`;需迁移+部署+真实登录会话。本环境无 staging 登录态 | + +## 偏离 + +任务书写「由 worker 从 MD 摘要段写入」。实现上 worker 仍通过 `extractLongformSummary(..., 500)` 写入封面 `executiveSummary.summary`,完成 RPC 在同一事务拷到 `card_summary`。没有另开一次 PostgREST JSON 路径 select,也没有改 RPC 参数签名。 + +## 质量门(本机跑过) + +```text +tsx --test tests/safe-error-reason.test.ts tests/personal-report-longform-md.test.ts \ + tests/personal-report-migration.test.ts tests/personal-report-api.test.ts +# 77 passed + +tsx --test --test-concurrency=1 tests/database-personal-report-card-summary.test.ts +# 1 passed(Docker Postgres 真迁移:可空、authenticated 只读、service_role 可写、500 接受、501 拒绝、complete RPC 写入摘要) + +./node_modules/.bin/tsc --noEmit +# pass +``` + +## 推送 + +未推。产品要求只快进 staging、不提升 main。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 76241d91..b5215bab 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -100,6 +100,7 @@ | `TASK-report-longform-parity-20260905.md`(仓库根) | `PROGRESS-report-longform-parity-20260905.md` | 长报告对齐全量版并挂入产品附录 | 待验收 | `codex/report-longform-parity-20260905`(任务 6 待部署后核对) | | `TASK-report-longform-gaps2-20260906.md`(仓库根) | `PROGRESS-report-longform-gaps2-20260906.md` | 长报告真实参数组合下的装配缺口 | 待验收 | `codex/report-longform-gaps2-20260906`(BUG-561/562 `cfcd369d`;补洞 BUG-564 `e4d16b75`) | | `TASK-report-md-page-20260906.md` | `PROGRESS-report-md-page-20260906.md` | 长报告 Markdown 直接作为报告页 | 已合入 | `cfcd369d` / `809bdf13`(BUG-563 lint 随后修) | +| `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 执行中 | `codex/report-list-500-20260907`(BUG-574) | ### 前端基础与工程 diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 6c053173..96e00eb6 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -28,6 +28,7 @@ import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; +import { sanitizedErrorReason } from "@/lib/safe-error-reason"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -45,7 +46,7 @@ const REPORT_LIST_COLUMNS = [ "failure_code", "created_at", "completed_at", - "card_summary:report_document->executiveSummary->>summary", + "card_summary", ].join(","); function sanitizedErrorCode(error: unknown): string { @@ -139,7 +140,7 @@ export async function GET() { { status: 503 }, ); } - console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`); + console.error(`[reports] list failed reason=${sanitizedErrorReason(error)}`); return NextResponse.json( { error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, { status: 500 }, diff --git a/frontend/src/lib/personal-report-longform-cover.ts b/frontend/src/lib/personal-report-longform-cover.ts index 38ca3e1a..15dac739 100644 --- a/frontend/src/lib/personal-report-longform-cover.ts +++ b/frontend/src/lib/personal-report-longform-cover.ts @@ -4,7 +4,10 @@ import { REPORT_DOCUMENT_V2_SCHEMA_VERSION, type ReportDocumentV2, } from "./personal-report-contract.ts"; -import { extractLongformSummary } from "./personal-report-longform-outline.ts"; +import { + PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS, + extractLongformSummary, +} from "./personal-report-longform-outline.ts"; import type { PersonalReportRecord } from "./personal-report-service-core.ts"; type CoverSkillSnapshot = Readonly<{ @@ -52,7 +55,7 @@ function coverHouses(): ReportDocumentV2["charts"][number]["houses"] { } export function buildLongformCoverDocument(input: LongformCoverInput): ReportDocumentV2 { - const summary = extractLongformSummary(input.markdown); + const summary = extractLongformSummary(input.markdown, PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS); const skillName = input.skillSnapshot?.name ?? input.report.skillName; const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion; const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256; diff --git a/frontend/src/lib/personal-report-longform-outline.ts b/frontend/src/lib/personal-report-longform-outline.ts index 73d793f0..27c84a52 100644 --- a/frontend/src/lib/personal-report-longform-outline.ts +++ b/frontend/src/lib/personal-report-longform-outline.ts @@ -23,6 +23,9 @@ export type LongformOutline = Readonly<{ const EAGER_H2 = /成品阅读导航|质量验收矩阵/; const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/; +/** Dedicated `personal_reports.card_summary` column length. */ +export const PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS = 500; + export function slugifyHeading(title: string, used: Map): string { const base = title .trim() diff --git a/frontend/src/lib/safe-error-reason.ts b/frontend/src/lib/safe-error-reason.ts new file mode 100644 index 00000000..4a0e40ec --- /dev/null +++ b/frontend/src/lib/safe-error-reason.ts @@ -0,0 +1,20 @@ +/** + * Operational log reason for unknown catch values. + * + * PostgREST / supabase-js may throw a plain object with `code` and `hint` + * instead of an Error instance. Never include message, details, or row data. + */ +export function sanitizedErrorReason(error: unknown): string { + const record = error !== null && typeof error === "object" + ? error as Record + : null; + const name = error instanceof Error && error.name.trim() + ? error.name.trim() + : typeof record?.name === "string" && record.name.trim() + ? record.name.trim() + : "UnknownError"; + const code = typeof record?.code === "string" ? record.code.trim() : ""; + const hint = typeof record?.hint === "string" ? record.hint.trim() : ""; + if (!code && !hint) return name; + return `${name} code=${code || "none"} hint=${hint || "none"}`; +} diff --git a/frontend/supabase/migrations/20260907010000_personal_report_card_summary.sql b/frontend/supabase/migrations/20260907010000_personal_report_card_summary.sql new file mode 100644 index 00000000..2260e1af --- /dev/null +++ b/frontend/supabase/migrations/20260907010000_personal_report_card_summary.sql @@ -0,0 +1,166 @@ +-- Dedicated list-card excerpt. PostgREST JSON-path aliases on report_document +-- are rejected by the self-hosted PostgREST used in staging; this plain text +-- column is the only list-select form. Worker completion copies the Markdown +-- 摘要 excerpt already stored on executiveSummary.summary. Old rows stay null. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'personal_report_card_summary_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +alter table public.personal_reports + add column if not exists card_summary text; + +alter table public.personal_reports + drop constraint if exists personal_reports_card_summary_length_check; + +alter table public.personal_reports + add constraint personal_reports_card_summary_length_check + check (card_summary is null or char_length(card_summary) <= 500); + +comment on column public.personal_reports.card_summary is + 'Optional list-card excerpt, at most 500 characters. Owner SELECT only; writes are service_role via complete_personal_report_job. Not backfilled.'; + +-- Same signature as 20260814040000. Adds card_summary from the cover excerpt. +create or replace function public.complete_personal_report_job( + p_job_id uuid, + p_lease_token uuid, + p_user_id uuid, + p_report_id uuid, + p_request_id uuid, + p_request_fingerprint text, + p_schema_version text, + p_report_document jsonb, + p_calculation_hash text, + p_evidence_hash text, + p_skill_name text, + p_skill_version text, + p_skill_source_commit text, + p_skill_snapshot_sha256 text +) +returns setof public.personal_report_jobs +language plpgsql +set search_path = pg_catalog, public +as $$ +declare + v_job public.personal_report_jobs%rowtype; + v_report public.personal_reports%rowtype; + v_completed_at timestamptz; +begin + if p_schema_version <> 'report_document.v2' + or p_request_fingerprint !~ '^[0-9a-f]{64}$' + or p_calculation_hash !~ '^[0-9a-f]{64}$' + or p_evidence_hash !~ '^[0-9a-f]{64}$' + or p_skill_name is null + or p_skill_name !~ '^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$' + or p_skill_version is null + or length(p_skill_version) not between 5 and 80 + or p_skill_version !~ '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + or p_skill_snapshot_sha256 !~ '^[0-9a-f]{64}$' + or (p_skill_source_commit is not null and p_skill_source_commit !~ '^[0-9a-f]{40}$') then + raise exception using errcode = '22023', message = 'personal_report_completion_payload_invalid'; + end if; + + select job.* + into v_job + from public.personal_report_jobs as job + where job.id = p_job_id + for update; + + if not found then + raise exception using errcode = 'P0002', message = 'personal_report_job_not_found'; + end if; + v_completed_at := clock_timestamp(); + if v_job.user_id is distinct from p_user_id + or v_job.request_id is distinct from p_request_id + or v_job.request_fingerprint is distinct from p_request_fingerprint then + raise exception using errcode = '22023', message = 'personal_report_completion_identity_mismatch'; + end if; + if v_job.status <> 'running' + or v_job.lease_token is distinct from p_lease_token + or v_job.lease_expires_at <= v_completed_at then + raise exception using errcode = '55000', message = 'personal_report_job_lease_lost'; + end if; + + select report.* + into v_report + from public.personal_reports as report + where report.id = p_report_id + and report.user_id = p_user_id + and report.request_id = p_request_id + for update; + + if not found then + raise exception using errcode = 'P0002', message = 'personal_report_not_found'; + end if; + if v_report.status <> 'generating' then + raise exception using errcode = '55000', message = 'personal_report_completion_invalid_state'; + end if; + if v_report.request_fingerprint is distinct from p_request_fingerprint + or v_report.skill_name is distinct from p_skill_name + or v_report.skill_version is distinct from p_skill_version + or v_report.skill_source_commit is distinct from p_skill_source_commit + or v_report.skill_snapshot_sha256 is distinct from p_skill_snapshot_sha256 then + raise exception using errcode = '22023', message = 'personal_report_completion_identity_mismatch'; + end if; + if p_report_document is null + or p_report_document ->> 'schemaVersion' <> p_schema_version + or p_report_document ->> 'reportId' <> p_report_id::text + or p_report_document #>> '{provenance,calculationHash}' <> p_calculation_hash + or p_report_document #>> '{provenance,evidenceHash}' <> p_evidence_hash + or p_report_document #>> '{provenance,skillName}' <> p_skill_name + or p_report_document #>> '{provenance,skillVersion}' <> p_skill_version + or p_report_document #>> '{provenance,skillSnapshotSha256}' <> p_skill_snapshot_sha256 + or (p_report_document #>> '{provenance,skillSourceCommit}') is distinct from p_skill_source_commit then + raise exception using errcode = '22023', message = 'personal_report_completion_document_mismatch'; + end if; + if v_report.calculation_hash is not null + and v_report.calculation_hash is distinct from p_calculation_hash then + raise exception using errcode = '22023', message = 'personal_report_completion_hash_mismatch'; + end if; + if v_report.evidence_hash is not null + and v_report.evidence_hash is distinct from p_evidence_hash then + raise exception using errcode = '22023', message = 'personal_report_completion_hash_mismatch'; + end if; + + update public.personal_reports as report + set status = 'ready', + schema_version = p_schema_version, + report_document = p_report_document, + calculation_hash = p_calculation_hash, + evidence_hash = p_evidence_hash, + failure_code = null, + completed_at = v_completed_at, + updated_at = v_completed_at, + card_summary = nullif( + left(btrim(coalesce(p_report_document #>> '{executiveSummary,summary}', '')), 500), + '' + ) + where report.id = p_report_id; + + return query + update public.personal_report_jobs as job + set status = 'ready', + lease_token = null, + lease_owner = null, + lease_acquired_at = null, + lease_expires_at = null, + heartbeat_at = v_completed_at, + next_attempt_at = null, + progress_phase = 'ready', + progress_percent = 100, + last_error_code = null, + last_error_at = null, + finished_at = v_completed_at + where job.id = p_job_id + returning job.*; +end; +$$; + +commit; diff --git a/frontend/tests/database-personal-report-card-summary.test.ts b/frontend/tests/database-personal-report-card-summary.test.ts new file mode 100644 index 00000000..c7fd0ebb --- /dev/null +++ b/frontend/tests/database-personal-report-card-summary.test.ts @@ -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(); + } +}); diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 6f8b8404..4672557d 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -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\)/); diff --git a/frontend/tests/personal-report-longform-md.test.ts b/frontend/tests/personal-report-longform-md.test.ts index 6ca57608..7367670b 100644 --- a/frontend/tests/personal-report-longform-md.test.ts +++ b/frontend/tests/personal-report-longform-md.test.ts @@ -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"); diff --git a/frontend/tests/personal-report-migration.test.ts b/frontend/tests/personal-report-migration.test.ts index e993e97a..dc5fba7c 100644 --- a/frontend/tests/personal-report-migration.test.ts +++ b/frontend/tests/personal-report-migration.test.ts @@ -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\(/); +}); diff --git a/frontend/tests/safe-error-reason.test.ts b/frontend/tests/safe-error-reason.test.ts new file mode 100644 index 00000000..2606521c --- /dev/null +++ b/frontend/tests/safe-error-reason.test.ts @@ -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/, + ); +});