test-ops-import.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. #!/usr/bin/env node
  2. 'use strict';
  3. // 店铺运营数据:解析器单测 + 端到端接口测试
  4. // 全部本地跑:临时 SQLite 文件 + 随机端口,不依赖网络与云端数据库。
  5. // 用法:npm run test:ops
  6. const { spawn } = require('child_process');
  7. const net = require('net');
  8. const fs = require('fs');
  9. const os = require('os');
  10. const path = require('path');
  11. const crypto = require('crypto');
  12. const ROOT = path.resolve(__dirname, '..');
  13. const FIXTURE = path.join(__dirname, 'fixtures', 'ops-manual-sample.xlsx');
  14. const PLATFORM_FIXTURE = path.join(__dirname, 'fixtures', 'platform-export-sample.xlsx');
  15. const PLATFORM_VARIANT = path.join(__dirname, 'fixtures', 'platform-export-variant.xlsx');
  16. const importer = require(path.join(ROOT, 'ops-import.js'));
  17. const results = [];
  18. function expect(cond, msg) {
  19. if (!cond) throw new Error(msg);
  20. }
  21. async function check(name, fn) {
  22. try {
  23. await fn();
  24. results.push({ name, ok: true });
  25. } catch (e) {
  26. results.push({ name, ok: false, error: e.message || String(e) });
  27. }
  28. }
  29. function pickPort() {
  30. return new Promise((resolve, reject) => {
  31. const srv = net.createServer();
  32. srv.unref();
  33. srv.on('error', reject);
  34. srv.listen(0, '127.0.0.1', () => {
  35. const port = srv.address().port;
  36. srv.close(() => resolve(port));
  37. });
  38. });
  39. }
  40. async function waitForHealth(port, timeoutMs = 20000) {
  41. const start = Date.now();
  42. for (;;) {
  43. try {
  44. const res = await fetch(`http://127.0.0.1:${port}/healthz`);
  45. if (res.ok) return;
  46. } catch { /* 还没起来 */ }
  47. if (Date.now() - start > timeoutMs) throw new Error('服务未在超时内启动');
  48. await new Promise((r) => setTimeout(r, 300));
  49. }
  50. }
  51. async function readJson(res) {
  52. const text = await res.text();
  53. try {
  54. return { status: res.status, body: JSON.parse(text) };
  55. } catch {
  56. return { status: res.status, body: { raw: text } };
  57. }
  58. }
  59. async function main() {
  60. const fixture = fs.readFileSync(FIXTURE);
  61. const platformFixture = fs.readFileSync(PLATFORM_FIXTURE);
  62. const platformVariant = fs.readFileSync(PLATFORM_VARIANT);
  63. const fixtureSha = crypto.createHash('sha256').update(fixture).digest('hex');
  64. // ---------- 解析器单测 ----------
  65. await check('cleanValue: 空值 → null + missing', () => {
  66. const r = importer.cleanValue(null, 'order_cnt');
  67. expect(r.value === null, 'value 应为 null');
  68. expect(r.reasons.includes('missing'), '应标记 missing');
  69. });
  70. await check('cleanValue: 0 与 null 可区分', () => {
  71. const zero = importer.cleanValue(0, 'order_cnt');
  72. expect(zero.value === 0, '0 应保留为 0');
  73. expect(zero.reasons.length === 0, '0 不应有质量标记');
  74. const empty = importer.cleanValue('', 'order_cnt');
  75. expect(empty.value === null, '空串应为 null');
  76. });
  77. await check('cleanValue: 10..6% → 0.106 + malformed-percent', () => {
  78. const r = importer.cleanValue('10..6%', 'click_rate');
  79. expect(Math.abs(r.value - 0.106) < 1e-9, `实际 ${r.value}`);
  80. expect(r.reasons.includes('malformed-percent'), '应标记 malformed-percent');
  81. });
  82. await check('cleanValue: 12.3 → 0.123 + percent-scaled', () => {
  83. const r = importer.cleanValue(12.3, 'click_rate');
  84. expect(Math.abs(r.value - 0.123) < 1e-9, `实际 ${r.value}`);
  85. expect(r.reasons.includes('percent-scaled'), '应标记 percent-scaled');
  86. });
  87. await check('cleanValue: 未开放 → null + non-numeric', () => {
  88. const r = importer.cleanValue('未开放', 'order_cnt');
  89. expect(r.value === null, 'value 应为 null');
  90. expect(r.reasons.includes('non-numeric'), '应标记 non-numeric');
  91. expect(r.rawText === '未开放', '应保留原文');
  92. });
  93. await check('parseDateColumns: 9.9 + year 2026 → 20260909', () => {
  94. const cols = importer.parseDateColumns(['9.9订单量', '10.7营收'], 2026);
  95. expect(cols.length === 2, `列数 ${cols.length}`);
  96. expect(cols[0].date === '20260909', cols[0].date);
  97. expect(cols[1].date === '20261007', cols[1].date);
  98. });
  99. await check('parseDateColumns: 跨年 12.31 → 1.1 年 +1', () => {
  100. const cols = importer.parseDateColumns(['12.31订单量', '1.1订单量'], 2025);
  101. expect(cols[0].date === '20251231', cols[0].date);
  102. expect(cols[1].date === '20260101', cols[1].date);
  103. });
  104. await check('detect: fixture 识别为 manual-ops', () => {
  105. expect(importer.detectBuffer(fixture) === 'manual-ops', '未识别');
  106. });
  107. const parsed = importer.parseWorkbook(fixture, { year: 2026 });
  108. await check('fixture 解析行数 = 36 店 × 29 天', () => {
  109. expect(parsed.rows.length === 1044, `实际 ${parsed.rows.length}`);
  110. expect(parsed.shops.length === 36, `shops ${parsed.shops.length}`);
  111. expect(parsed.dates.length === 29, `dates ${parsed.dates.length}`);
  112. expect(parsed.dates[0] === '20260909' && parsed.dates[28] === '20261007', '日期范围');
  113. });
  114. await check('fixture 脏值已清洗', () => {
  115. const dirty = parsed.rows.find((r) => (r.raw.click_rate || '').includes('10..6'));
  116. expect(dirty, '找不到 10..6% 的行');
  117. expect(Math.abs(dirty.click_rate - 0.106) < 1e-9, `实际 ${dirty.click_rate}`);
  118. expect(dirty.quality.includes('malformed-percent'), '缺少 malformed-percent');
  119. const scaled = parsed.rows.find((r) => Number(r.raw.click_rate) === 12.3);
  120. expect(scaled && scaled.quality.includes('percent-scaled'), '12.3 未按百分数处理');
  121. const nonNumeric = parsed.rows.find((r) => (r.raw.order_cnt || '') === '未开放');
  122. expect(nonNumeric && nonNumeric.order_cnt === null, '未开放 未转为 null');
  123. });
  124. await check('fixture 无 ID 店铺用 school|shopName 兜底', () => {
  125. const noId = parsed.rows.filter((r) => r.quality.includes('no-shop-id'));
  126. expect(noId.length > 0, '应存在无 ID 店铺');
  127. expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对');
  128. });
  129. await check('fixture 指定学校时覆盖表格里的学校列', () => {
  130. const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' });
  131. expect(forced.rows.length === 1044, `rows ${forced.rows.length}`);
  132. expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖');
  133. expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1');
  134. expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖');
  135. });
  136. await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => {
  137. const cells = [
  138. ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'],
  139. ['测试店', '123', 1, 2, 3, 0.1],
  140. ];
  141. const out = importer.parseManualOps(cells, { year: 2026 });
  142. expect(out.rows.length === 1, `rows ${out.rows.length}`);
  143. expect(out.rows[0].school === '', `school='${out.rows[0].school}'`);
  144. });
  145. await check('fixture 占位行未产生数据', () => {
  146. expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
  147. });
  148. // ---------- 平台导出(全字段明细) ----------
  149. await check('platform: 识别为 platform-export', () => {
  150. expect(importer.detectBuffer(platformFixture) === 'platform-export', '识别失败');
  151. });
  152. const platformParsed = importer.parseAny(platformFixture, { school: '测试校区' });
  153. await check('platform: 10 行 / 10 店 / 124 字段', () => {
  154. expect(platformParsed.kind === 'platform-export', platformParsed.kind);
  155. expect(platformParsed.rows.length === 10, `rows ${platformParsed.rows.length}`);
  156. expect(platformParsed.shops.length === 10, `shops ${platformParsed.shops.length}`);
  157. expect(Object.keys(platformParsed.rows[0].data).length === 124, `fields ${Object.keys(platformParsed.rows[0].data).length}`);
  158. expect(platformParsed.rows[0].date === '20261018', platformParsed.rows[0].date);
  159. expect(platformParsed.rows[0].school === '测试校区', platformParsed.rows[0].school);
  160. });
  161. await check('platform: 日期归一化', () => {
  162. const cells = [
  163. ['日期', '门店编号', '门店名称', '有效订单'],
  164. ['2026/1/9', '1', 'A店', 1],
  165. ['2026.10.18', '2', 'B店', 2],
  166. ];
  167. const r = importer.parsePlatformExport(cells, {});
  168. expect(r.rows[0].date === '20260109', r.rows[0].date);
  169. expect(r.rows[1].date === '20261018', r.rows[1].date);
  170. });
  171. // ---------- 端到端(临时 SQLite) ----------
  172. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-'));
  173. const dbFile = path.join(tmp, 'ops.db');
  174. const configPath = path.join(tmp, 'config.json');
  175. fs.writeFileSync(configPath, JSON.stringify({
  176. aitable: { enabled: false },
  177. storage: { driver: 'sqlite', sqlite: { file: dbFile } },
  178. }));
  179. const port = await pickPort();
  180. const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  181. 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' },
  182. stdio: ['ignore', 'pipe', 'pipe'],
  183. });
  184. let bootLog = '';
  185. child.stdout.on('data', (c) => { bootLog += c; });
  186. child.stderr.on('data', (c) => { bootLog += c; });
  187. const base = `http://127.0.0.1:${port}`;
  188. const upload = (force) => fetch(base + '/api/ops/upload', {
  189. method: 'POST',
  190. headers: { 'Content-Type': 'application/json' },
  191. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: !!force }),
  192. }).then(readJson);
  193. try {
  194. await waitForHealth(port);
  195. await check('E2E 空态 summary', async () => {
  196. const r = await fetch(base + '/api/ops/summary').then(readJson);
  197. expect(r.status === 200, `status ${r.status}`);
  198. expect(r.body.enabled === true, 'enabled 应为 true');
  199. expect(r.body.rowCount === 0, 'rowCount 应为 0');
  200. expect(r.body.driver === 'sqlite', `driver ${r.body.driver}`);
  201. });
  202. let uploadId = '';
  203. await check('E2E 上传成功', async () => {
  204. const r = await upload(false);
  205. expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
  206. uploadId = r.body.uploadId;
  207. expect(/^\d{8}-\d{6}-[a-z0-9]{6}$/.test(uploadId), `uploadId ${uploadId}`);
  208. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  209. expect(r.body.report.shops === 36, `shops ${r.body.report.shops}`);
  210. });
  211. await check('E2E 重复上传 409', async () => {
  212. const r = await upload(false);
  213. expect(r.status === 409, `status ${r.status}`);
  214. expect(r.body.uploadId === uploadId, '应返回已存在的 uploadId');
  215. });
  216. await check('E2E force 覆盖导入', async () => {
  217. const r = await upload(true);
  218. expect(r.status === 200, `status ${r.status}`);
  219. expect(r.body.report.overwritten === 1044, `overwritten ${r.body.report.overwritten}`);
  220. });
  221. await check('E2E summary 有数据', async () => {
  222. const r = await fetch(base + '/api/ops/summary').then(readJson);
  223. expect(r.body.rowCount === 1044, `rowCount ${r.body.rowCount}`);
  224. expect(r.body.schools.length === 3, `schools ${r.body.schools.length}`);
  225. expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage');
  226. });
  227. await check('E2E 矩阵查询 + 字段过滤', async () => {
  228. const r = await fetch(base + '/api/ops/rows?fields=order_cnt').then(readJson);
  229. expect(r.status === 200, `status ${r.status}`);
  230. expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`);
  231. expect(r.body.fields.length === 1 && r.body.fields[0].key === 'order_cnt', 'fields 过滤失效');
  232. expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`);
  233. const shop = r.body.groups[0].shops[0];
  234. expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失');
  235. });
  236. await check('E2E 学校过滤', async () => {
  237. const r = await fetch(base + '/api/ops/rows?schools=' + encodeURIComponent('样例学校A')).then(readJson);
  238. expect(r.body.groups.length === 1, `groups ${r.body.groups.length}`);
  239. expect(r.body.groups[0].school === '样例学校A', r.body.groups[0].school);
  240. });
  241. await check('E2E 下载原件字节一致', async () => {
  242. const res = await fetch(`${base}/api/ops/uploads/${uploadId}/file`);
  243. expect(res.status === 200, `status ${res.status}`);
  244. const buf = Buffer.from(await res.arrayBuffer());
  245. const sha = crypto.createHash('sha256').update(buf).digest('hex');
  246. expect(sha === fixtureSha, 'sha256 不一致');
  247. });
  248. await check('E2E 非法 uploadId 400', async () => {
  249. const res = await fetch(`${base}/api/ops/uploads/${encodeURIComponent('../etc')}/file`);
  250. expect(res.status === 400, `status ${res.status}`);
  251. });
  252. await check('E2E 不存在的 uploadId 404', async () => {
  253. const res = await fetch(`${base}/api/ops/uploads/20000101-000000-abcdef/file`);
  254. expect(res.status === 404, `status ${res.status}`);
  255. });
  256. await check('E2E 重新解析', async () => {
  257. const r = await fetch(`${base}/api/ops/uploads/${uploadId}/reparse`, { method: 'POST' }).then(readJson);
  258. expect(r.status === 200, `status ${r.status}`);
  259. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  260. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  261. expect(sum.body.rowCount === 1044, `rowCount ${sum.body.rowCount}`);
  262. });
  263. await check('E2E 导出 CSV', async () => {
  264. const res = await fetch(base + '/api/ops/export?fields=order_cnt');
  265. expect(res.status === 200, `status ${res.status}`);
  266. const text = await res.text();
  267. expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
  268. expect(text.includes('样例学校A'), 'CSV 缺少学校列');
  269. expect(text.split('\n')[0].includes('日期') && text.split('\n')[0].includes('订单量'), 'CSV 缺少日期/订单量列');
  270. expect(text.trim().split('\n').length === 1045, `行数 ${text.trim().split('\n').length}`);
  271. });
  272. await check('E2E 非 xlsx 拒绝', async () => {
  273. const res = await fetch(base + '/api/ops/upload', {
  274. method: 'POST',
  275. headers: { 'Content-Type': 'application/json' },
  276. body: JSON.stringify({ fileName: 'a.txt', contentBase64: Buffer.from('x').toString('base64'), year: 2026 }),
  277. });
  278. expect(res.status === 400, `status ${res.status}`);
  279. });
  280. await check('E2E 坏文件 400 且不落库', async () => {
  281. const before = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  282. const res = await fetch(base + '/api/ops/upload', {
  283. method: 'POST',
  284. headers: { 'Content-Type': 'application/json' },
  285. body: JSON.stringify({ fileName: 'bad.xlsx', contentBase64: Buffer.from('not a zip file at all').toString('base64'), year: 2026 }),
  286. });
  287. expect(res.status === 400, `status ${res.status}`);
  288. const after = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  289. expect(after === before, '坏文件不应产生上传记录');
  290. });
  291. await check('E2E 上传时指定学校 → 覆盖并写入', async () => {
  292. const res = await fetch(base + '/api/ops/upload', {
  293. method: 'POST',
  294. headers: { 'Content-Type': 'application/json' },
  295. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }),
  296. }).then(readJson);
  297. expect(res.status === 200, `status ${res.status}`);
  298. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  299. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  300. });
  301. await check('E2E 重新解析保留指定学校', async () => {
  302. const list = await fetch(base + '/api/ops/uploads').then(readJson);
  303. const id = list.body.uploads[0].id;
  304. const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson);
  305. expect(r.status === 200, `status ${r.status}`);
  306. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  307. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  308. });
  309. const uploadPlatform = (buf, force, school) => fetch(base + '/api/ops/upload', {
  310. method: 'POST',
  311. headers: { 'Content-Type': 'application/json' },
  312. body: JSON.stringify({
  313. fileName: '平台导出.xlsx',
  314. contentBase64: buf.toString('base64'),
  315. year: 2026,
  316. force: !!force,
  317. ...(school === undefined ? {} : { school }),
  318. }),
  319. }).then(readJson);
  320. await check('E2E 平台导出:未选学校 → 400 提示补选', async () => {
  321. const r = await uploadPlatform(platformFixture);
  322. expect(r.status === 400 && r.body.code === 'OPS_SCHOOL_REQUIRED', `status ${r.status} ${JSON.stringify(r.body)}`);
  323. });
  324. await check('E2E 平台导出:首次导入 10 行', async () => {
  325. const r = await uploadPlatform(platformFixture, false, '测试校区');
  326. expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
  327. expect(r.body.report.kind === 'platform-export', r.body.report.kind);
  328. expect(r.body.report.newRows === 10, `newRows ${r.body.report.newRows}`);
  329. expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
  330. });
  331. await check('E2E 平台导出:同文件强制重传 → 全部已存在', async () => {
  332. const r = await uploadPlatform(platformFixture, true, '测试校区');
  333. expect(r.status === 200, `status ${r.status}`);
  334. expect(r.body.report.newRows === 0, `newRows ${r.body.report.newRows}`);
  335. expect(r.body.report.identicalRows === 10, `identical ${r.body.report.identicalRows}`);
  336. expect(r.body.duplicateData === true, '应标记重复');
  337. });
  338. await check('E2E 平台导出:内容变化 → 冲突 1 行', async () => {
  339. const r = await uploadPlatform(platformVariant, false, '测试校区');
  340. expect(r.status === 200, `status ${r.status}`);
  341. expect(r.body.report.conflictRows === 1, `conflict ${r.body.report.conflictRows}`);
  342. expect(r.body.report.identicalRows === 9, `identical ${r.body.report.identicalRows}`);
  343. expect(r.body.report.writtenRows === 0, `written ${r.body.report.writtenRows}`);
  344. });
  345. await check('E2E 平台导出:强制覆盖冲突', async () => {
  346. const r = await uploadPlatform(platformVariant, true, '测试校区');
  347. expect(r.status === 200, `status ${r.status}`);
  348. expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
  349. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  350. expect(sum.body.platformRowCount === 10, `platformRows ${sum.body.platformRowCount}`);
  351. });
  352. await check('E2E 字段目录包含平台列', async () => {
  353. const r = await fetch(base + '/api/ops/fields').then(readJson);
  354. expect(r.status === 200, `status ${r.status}`);
  355. expect(r.body.defaults.join(',') === 'order_cnt,revenue,exposure,click_rate', '默认字段不对');
  356. expect(r.body.fields.length > 4, `fields ${r.body.fields.length}`);
  357. expect(r.body.fields.some((f) => f.key === 'p:营业额' && f.type === 'money'), '缺少营业额字段');
  358. });
  359. await check('E2E 店铺筛选', async () => {
  360. const all = await fetch(base + '/api/ops/rows').then(readJson);
  361. const shop = all.body.groups[0].shops[0];
  362. const r = await fetch(base + '/api/ops/rows?shops=' + encodeURIComponent(shop.shopId || shop.key)).then(readJson);
  363. expect(r.status === 200, `status ${r.status}`);
  364. const shops = r.body.groups.flatMap((g) => g.shops);
  365. expect(shops.length === 1, `shops ${shops.length}`);
  366. });
  367. await check('E2E 驾驶舱:KPI 与图表数据', async () => {
  368. const r = await fetch(base + '/api/ops/dashboard').then(readJson);
  369. expect(r.status === 200, `status ${r.status}`);
  370. const d = r.body;
  371. expect(d.kpis && d.kpis.revenue.value > 0, `revenue ${d.kpis && d.kpis.revenue.value}`);
  372. expect(d.trend.length >= 1, `trend ${d.trend.length}`);
  373. expect(d.structure.length === d.trend.length, '结构图与趋势日期数不一致');
  374. expect(d.ranking.length <= 10 && d.ranking.length > 0, `ranking ${d.ranking.length}`);
  375. expect(d.funnel.exposure >= 0 && d.funnel.visit >= 0, '漏斗数据异常');
  376. expect(Array.isArray(d.zeroOrder), '缺少零单诊断');
  377. expect(d.zeroOrder.some((z) => z.reason === 'not-open'), '未识别出未开业门店');
  378. });
  379. await check('E2E 驾驶舱:店铺筛选后金额变化', async () => {
  380. const all = await fetch(base + '/api/ops/dashboard').then(readJson);
  381. const shopId = all.body.ranking[0].shopId;
  382. const one = await fetch(base + '/api/ops/dashboard?shops=' + encodeURIComponent(shopId)).then(readJson);
  383. expect(one.status === 200, `status ${one.status}`);
  384. expect(one.body.kpis.revenue.value > 0, '单店营收为 0');
  385. expect(one.body.kpis.revenue.value < all.body.kpis.revenue.value, '单店营收不应大于全部');
  386. expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`);
  387. });
  388. await check('E2E 经营解读:无模型时退回规则版摘要', async () => {
  389. const r = await fetch(base + '/api/ops/insights', {
  390. method: 'POST',
  391. headers: { 'Content-Type': 'application/json' },
  392. body: JSON.stringify({}),
  393. }).then(readJson);
  394. expect(r.status === 200, `status ${r.status}`);
  395. expect(r.body.enabled === true, JSON.stringify(r.body).slice(0, 120));
  396. expect(r.body.source === 'rules', `source ${r.body.source}`);
  397. expect(Array.isArray(r.body.points) && r.body.points.length > 0, 'points 为空');
  398. expect(!!r.body.aiError, '应提示模型未配置');
  399. });
  400. await check('E2E 报告中心:保存 / 列表 / 查看 / 删除', async () => {
  401. const saved = await fetch(base + '/api/ops/reports', {
  402. method: 'POST',
  403. headers: { 'Content-Type': 'application/json' },
  404. body: JSON.stringify({}),
  405. }).then(readJson);
  406. 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)}`);
  407. const list = await fetch(base + '/api/ops/reports').then(readJson);
  408. expect(list.status === 200 && list.body.reports.some((r) => r.id === saved.body.id), '列表缺少刚保存的报告');
  409. const one = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
  410. expect(one.status === 200 && one.body.report && one.body.report.meta, '读取报告失败');
  411. const del = await fetch(base + '/api/ops/reports/' + saved.body.id, { method: 'DELETE' }).then(readJson);
  412. expect(del.status === 200 && del.body.removed === 1, `delete ${del.status}`);
  413. const gone = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
  414. expect(gone.status === 404, `expect 404, got ${gone.status}`);
  415. });
  416. await check('E2E 导入记录只含元数据', async () => {
  417. const r = await fetch(base + '/api/ops/uploads').then(readJson);
  418. expect(r.body.uploads.length >= 1, '记录为空');
  419. expect(!Object.prototype.hasOwnProperty.call(r.body.uploads[0], 'content'), '不应返回 content');
  420. });
  421. } finally {
  422. try { child.kill('SIGKILL'); } catch { /* ignore */ }
  423. }
  424. // ---------- 存储不可用时的降级 ----------
  425. const badTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-bad-'));
  426. const badConfig = path.join(badTmp, 'config.json');
  427. fs.writeFileSync(badConfig, JSON.stringify({
  428. aitable: { enabled: false },
  429. storage: { driver: 'sqlite', sqlite: { file: '/dev/null/ops.db' } },
  430. }));
  431. const badPort = await pickPort();
  432. const badChild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  433. 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' },
  434. stdio: ['ignore', 'pipe', 'pipe'],
  435. });
  436. const badBase = `http://127.0.0.1:${badPort}`;
  437. try {
  438. await waitForHealth(badPort);
  439. await check('E2E 存储不可用时 summary 降级为 enabled:false', async () => {
  440. const r = await fetch(badBase + '/api/ops/summary').then(readJson);
  441. expect(r.status === 200, `status ${r.status}`);
  442. expect(r.body.enabled === false, 'enabled 应为 false');
  443. expect(!!r.body.reason, '缺少 reason');
  444. });
  445. await check('E2E 存储不可用时其它接口 503', async () => {
  446. const r = await fetch(badBase + '/api/ops/rows').then(readJson);
  447. expect(r.status === 503, `status ${r.status}`);
  448. });
  449. await check('E2E 存储不可用时主服务仍正常', async () => {
  450. const r = await fetch(badBase + '/healthz');
  451. expect(r.status === 200, `healthz ${r.status}`);
  452. });
  453. } finally {
  454. try { badChild.kill('SIGKILL'); } catch { /* ignore */ }
  455. try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
  456. try { fs.rmSync(badTmp, { recursive: true, force: true }); } catch { /* ignore */ }
  457. }
  458. // ---------- 汇总 ----------
  459. const failed = results.filter((r) => !r.ok);
  460. console.log('\n========== 店铺运营测试结果 ==========');
  461. for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ' ← ' + r.error}`);
  462. console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
  463. if (failed.length) {
  464. if (bootLog) console.log('\n服务启动日志(末尾 800 字):\n' + bootLog.slice(-800));
  465. process.exit(1);
  466. }
  467. }
  468. main().catch((e) => {
  469. console.error('测试脚本自身异常:', e);
  470. process.exit(1);
  471. });