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:
Jesse_Chen
2026-09-07 11:08:00 +08:00
parent ad9283d1bd
commit b466a6fc8c
15 changed files with 517 additions and 10 deletions
+5
View File
@@ -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 birth data, narrative, account identifiers, tokens, or model prompts in smoke
evidence. 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/<id>` 200, and one new `POST /api/reports` through ready.
## Common operations ## Common operations
```bash ```bash
+16
View File
@@ -8838,4 +8838,20 @@
- 复发自:unknown-time `block_scan``814c924e`)按时段长度偏置 - 复发自:unknown-time `block_scan``814c924e`)按时段长度偏置
- 修复版本:`517df002` - 修复版本:`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`
@@ -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. 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/<existing-id>`**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 1030s). 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: On the host, confirm no product PDF browser runtime exists:
```bash ```bash
@@ -0,0 +1,43 @@
# PROGRESS · 报告列表 500PostgREST 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 passedDocker Postgres 真迁移:可空、authenticated 只读、service_role 可写、500 接受、501 拒绝、complete RPC 写入摘要)
./node_modules/.bin/tsc --noEmit
# pass
```
## 推送
未推。产品要求只快进 staging、不提升 main。
+1
View File
@@ -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-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-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-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 |
### 前端基础与工程 ### 前端基础与工程
+3 -2
View File
@@ -28,6 +28,7 @@ import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing"; import { authorizeUsage, completeUsage, releaseUsage } from "@/lib/consultation-billing";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { sanitizedErrorReason } from "@/lib/safe-error-reason";
import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server"; import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -45,7 +46,7 @@ const REPORT_LIST_COLUMNS = [
"failure_code", "failure_code",
"created_at", "created_at",
"completed_at", "completed_at",
"card_summary:report_document->executiveSummary->>summary", "card_summary",
].join(","); ].join(",");
function sanitizedErrorCode(error: unknown): string { function sanitizedErrorCode(error: unknown): string {
@@ -139,7 +140,7 @@ export async function GET() {
{ status: 503 }, { status: 503 },
); );
} }
console.error(`[reports] list failed reason=${sanitizedErrorCode(error)}`); console.error(`[reports] list failed reason=${sanitizedErrorReason(error)}`);
return NextResponse.json( return NextResponse.json(
{ error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, { error: "报告列表暂时无法读取", code: REPORT_STABLE_CODES.generationFailed },
{ status: 500 }, { status: 500 },
@@ -4,7 +4,10 @@ import {
REPORT_DOCUMENT_V2_SCHEMA_VERSION, REPORT_DOCUMENT_V2_SCHEMA_VERSION,
type ReportDocumentV2, type ReportDocumentV2,
} from "./personal-report-contract.ts"; } 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"; import type { PersonalReportRecord } from "./personal-report-service-core.ts";
type CoverSkillSnapshot = Readonly<{ type CoverSkillSnapshot = Readonly<{
@@ -52,7 +55,7 @@ function coverHouses(): ReportDocumentV2["charts"][number]["houses"] {
} }
export function buildLongformCoverDocument(input: LongformCoverInput): ReportDocumentV2 { 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 skillName = input.skillSnapshot?.name ?? input.report.skillName;
const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion; const skillVersion = input.skillSnapshot?.version ?? input.report.skillVersion;
const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256; const skillSnapshotSha256 = input.skillSnapshot?.sha256 ?? input.report.skillSnapshotSha256;
@@ -23,6 +23,9 @@ export type LongformOutline = Readonly<{
const EAGER_H2 = /成品阅读导航|质量验收矩阵/; const EAGER_H2 = /成品阅读导航|质量验收矩阵/;
const SUMMARY_TITLE = /^(?:解读摘要|摘要)$/; 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, number>): string { export function slugifyHeading(title: string, used: Map<string, number>): string {
const base = title const base = title
.trim() .trim()
+20
View File
@@ -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<string, unknown>
: 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"}`;
}
@@ -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;
@@ -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();
}
});
+3 -5
View File
@@ -1311,11 +1311,9 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi
createRoute.indexOf("function sanitizedErrorCode"), createRoute.indexOf("function sanitizedErrorCode"),
); );
assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/); assert.doesNotMatch(listColumns, /calculation_hash|evidence_hash/);
assert.match(listColumns, /card_summary:report_document->executiveSummary->>summary/); assert.match(listColumns, /"card_summary"/);
assert.doesNotMatch( assert.doesNotMatch(listColumns, /->|->>|report_document/);
listColumns.replace("card_summary:report_document->executiveSummary->>summary", ""), assert.match(createRoute, /sanitizedErrorReason/);
/report_document/,
);
assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/); assert.doesNotMatch(createRoute, /STALE_GENERATION_MS|staleBefore/);
assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/); assert.match(createRoute, /reportListTimestamp\(row\.created_at\)/);
assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/); assert.match(createRoute, /reportListTimestamp\(row\.completed_at\)/);
@@ -12,6 +12,7 @@ import {
PERSONAL_REPORT_LEGACY_PLACEHOLDER, PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "../src/lib/personal-report-longform-copy.ts"; } from "../src/lib/personal-report-longform-copy.ts";
import { import {
PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS,
buildLongformOutline, buildLongformOutline,
extractLongformSummary, extractLongformSummary,
personalReportMarkdownFilename, personalReportMarkdownFilename,
@@ -62,12 +63,14 @@ const UNSAFE_MARKDOWN = [
test("report centre cards read the stored Markdown excerpt, not a writer summary field name", () => { 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"); 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( const coverSource = readFileSync(
new URL("../src/lib/personal-report-longform-cover.ts", import.meta.url), new URL("../src/lib/personal-report-longform-cover.ts", import.meta.url),
"utf8", "utf8",
); );
assert.match(coverSource, /extractLongformSummary/); assert.match(coverSource, /extractLongformSummary/);
assert.match(coverSource, /PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS/);
}); });
test("writer pipeline stays in the tree but is feature-off", () => { 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", () => { test("card excerpt comes from the Markdown 摘要 section", () => {
const excerpt = extractLongformSummary(SAMPLE_MARKDOWN, 80); const excerpt = extractLongformSummary(SAMPLE_MARKDOWN, 80);
assert.equal(PERSONAL_REPORT_CARD_SUMMARY_MAX_CHARS, 500);
assert.match(excerpt, /事业方向保持观察/); assert.match(excerpt, /事业方向保持观察/);
assert.doesNotMatch(excerpt, /executiveSummary/); assert.doesNotMatch(excerpt, /executiveSummary/);
assert.equal(personalReportMarkdownFilename("2026-09-06T08:00:00.000Z"), "个人报告-2026-09-06"); 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(localMigration, /to admin_runtime/);
assert.doesNotMatch(supabaseMigration, /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\(/);
});
+21
View File
@@ -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/,
);
});