From 998d07baf965b0f3e9c603708adc46994e538213 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 21 Oct 2026 14:38:16 +0800 Subject: [PATCH] test(db): run the gte/lt check on a probe table the runtime role can read The first version seeded chart_profiles, which the runtime role cannot read directly; the gate failed with permission denied (run 4168). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- docs/BUG_HISTORY.md | 2 +- .../PROGRESS-synastry-gte-fix-20261021.md | 2 +- frontend/tests/database-range-filters.test.ts | 42 ++++++++----------- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ed57e4de..407faf44 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36963,7 +36963,7 @@ - 触发条件:任意一次新建合盘报告。 - 根因:`createSynastryFullReport` 数「今天已生成几份」时用了 `.gte("created_at", …)`。自托管客户端的查询构造器没有 `gte` 方法(也没有 `lt`),内部类型和比较符表里有、方法没实现,编译分支也只认 `neq / gt / lte / like`。调用抛 TypeError,被 `app/api/reports/route.ts` 外层 `catch` 吞成通用 500。单测用合盘文件里手写的 `Query` 类型和桩,桩里自带 `gte`,所以测试与门禁全绿;共享接口 `DataQueryBuilder` 也没有 `gte`,但这段代码绕开了它。与 BUG-990 同类:兼容层没实现的写法只在真库上暴露。 - 修复:构造器补 `gte`、`lt`,比较符统一走 `SQL_CMP`;`DataQueryBuilder` 接口补这两个方法;`synastry-full-create.ts` 删掉手写 `Query` 类型,改用 `DataQueryBuilder`,以后缺方法会在类型检查时报错。 -- 验证:`tests/local-postgres-roundtrips.test.ts` 新增一条(假连接池断言生成 `>=` / `<` 且值走参数);`tests/database-range-filters.test.ts`(真库,门禁跑:`gte` / `lt` 过滤真实行,并跑合盘「今日计数」同一条查询);`tests/db-query-builder-coverage.test.ts` 扫描 `frontend/src` 里用到的查询构造器方法,要求自托管构造器都有实现(临时删掉 `gte` 时该测试会红,已验证)。前端全量 5089 条,失败 25 条与基线同名单(均为无 Docker 的库测试),无测试消失;tsc 0;lint 0 error;构建 `/` Static,首屏 gzip 与基线同为 643,725 字节。 +- 验证:`tests/local-postgres-roundtrips.test.ts` 新增一条(假连接池断言生成 `>=` / `<` 且值走参数);`tests/database-range-filters.test.ts`(真库,门禁跑:在探针表上验证 `gte` / `lt` 与边界,并跑合盘「今日计数」同一条查询;第一版用 `chart_profiles` 造数据,门禁 run 4168 报 `permission denied`——运行时角色对业务表没有直接读写权限,改用测试内建的探针表);`tests/db-query-builder-coverage.test.ts` 扫描 `frontend/src` 里用到的查询构造器方法,要求自托管构造器都有实现(临时删掉 `gte` 时该测试会红,已验证)。前端全量 5089 条,失败 25 条与基线同名单(均为无 Docker 的库测试),无测试消失;tsc 0;lint 0 error;构建 `/` Static,首屏 gzip 与基线同为 643,725 字节。 - 防复发:上面的扫描测试;业务代码不得为数据库查询另写本地类型绕开 `DataQueryBuilder`。 - 相关记录:BUG-990(同类:兼容层缺 `not(col,"is",null)`);issue #82。 - 复发自:BUG-990 同类问题。BUG-990 的防复发是「新写法须 test:db 真查或部署后 smoke」,#82 的创建路径没有真库测试,验收也没在 staging 点过一次,所以没拦住。 diff --git a/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md b/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md index 35fa6ab7..11ba0372 100644 --- a/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md +++ b/docs/tasks/PROGRESS-synastry-gte-fix-20261021.md @@ -20,7 +20,7 @@ | `npm test` | 5089 条,失败 25 条,与 `63984d4fc` 失败名单逐条相同(均为本机无 Docker 的库测试);无测试名消失,新增 4 条 | | 新增扫描测试的反向验证 | 临时删掉 `gte` 方法,扫描测试报出 `src/lib/synastry-full-create.ts:131 .gte()`,恢复后通过 | | `npm run build` | `/` 仍 Static;首屏 gzip 643,725 字节,与基线相同 | -| 真库测试 `database-range-filters.test.ts` | 本机无 Docker 跳过;由 staging 门禁的真 PostgreSQL 跑(推送后核对门禁结果) | +| 真库测试 `database-range-filters.test.ts` | 本机无 Docker 跳过,由 staging 门禁的真 PostgreSQL 跑。第一次推送(`503a16fb`,run 4168)门禁唯一失败就是它:用 `chart_profiles` 造数据,运行时角色没有该表的直接读权限(`permission denied`)。改为测试内建探针表后重推 | ## 留给执行方(同一任务书 T3~T6 与红线 3) diff --git a/frontend/tests/database-range-filters.test.ts b/frontend/tests/database-range-filters.test.ts index d6f57736..5d33b530 100644 --- a/frontend/tests/database-range-filters.test.ts +++ b/frontend/tests/database-range-filters.test.ts @@ -33,36 +33,28 @@ test("gte and lt filter real rows, and the synastry daily count query runs", { try { applyMigrations(fixture); const owner = randomUUID(); - const older = randomUUID(); - const newer = randomUUID(); - fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at) - values ('${owner}', 'Fictional owner', '${owner}@example.invalid', true, now());`); - for (const [id, updatedAt, name] of [ - [older, "2026-10-01T00:00:00Z", "虚构甲"], - [newer, "2026-10-21T08:00:00Z", "虚构乙"], - ] as const) { - fixture.psql(`insert into public.chart_profiles ( - id, user_id, role, profile, updated_at, name, birth_date, reported_birth_time, - birth_time_source, latitude, longitude, timezone_id, timezone_offset, - birth_place_label, ayanamsa, birth_time_status - ) values ( - '${id}', '${owner}', 'other', '{"name":"${name}"}'::jsonb, '${updatedAt}', '${name}', '1990-01-01', '08:00', - 'family_exact', 22.3, 114.1, 'Asia/Shanghai', 8, '虚构港', 'lahiri', 'reported' - );`); - } + // A probe table keeps this a query-builder test: runtime roles have no + // direct DML on business tables, so grant select on the probe only. + fixture.psql(`create table public.range_filter_probe (id integer primary key, seen_at timestamptz not null); + insert into public.range_filter_probe values (1, '2026-10-01T00:00:00Z'), (2, '2026-10-21T08:00:00Z'); + grant select on table public.range_filter_probe to service_role;`); const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); - const since = await admin.from("chart_profiles").select("id") - .eq("user_id", owner) - .gte("updated_at", "2026-10-21T00:00:00.000Z"); + const since = await admin.from("range_filter_probe").select("id") + .gte("seen_at", "2026-10-21T00:00:00.000Z"); assert.equal(since.error, null, since.error?.message ?? "gte failed"); - assert.deepEqual((since.data as Array<{ id: string }>).map((row) => row.id), [newer]); + assert.deepEqual((since.data as Array<{ id: number }>).map((row) => row.id), [2]); - const before = await admin.from("chart_profiles").select("id") - .eq("user_id", owner) - .lt("updated_at", "2026-10-21T00:00:00.000Z"); + const before = await admin.from("range_filter_probe").select("id") + .lt("seen_at", "2026-10-21T00:00:00.000Z"); assert.equal(before.error, null, before.error?.message ?? "lt failed"); - assert.deepEqual((before.data as Array<{ id: string }>).map((row) => row.id), [older]); + assert.deepEqual((before.data as Array<{ id: number }>).map((row) => row.id), [1]); + + const boundary = await admin.from("range_filter_probe").select("id") + .gte("seen_at", "2026-10-21T08:00:00.000Z") + .lt("seen_at", "2026-10-21T08:00:00.001Z"); + assert.equal(boundary.error, null, boundary.error?.message ?? "range failed"); + assert.deepEqual((boundary.data as Array<{ id: number }>).map((row) => row.id), [2]); // The exact chain createSynastryFullReport uses for today's count. const today = await admin.from("personal_reports").select("created_at")