#!/usr/bin/env node 'use strict'; // 店铺运营数据:解析器单测 + 端到端接口测试 // 全部本地跑:临时 SQLite 文件 + 随机端口,不依赖网络与云端数据库。 // 用法:npm run test:ops const { spawn } = require('child_process'); const net = require('net'); const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const ROOT = path.resolve(__dirname, '..'); const FIXTURE = path.join(__dirname, 'fixtures', 'ops-manual-sample.xlsx'); const PLATFORM_FIXTURE = path.join(__dirname, 'fixtures', 'platform-export-sample.xlsx'); const PLATFORM_VARIANT = path.join(__dirname, 'fixtures', 'platform-export-variant.xlsx'); const importer = require(path.join(ROOT, 'ops-import.js')); const results = []; function expect(cond, msg) { if (!cond) throw new Error(msg); } async function check(name, fn) { try { await fn(); results.push({ name, ok: true }); } catch (e) { results.push({ name, ok: false, error: e.message || String(e) }); } } function pickPort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const port = srv.address().port; srv.close(() => resolve(port)); }); }); } async function waitForHealth(port, timeoutMs = 20000) { const start = Date.now(); for (;;) { try { const res = await fetch(`http://127.0.0.1:${port}/healthz`); if (res.ok) return; } catch { /* 还没起来 */ } if (Date.now() - start > timeoutMs) throw new Error('服务未在超时内启动'); await new Promise((r) => setTimeout(r, 300)); } } async function readJson(res) { const text = await res.text(); try { return { status: res.status, body: JSON.parse(text) }; } catch { return { status: res.status, body: { raw: text } }; } } async function main() { const fixture = fs.readFileSync(FIXTURE); const platformFixture = fs.readFileSync(PLATFORM_FIXTURE); const platformVariant = fs.readFileSync(PLATFORM_VARIANT); const fixtureSha = crypto.createHash('sha256').update(fixture).digest('hex'); // ---------- 解析器单测 ---------- await check('cleanValue: 空值 → null + missing', () => { const r = importer.cleanValue(null, 'order_cnt'); expect(r.value === null, 'value 应为 null'); expect(r.reasons.includes('missing'), '应标记 missing'); }); await check('cleanValue: 0 与 null 可区分', () => { const zero = importer.cleanValue(0, 'order_cnt'); expect(zero.value === 0, '0 应保留为 0'); expect(zero.reasons.length === 0, '0 不应有质量标记'); const empty = importer.cleanValue('', 'order_cnt'); expect(empty.value === null, '空串应为 null'); }); await check('cleanValue: 10..6% → 0.106 + malformed-percent', () => { const r = importer.cleanValue('10..6%', 'click_rate'); expect(Math.abs(r.value - 0.106) < 1e-9, `实际 ${r.value}`); expect(r.reasons.includes('malformed-percent'), '应标记 malformed-percent'); }); await check('cleanValue: 12.3 → 0.123 + percent-scaled', () => { const r = importer.cleanValue(12.3, 'click_rate'); expect(Math.abs(r.value - 0.123) < 1e-9, `实际 ${r.value}`); expect(r.reasons.includes('percent-scaled'), '应标记 percent-scaled'); }); await check('cleanValue: 未开放 → null + non-numeric', () => { const r = importer.cleanValue('未开放', 'order_cnt'); expect(r.value === null, 'value 应为 null'); expect(r.reasons.includes('non-numeric'), '应标记 non-numeric'); expect(r.rawText === '未开放', '应保留原文'); }); await check('parseDateColumns: 9.9 + year 2026 → 20260909', () => { const cols = importer.parseDateColumns(['9.9订单量', '10.7营收'], 2026); expect(cols.length === 2, `列数 ${cols.length}`); expect(cols[0].date === '20260909', cols[0].date); expect(cols[1].date === '20261007', cols[1].date); }); await check('parseDateColumns: 跨年 12.31 → 1.1 年 +1', () => { const cols = importer.parseDateColumns(['12.31订单量', '1.1订单量'], 2025); expect(cols[0].date === '20251231', cols[0].date); expect(cols[1].date === '20260101', cols[1].date); }); await check('detect: fixture 识别为 manual-ops', () => { expect(importer.detectBuffer(fixture) === 'manual-ops', '未识别'); }); const parsed = importer.parseWorkbook(fixture, { year: 2026 }); await check('fixture 解析行数 = 36 店 × 29 天', () => { expect(parsed.rows.length === 1044, `实际 ${parsed.rows.length}`); expect(parsed.shops.length === 36, `shops ${parsed.shops.length}`); expect(parsed.dates.length === 29, `dates ${parsed.dates.length}`); expect(parsed.dates[0] === '20260909' && parsed.dates[28] === '20261007', '日期范围'); }); await check('fixture 脏值已清洗', () => { const dirty = parsed.rows.find((r) => (r.raw.click_rate || '').includes('10..6')); expect(dirty, '找不到 10..6% 的行'); expect(Math.abs(dirty.click_rate - 0.106) < 1e-9, `实际 ${dirty.click_rate}`); expect(dirty.quality.includes('malformed-percent'), '缺少 malformed-percent'); const scaled = parsed.rows.find((r) => Number(r.raw.click_rate) === 12.3); expect(scaled && scaled.quality.includes('percent-scaled'), '12.3 未按百分数处理'); const nonNumeric = parsed.rows.find((r) => (r.raw.order_cnt || '') === '未开放'); expect(nonNumeric && nonNumeric.order_cnt === null, '未开放 未转为 null'); }); await check('fixture 无 ID 店铺用 school|shopName 兜底', () => { const noId = parsed.rows.filter((r) => r.quality.includes('no-shop-id')); expect(noId.length > 0, '应存在无 ID 店铺'); expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对'); }); await check('fixture 指定学校时覆盖表格里的学校列', () => { const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' }); expect(forced.rows.length === 1044, `rows ${forced.rows.length}`); expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖'); expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1'); expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖'); }); await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => { const cells = [ ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'], ['测试店', '123', 1, 2, 3, 0.1], ]; const out = importer.parseManualOps(cells, { year: 2026 }); expect(out.rows.length === 1, `rows ${out.rows.length}`); expect(out.rows[0].school === '', `school='${out.rows[0].school}'`); }); await check('fixture 占位行未产生数据', () => { expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名'); }); // ---------- 平台导出(全字段明细) ---------- await check('platform: 识别为 platform-export', () => { expect(importer.detectBuffer(platformFixture) === 'platform-export', '识别失败'); }); const platformParsed = importer.parseAny(platformFixture, { school: '测试校区' }); await check('platform: 10 行 / 10 店 / 124 字段', () => { expect(platformParsed.kind === 'platform-export', platformParsed.kind); expect(platformParsed.rows.length === 10, `rows ${platformParsed.rows.length}`); expect(platformParsed.shops.length === 10, `shops ${platformParsed.shops.length}`); expect(Object.keys(platformParsed.rows[0].data).length === 124, `fields ${Object.keys(platformParsed.rows[0].data).length}`); expect(platformParsed.rows[0].date === '20261018', platformParsed.rows[0].date); expect(platformParsed.rows[0].school === '测试校区', platformParsed.rows[0].school); }); await check('platform: 日期归一化', () => { const cells = [ ['日期', '门店编号', '门店名称', '有效订单'], ['2026/1/9', '1', 'A店', 1], ['2026.10.18', '2', 'B店', 2], ]; const r = importer.parsePlatformExport(cells, {}); expect(r.rows[0].date === '20260109', r.rows[0].date); expect(r.rows[1].date === '20261018', r.rows[1].date); }); // ---------- 端到端(临时 SQLite) ---------- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-')); const dbFile = path.join(tmp, 'ops.db'); const configPath = path.join(tmp, 'config.json'); fs.writeFileSync(configPath, JSON.stringify({ aitable: { enabled: false }, storage: { driver: 'sqlite', sqlite: { file: dbFile } }, })); const port = await pickPort(); const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_CONFIG: configPath, GENPIC_LAKE_DIR: path.join(tmp, 'lake'), NAPOS_CDP_PORT: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); let bootLog = ''; child.stdout.on('data', (c) => { bootLog += c; }); child.stderr.on('data', (c) => { bootLog += c; }); const base = `http://127.0.0.1:${port}`; const upload = (force) => fetch(base + '/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: !!force }), }).then(readJson); try { await waitForHealth(port); await check('E2E 空态 summary', async () => { const r = await fetch(base + '/api/ops/summary').then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.enabled === true, 'enabled 应为 true'); expect(r.body.rowCount === 0, 'rowCount 应为 0'); expect(r.body.driver === 'sqlite', `driver ${r.body.driver}`); }); let uploadId = ''; await check('E2E 上传成功', async () => { const r = await upload(false); expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`); uploadId = r.body.uploadId; expect(/^\d{8}-\d{6}-[a-z0-9]{6}$/.test(uploadId), `uploadId ${uploadId}`); expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`); expect(r.body.report.shops === 36, `shops ${r.body.report.shops}`); }); await check('E2E 重复上传 409', async () => { const r = await upload(false); expect(r.status === 409, `status ${r.status}`); expect(r.body.uploadId === uploadId, '应返回已存在的 uploadId'); }); await check('E2E force 覆盖导入', async () => { const r = await upload(true); expect(r.status === 200, `status ${r.status}`); expect(r.body.report.overwritten === 1044, `overwritten ${r.body.report.overwritten}`); }); await check('E2E summary 有数据', async () => { const r = await fetch(base + '/api/ops/summary').then(readJson); expect(r.body.rowCount === 1044, `rowCount ${r.body.rowCount}`); expect(r.body.schools.length === 3, `schools ${r.body.schools.length}`); expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage'); }); await check('E2E 矩阵查询 + 字段过滤', async () => { const r = await fetch(base + '/api/ops/rows?fields=order_cnt').then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`); expect(r.body.fields.length === 1 && r.body.fields[0].key === 'order_cnt', 'fields 过滤失效'); expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`); const shop = r.body.groups[0].shops[0]; expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失'); }); await check('E2E 学校过滤', async () => { const r = await fetch(base + '/api/ops/rows?schools=' + encodeURIComponent('样例学校A')).then(readJson); expect(r.body.groups.length === 1, `groups ${r.body.groups.length}`); expect(r.body.groups[0].school === '样例学校A', r.body.groups[0].school); }); await check('E2E 下载原件字节一致', async () => { const res = await fetch(`${base}/api/ops/uploads/${uploadId}/file`); expect(res.status === 200, `status ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); const sha = crypto.createHash('sha256').update(buf).digest('hex'); expect(sha === fixtureSha, 'sha256 不一致'); }); await check('E2E 非法 uploadId 400', async () => { const res = await fetch(`${base}/api/ops/uploads/${encodeURIComponent('../etc')}/file`); expect(res.status === 400, `status ${res.status}`); }); await check('E2E 不存在的 uploadId 404', async () => { const res = await fetch(`${base}/api/ops/uploads/20000101-000000-abcdef/file`); expect(res.status === 404, `status ${res.status}`); }); await check('E2E 重新解析', async () => { const r = await fetch(`${base}/api/ops/uploads/${uploadId}/reparse`, { method: 'POST' }).then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`); const sum = await fetch(base + '/api/ops/summary').then(readJson); expect(sum.body.rowCount === 1044, `rowCount ${sum.body.rowCount}`); }); await check('E2E 导出 CSV', async () => { const res = await fetch(base + '/api/ops/export?fields=order_cnt'); expect(res.status === 200, `status ${res.status}`); const text = await res.text(); expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type'); expect(text.includes('样例学校A'), 'CSV 缺少学校列'); expect(text.split('\n')[0].includes('日期') && text.split('\n')[0].includes('订单量'), 'CSV 缺少日期/订单量列'); expect(text.trim().split('\n').length === 1045, `行数 ${text.trim().split('\n').length}`); }); await check('E2E 非 xlsx 拒绝', async () => { const res = await fetch(base + '/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: 'a.txt', contentBase64: Buffer.from('x').toString('base64'), year: 2026 }), }); expect(res.status === 400, `status ${res.status}`); }); await check('E2E 坏文件 400 且不落库', async () => { const before = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length; const res = await fetch(base + '/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: 'bad.xlsx', contentBase64: Buffer.from('not a zip file at all').toString('base64'), year: 2026 }), }); expect(res.status === 400, `status ${res.status}`); const after = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length; expect(after === before, '坏文件不应产生上传记录'); }); await check('E2E 上传时指定学校 → 覆盖并写入', async () => { const res = await fetch(base + '/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }), }).then(readJson); expect(res.status === 200, `status ${res.status}`); const sum = await fetch(base + '/api/ops/summary').then(readJson); expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools)); }); await check('E2E 重新解析保留指定学校', async () => { const list = await fetch(base + '/api/ops/uploads').then(readJson); const id = list.body.uploads[0].id; const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson); expect(r.status === 200, `status ${r.status}`); const sum = await fetch(base + '/api/ops/summary').then(readJson); expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools)); }); const uploadPlatform = (buf, force, school) => fetch(base + '/api/ops/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: '平台导出.xlsx', contentBase64: buf.toString('base64'), year: 2026, force: !!force, ...(school === undefined ? {} : { school }), }), }).then(readJson); await check('E2E 平台导出:未选学校 → 400 提示补选', async () => { const r = await uploadPlatform(platformFixture); expect(r.status === 400 && r.body.code === 'OPS_SCHOOL_REQUIRED', `status ${r.status} ${JSON.stringify(r.body)}`); }); await check('E2E 平台导出:首次导入 10 行', async () => { const r = await uploadPlatform(platformFixture, false, '测试校区'); expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`); expect(r.body.report.kind === 'platform-export', r.body.report.kind); expect(r.body.report.newRows === 10, `newRows ${r.body.report.newRows}`); expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`); }); await check('E2E 平台导出:同文件强制重传 → 全部已存在', async () => { const r = await uploadPlatform(platformFixture, true, '测试校区'); expect(r.status === 200, `status ${r.status}`); expect(r.body.report.newRows === 0, `newRows ${r.body.report.newRows}`); expect(r.body.report.identicalRows === 10, `identical ${r.body.report.identicalRows}`); expect(r.body.duplicateData === true, '应标记重复'); }); await check('E2E 平台导出:内容变化 → 冲突 1 行', async () => { const r = await uploadPlatform(platformVariant, false, '测试校区'); expect(r.status === 200, `status ${r.status}`); expect(r.body.report.conflictRows === 1, `conflict ${r.body.report.conflictRows}`); expect(r.body.report.identicalRows === 9, `identical ${r.body.report.identicalRows}`); expect(r.body.report.writtenRows === 0, `written ${r.body.report.writtenRows}`); }); await check('E2E 平台导出:强制覆盖冲突', async () => { const r = await uploadPlatform(platformVariant, true, '测试校区'); expect(r.status === 200, `status ${r.status}`); expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`); const sum = await fetch(base + '/api/ops/summary').then(readJson); expect(sum.body.platformRowCount === 10, `platformRows ${sum.body.platformRowCount}`); }); await check('E2E 字段目录包含平台列', async () => { const r = await fetch(base + '/api/ops/fields').then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.defaults.join(',') === 'order_cnt,revenue,exposure,click_rate', '默认字段不对'); expect(r.body.fields.length > 4, `fields ${r.body.fields.length}`); expect(r.body.fields.some((f) => f.key === 'p:营业额' && f.type === 'money'), '缺少营业额字段'); }); await check('E2E 店铺筛选', async () => { const all = await fetch(base + '/api/ops/rows').then(readJson); const shop = all.body.groups[0].shops[0]; const r = await fetch(base + '/api/ops/rows?shops=' + encodeURIComponent(shop.shopId || shop.key)).then(readJson); expect(r.status === 200, `status ${r.status}`); const shops = r.body.groups.flatMap((g) => g.shops); expect(shops.length === 1, `shops ${shops.length}`); }); await check('E2E 驾驶舱:KPI 与图表数据', async () => { const r = await fetch(base + '/api/ops/dashboard').then(readJson); expect(r.status === 200, `status ${r.status}`); const d = r.body; expect(d.kpis && d.kpis.revenue.value > 0, `revenue ${d.kpis && d.kpis.revenue.value}`); expect(d.trend.length >= 1, `trend ${d.trend.length}`); expect(d.structure.length === d.trend.length, '结构图与趋势日期数不一致'); expect(d.ranking.length <= 10 && d.ranking.length > 0, `ranking ${d.ranking.length}`); expect(d.funnel.exposure >= 0 && d.funnel.visit >= 0, '漏斗数据异常'); expect(Array.isArray(d.zeroOrder), '缺少零单诊断'); expect(d.zeroOrder.some((z) => z.reason === 'not-open'), '未识别出未开业门店'); }); await check('E2E 驾驶舱:店铺筛选后金额变化', async () => { const all = await fetch(base + '/api/ops/dashboard').then(readJson); const shopId = all.body.ranking[0].shopId; const one = await fetch(base + '/api/ops/dashboard?shops=' + encodeURIComponent(shopId)).then(readJson); expect(one.status === 200, `status ${one.status}`); expect(one.body.kpis.revenue.value > 0, '单店营收为 0'); expect(one.body.kpis.revenue.value < all.body.kpis.revenue.value, '单店营收不应大于全部'); expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`); }); await check('E2E 经营解读:无模型时退回规则版摘要', async () => { const r = await fetch(base + '/api/ops/insights', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }).then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.enabled === true, JSON.stringify(r.body).slice(0, 120)); expect(r.body.source === 'rules', `source ${r.body.source}`); expect(Array.isArray(r.body.points) && r.body.points.length > 0, 'points 为空'); expect(!!r.body.aiError, '应提示模型未配置'); }); await check('E2E 报告缓存:二次读取命中', async () => { const first = await fetch(base + '/api/ops/report').then(readJson); const second = await fetch(base + '/api/ops/report').then(readJson); expect(first.status === 200 && second.status === 200, `status ${first.status}/${second.status}`); expect(second.body.report && second.body.report.cached === true, '二次读取未命中缓存'); }); await check('E2E 报告中心:保存 / 列表 / 查看 / 删除', async () => { const saved = await fetch(base + '/api/ops/reports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }).then(readJson); expect(saved.status === 200 && /^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(saved.body.id), `save ${saved.status} ${JSON.stringify(saved.body).slice(0, 120)}`); const list = await fetch(base + '/api/ops/reports').then(readJson); expect(list.status === 200 && list.body.reports.some((r) => r.id === saved.body.id), '列表缺少刚保存的报告'); // 前端提交当前 Report JSON 时应原样保存(快照),不被重新生成成规则版 const snapshot = { meta: { period_start: '2026-10-14', period_end: '2026-10-20', title: '快照测试' }, kpis: [{ key: 'revenue', label: '营业额', current: 1, unit: 'CNY' }], executive_summary: { overall_assessment: 'snapshot', key_findings: [] }, ai: { source: 'llm', model: 'test-model' }, }; const snapSaved = await fetch(base + '/api/ops/reports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: '20261014', to: '20261020', report: snapshot }), }).then(readJson); expect(snapSaved.status === 200, `snapshot save ${snapSaved.status}`); const snapList = await fetch(base + '/api/ops/reports').then(readJson); const snapRow = snapList.body.reports.find((r) => r.id === snapSaved.body.id); expect(snapRow && snapRow.source === 'llm', '快照来源未保留'); const one = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson); expect(one.status === 200 && one.body.report && one.body.report.meta, '读取报告失败'); const del = await fetch(base + '/api/ops/reports/' + saved.body.id, { method: 'DELETE' }).then(readJson); expect(del.status === 200 && del.body.removed === 1, `delete ${del.status}`); const gone = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson); expect(gone.status === 404, `expect 404, got ${gone.status}`); }); await check('E2E 导入记录只含元数据', async () => { const r = await fetch(base + '/api/ops/uploads').then(readJson); expect(r.body.uploads.length >= 1, '记录为空'); expect(!Object.prototype.hasOwnProperty.call(r.body.uploads[0], 'content'), '不应返回 content'); }); } finally { try { child.kill('SIGKILL'); } catch { /* ignore */ } } // ---------- 存储不可用时的降级 ---------- const badTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-bad-')); const badConfig = path.join(badTmp, 'config.json'); fs.writeFileSync(badConfig, JSON.stringify({ aitable: { enabled: false }, storage: { driver: 'sqlite', sqlite: { file: '/dev/null/ops.db' } }, })); const badPort = await pickPort(); const badChild = spawn(process.execPath, [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(badPort), HOST: '127.0.0.1', GENPIC_CONFIG: badConfig, GENPIC_LAKE_DIR: path.join(badTmp, 'lake'), NAPOS_CDP_PORT: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); const badBase = `http://127.0.0.1:${badPort}`; try { await waitForHealth(badPort); await check('E2E 存储不可用时 summary 降级为 enabled:false', async () => { const r = await fetch(badBase + '/api/ops/summary').then(readJson); expect(r.status === 200, `status ${r.status}`); expect(r.body.enabled === false, 'enabled 应为 false'); expect(!!r.body.reason, '缺少 reason'); }); await check('E2E 存储不可用时其它接口 503', async () => { const r = await fetch(badBase + '/api/ops/rows').then(readJson); expect(r.status === 503, `status ${r.status}`); }); await check('E2E 存储不可用时主服务仍正常', async () => { const r = await fetch(badBase + '/healthz'); expect(r.status === 200, `healthz ${r.status}`); }); } finally { try { badChild.kill('SIGKILL'); } catch { /* ignore */ } try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } try { fs.rmSync(badTmp, { recursive: true, force: true }); } catch { /* ignore */ } } // ---------- 汇总 ---------- const failed = results.filter((r) => !r.ok); console.log('\n========== 店铺运营测试结果 =========='); for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ' ← ' + r.error}`); console.log(`\n通过 ${results.length - failed.length}/${results.length}`); if (failed.length) { if (bootLog) console.log('\n服务启动日志(末尾 800 字):\n' + bootLog.slice(-800)); process.exit(1); } } main().catch((e) => { console.error('测试脚本自身异常:', e); process.exit(1); });