// 记忆整理**应用路径** api 客户端的测试(切片 TIDY-1)。 // // 为什么需要它:这张卡的价值全在「点得到且说真话」,而最容易走样的是**传输层**的三件事 —— // ① 五个端点的 URL / 方法形状(批量是 `…/candidates/apply`,不是 `…/candidates/{id}/apply`); // ② `{data}` 信封解包(回到调用方的必须是 data 本身,不能是信封); // ③ **`status:"skipped"` 是 200、不是错误** —— 传输层不许把它抛成异常(界面才不会报红), // 而真正的失败(tidy_unavailable / not_found / tidy_run_not_found)必须带着 `code` 抛出, // **绝不**回一份空结果假装「没有候选」。 // // 跑法(与 flowTimeline.test.ts / configFacts.test.ts 同手法;项目无前端测试运行器): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/lib/api/adminMemoryTidyApply.test.ts \ // --bundle --platform=node --format=esm \ // --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ // --outfile=/tmp/cyrene-memory-tidy-apply-api.test.mjs \ // && node /tmp/cyrene-memory-tidy-apply-api.test.mjs // // `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。 import { ApiError } from "./core"; import { adminTidyCandidateApply, adminTidyCandidateRevert, adminTidyCandidates, adminTidyCandidatesApplyBatch, adminTidyRun, } from "./adminMemoryTidyApply"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: T, want: T, what: string): void { ok(got === want, `${what}(实际 ${JSON.stringify(got)},期望 ${JSON.stringify(want)})`); } type Call = { url: string; method: string }; const calls: Call[] = []; /** api 客户端的 `request` 会读 `localStorage` 取 token / API key;node 下没有它。 */ globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, } as unknown as Storage; /** 2xx 桩:回 `{data: body}` 信封(真后端 writeJSON 就是这个形状)。 */ function stubOK(body: unknown): void { globalThis.fetch = ((url: string, init?: { method?: string }) => { calls.push({ url: String(url), method: init?.method ?? "GET" }); return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ data: body }), } as unknown as Response); }) as typeof globalThis.fetch; } /** 非 2xx 桩:回 `{error:{code,message}}` 信封(真后端 writeError 的形状)。 */ function stubErr(status: number, code: string, message: string): void { globalThis.fetch = ((url: string, init?: { method?: string }) => { calls.push({ url: String(url), method: init?.method ?? "GET" }); return Promise.resolve({ ok: false, status, json: () => Promise.resolve({ error: { code, message } }), } as unknown as Response); }) as typeof globalThis.fetch; } /** 跑一次调用并把它抛出来的错误码取回来(没抛则回 "no-error")。 */ async function codeOf(task: () => Promise): Promise { try { await task(); return "no-error"; } catch (err: unknown) { return err instanceof ApiError ? (err.code ?? "no-code") : "not-apierror"; } } // ── ① 候选列表:URL 形状 + `{data}` 解包 ──────────────────────────────────── const CANDIDATES_BODY = { candidates: [], count: 0 }; stubOK(CANDIDATES_BODY); const list = await adminTidyCandidates({ status: "pending" }); same(calls[0].url, "/api/v1/admin/memories/candidates?status=pending", "候选列表 URL"); same(calls[0].method, "GET", "候选列表是只读 GET"); ok(list.candidates.length === 0 && list.count === 0, "解包 `{data}` 信封(回的是 data 本身)"); stubOK(CANDIDATES_BODY); await adminTidyCandidates(); same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates", "不给 status 时不带查询串"); // ── ② 单条应用 / 回滚:路径 + POST,且 **skipped 是 200 不是错误** ───────── const SKIPPED = { candidateId: "cand-1", action: "link", status: "skipped", reason: "action_not_applyable", affectedIds: [], }; stubOK(SKIPPED); const skipped = await adminTidyCandidateApply("cand-1"); same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/apply", "单条应用 URL"); same(calls[calls.length - 1].method, "POST", "应用是写动作 POST"); same(skipped.status, "skipped", "200 + skipped 正常解包(客户端不许把它抛成异常)"); same(skipped.reason, "action_not_applyable", "skipped 的机器可读原因要透传给调用方"); stubOK({ ...SKIPPED, status: "applied", affectedIds: ["mem-short"] }); const applied = await adminTidyCandidateApply("cand-1"); same(applied.status, "applied", "applied 回执按后端值透传"); same(applied.affectedIds.length, 1, "受影响行 id 要透传(回滚的抓手)"); stubOK({ candidateId: "cand-1", action: "merge", status: "applied", affectedIds: ["mem-short"] }); await adminTidyCandidateRevert("cand-1"); same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/revert", "回滚 URL"); same(calls[calls.length - 1].method, "POST", "回滚是写动作 POST"); // 候选 id 必须 URL 编码(不拼裸串)。 stubOK(SKIPPED); await adminTidyCandidateApply("cand a/b"); same( calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand%20a%2Fb/apply", "候选 id 要 URL 编码" ); // ── ③ 批量应用:`…/candidates/apply` + `truncated`/`total` 原样透传 ────────── const BATCH = { limit: 100, total: 342, truncated: true, applied: 90, skipped: 10, failed: 0, merged: 80, archived: 10, reclassified: 0, results: [SKIPPED], }; stubOK(BATCH); const batch = await adminTidyCandidatesApplyBatch(); same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/apply", "批量应用 URL(不带 id)"); same(calls[calls.length - 1].method, "POST", "批量应用是写动作 POST"); same(batch.total, 342, "total 是真待定总数,原样透传"); same(batch.truncated, true, "truncated 原样透传(界面据此说「还剩多少未处理」)"); stubOK({ ...BATCH, runId: "run-1", total: 5, truncated: false }); await adminTidyCandidatesApplyBatch({ runId: "run-1", limit: 50 }); same( calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/apply?runId=run-1&limit=50", "按轮次批量应用要带 runId 与 limit" ); // ── ④ 手动真跑 ──────────────────────────────────────────────────────────── stubOK({ runId: "run-9", trigger: "manual", ruleVersion: "t0.1", durationMs: 12, partitions: 1, scanned: 3, compared: 1, pairs: 1, vetoed: 0, errors: 0, vetoReasons: {}, candidatesCreated: 2, candidatesDuplicate: 0, candidatesByAction: { merge: 2 }, }); const run = await adminTidyRun(); same(calls[calls.length - 1].url, "/api/v1/admin/memories/tidy/run", "手动真跑 URL"); same(calls[calls.length - 1].method, "POST", "手动真跑是写动作 POST"); same(run.candidatesCreated, 2, "真跑回执按后端值透传"); // ── ⑤ 诚实失败:错误码必须原样上抛(绝不回空结果) ────────────────────────── stubErr(500, "tidy_unavailable", "记忆整理候选未接线"); same( await codeOf(() => adminTidyCandidates({ status: "pending" })), "tidy_unavailable", "写面未接线 ⇒ 抛 tidy_unavailable(不是空候选列表)" ); stubErr(404, "not_found", "整理候选不存在"); same(await codeOf(() => adminTidyCandidateApply("nope")), "not_found", "候选不存在 ⇒ not_found"); stubErr(404, "tidy_run_not_found", "整理运行不存在"); same( await codeOf(() => adminTidyCandidatesApplyBatch({ runId: "nope" })), "tidy_run_not_found", "runId 查不到 ⇒ tidy_run_not_found(与「这一轮没有待定候选」是两件事)" ); stubErr(409, "tidy_disabled", "记忆整理已被配置关闭"); same(await codeOf(() => adminTidyRun()), "tidy_disabled", "配置关闭 ⇒ 409 tidy_disabled"); stubErr(500, "tidy_run_unavailable", "记忆整理真跑入口未接线"); same(await codeOf(() => adminTidyRun()), "tidy_run_unavailable", "只接了试算 ⇒ 真跑回 unavailable"); stubErr(503, "tidy_timeout", "记忆整理真跑超时"); same(await codeOf(() => adminTidyRun()), "tidy_timeout", "超时 ⇒ 503 tidy_timeout"); stubErr(400, "bad_request", "invalid limit"); same( await codeOf(() => adminTidyCandidates({ status: "pending", limit: -1 })), "bad_request", "非法 limit 由后端回 400(客户端只转发合法值)" ); // eslint-disable-next-line no-console console.log(`adminMemoryTidyApply: ${checks} checks passed`);