test-ops-import.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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 importer = require(path.join(ROOT, 'ops-import.js'));
  15. const results = [];
  16. function expect(cond, msg) {
  17. if (!cond) throw new Error(msg);
  18. }
  19. async function check(name, fn) {
  20. try {
  21. await fn();
  22. results.push({ name, ok: true });
  23. } catch (e) {
  24. results.push({ name, ok: false, error: e.message || String(e) });
  25. }
  26. }
  27. function pickPort() {
  28. return new Promise((resolve, reject) => {
  29. const srv = net.createServer();
  30. srv.unref();
  31. srv.on('error', reject);
  32. srv.listen(0, '127.0.0.1', () => {
  33. const port = srv.address().port;
  34. srv.close(() => resolve(port));
  35. });
  36. });
  37. }
  38. async function waitForHealth(port, timeoutMs = 20000) {
  39. const start = Date.now();
  40. for (;;) {
  41. try {
  42. const res = await fetch(`http://127.0.0.1:${port}/healthz`);
  43. if (res.ok) return;
  44. } catch { /* 还没起来 */ }
  45. if (Date.now() - start > timeoutMs) throw new Error('服务未在超时内启动');
  46. await new Promise((r) => setTimeout(r, 300));
  47. }
  48. }
  49. async function readJson(res) {
  50. const text = await res.text();
  51. try {
  52. return { status: res.status, body: JSON.parse(text) };
  53. } catch {
  54. return { status: res.status, body: { raw: text } };
  55. }
  56. }
  57. async function main() {
  58. const fixture = fs.readFileSync(FIXTURE);
  59. const fixtureSha = crypto.createHash('sha256').update(fixture).digest('hex');
  60. // ---------- 解析器单测 ----------
  61. await check('cleanValue: 空值 → null + missing', () => {
  62. const r = importer.cleanValue(null, 'order_cnt');
  63. expect(r.value === null, 'value 应为 null');
  64. expect(r.reasons.includes('missing'), '应标记 missing');
  65. });
  66. await check('cleanValue: 0 与 null 可区分', () => {
  67. const zero = importer.cleanValue(0, 'order_cnt');
  68. expect(zero.value === 0, '0 应保留为 0');
  69. expect(zero.reasons.length === 0, '0 不应有质量标记');
  70. const empty = importer.cleanValue('', 'order_cnt');
  71. expect(empty.value === null, '空串应为 null');
  72. });
  73. await check('cleanValue: 10..6% → 0.106 + malformed-percent', () => {
  74. const r = importer.cleanValue('10..6%', 'click_rate');
  75. expect(Math.abs(r.value - 0.106) < 1e-9, `实际 ${r.value}`);
  76. expect(r.reasons.includes('malformed-percent'), '应标记 malformed-percent');
  77. });
  78. await check('cleanValue: 12.3 → 0.123 + percent-scaled', () => {
  79. const r = importer.cleanValue(12.3, 'click_rate');
  80. expect(Math.abs(r.value - 0.123) < 1e-9, `实际 ${r.value}`);
  81. expect(r.reasons.includes('percent-scaled'), '应标记 percent-scaled');
  82. });
  83. await check('cleanValue: 未开放 → null + non-numeric', () => {
  84. const r = importer.cleanValue('未开放', 'order_cnt');
  85. expect(r.value === null, 'value 应为 null');
  86. expect(r.reasons.includes('non-numeric'), '应标记 non-numeric');
  87. expect(r.rawText === '未开放', '应保留原文');
  88. });
  89. await check('parseDateColumns: 9.9 + year 2026 → 20260909', () => {
  90. const cols = importer.parseDateColumns(['9.9订单量', '10.7营收'], 2026);
  91. expect(cols.length === 2, `列数 ${cols.length}`);
  92. expect(cols[0].date === '20260909', cols[0].date);
  93. expect(cols[1].date === '20261007', cols[1].date);
  94. });
  95. await check('parseDateColumns: 跨年 12.31 → 1.1 年 +1', () => {
  96. const cols = importer.parseDateColumns(['12.31订单量', '1.1订单量'], 2025);
  97. expect(cols[0].date === '20251231', cols[0].date);
  98. expect(cols[1].date === '20260101', cols[1].date);
  99. });
  100. await check('detect: fixture 识别为 manual-ops', () => {
  101. expect(importer.detectBuffer(fixture) === 'manual-ops', '未识别');
  102. });
  103. const parsed = importer.parseWorkbook(fixture, { year: 2026 });
  104. await check('fixture 解析行数 = 36 店 × 29 天', () => {
  105. expect(parsed.rows.length === 1044, `实际 ${parsed.rows.length}`);
  106. expect(parsed.shops.length === 36, `shops ${parsed.shops.length}`);
  107. expect(parsed.dates.length === 29, `dates ${parsed.dates.length}`);
  108. expect(parsed.dates[0] === '20260909' && parsed.dates[28] === '20261007', '日期范围');
  109. });
  110. await check('fixture 脏值已清洗', () => {
  111. const dirty = parsed.rows.find((r) => (r.raw.click_rate || '').includes('10..6'));
  112. expect(dirty, '找不到 10..6% 的行');
  113. expect(Math.abs(dirty.click_rate - 0.106) < 1e-9, `实际 ${dirty.click_rate}`);
  114. expect(dirty.quality.includes('malformed-percent'), '缺少 malformed-percent');
  115. const scaled = parsed.rows.find((r) => Number(r.raw.click_rate) === 12.3);
  116. expect(scaled && scaled.quality.includes('percent-scaled'), '12.3 未按百分数处理');
  117. const nonNumeric = parsed.rows.find((r) => (r.raw.order_cnt || '') === '未开放');
  118. expect(nonNumeric && nonNumeric.order_cnt === null, '未开放 未转为 null');
  119. });
  120. await check('fixture 无 ID 店铺用 school|shopName 兜底', () => {
  121. const noId = parsed.rows.filter((r) => r.quality.includes('no-shop-id'));
  122. expect(noId.length > 0, '应存在无 ID 店铺');
  123. expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对');
  124. });
  125. await check('fixture 指定学校时覆盖表格里的学校列', () => {
  126. const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' });
  127. expect(forced.rows.length === 1044, `rows ${forced.rows.length}`);
  128. expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖');
  129. expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1');
  130. expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖');
  131. });
  132. await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => {
  133. const cells = [
  134. ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'],
  135. ['测试店', '123', 1, 2, 3, 0.1],
  136. ];
  137. const out = importer.parseManualOps(cells, { year: 2026 });
  138. expect(out.rows.length === 1, `rows ${out.rows.length}`);
  139. expect(out.rows[0].school === '', `school='${out.rows[0].school}'`);
  140. });
  141. await check('fixture 占位行未产生数据', () => {
  142. expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
  143. });
  144. // ---------- 端到端(临时 SQLite) ----------
  145. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-'));
  146. const dbFile = path.join(tmp, 'ops.db');
  147. const configPath = path.join(tmp, 'config.json');
  148. fs.writeFileSync(configPath, JSON.stringify({
  149. aitable: { enabled: false },
  150. storage: { driver: 'sqlite', sqlite: { file: dbFile } },
  151. }));
  152. const port = await pickPort();
  153. const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  154. 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' },
  155. stdio: ['ignore', 'pipe', 'pipe'],
  156. });
  157. let bootLog = '';
  158. child.stdout.on('data', (c) => { bootLog += c; });
  159. child.stderr.on('data', (c) => { bootLog += c; });
  160. const base = `http://127.0.0.1:${port}`;
  161. const upload = (force) => fetch(base + '/api/ops/upload', {
  162. method: 'POST',
  163. headers: { 'Content-Type': 'application/json' },
  164. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: !!force }),
  165. }).then(readJson);
  166. try {
  167. await waitForHealth(port);
  168. await check('E2E 空态 summary', async () => {
  169. const r = await fetch(base + '/api/ops/summary').then(readJson);
  170. expect(r.status === 200, `status ${r.status}`);
  171. expect(r.body.enabled === true, 'enabled 应为 true');
  172. expect(r.body.rowCount === 0, 'rowCount 应为 0');
  173. expect(r.body.driver === 'sqlite', `driver ${r.body.driver}`);
  174. });
  175. let uploadId = '';
  176. await check('E2E 上传成功', async () => {
  177. const r = await upload(false);
  178. expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
  179. uploadId = r.body.uploadId;
  180. expect(/^\d{8}-\d{6}-[a-z0-9]{6}$/.test(uploadId), `uploadId ${uploadId}`);
  181. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  182. expect(r.body.report.shops === 36, `shops ${r.body.report.shops}`);
  183. });
  184. await check('E2E 重复上传 409', async () => {
  185. const r = await upload(false);
  186. expect(r.status === 409, `status ${r.status}`);
  187. expect(r.body.uploadId === uploadId, '应返回已存在的 uploadId');
  188. });
  189. await check('E2E force 覆盖导入', async () => {
  190. const r = await upload(true);
  191. expect(r.status === 200, `status ${r.status}`);
  192. expect(r.body.report.overwritten === 1044, `overwritten ${r.body.report.overwritten}`);
  193. });
  194. await check('E2E summary 有数据', async () => {
  195. const r = await fetch(base + '/api/ops/summary').then(readJson);
  196. expect(r.body.rowCount === 1044, `rowCount ${r.body.rowCount}`);
  197. expect(r.body.schools.length === 3, `schools ${r.body.schools.length}`);
  198. expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage');
  199. });
  200. await check('E2E 矩阵查询 + 指标过滤', async () => {
  201. const r = await fetch(base + '/api/ops/rows?metrics=order_cnt').then(readJson);
  202. expect(r.status === 200, `status ${r.status}`);
  203. expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`);
  204. expect(r.body.metrics.length === 1 && r.body.metrics[0] === 'order_cnt', 'metrics 过滤失效');
  205. expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`);
  206. const shop = r.body.groups[0].shops[0];
  207. expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失');
  208. });
  209. await check('E2E 学校过滤', async () => {
  210. const r = await fetch(base + '/api/ops/rows?schools=' + encodeURIComponent('样例学校A')).then(readJson);
  211. expect(r.body.groups.length === 1, `groups ${r.body.groups.length}`);
  212. expect(r.body.groups[0].school === '样例学校A', r.body.groups[0].school);
  213. });
  214. await check('E2E 下载原件字节一致', async () => {
  215. const res = await fetch(`${base}/api/ops/uploads/${uploadId}/file`);
  216. expect(res.status === 200, `status ${res.status}`);
  217. const buf = Buffer.from(await res.arrayBuffer());
  218. const sha = crypto.createHash('sha256').update(buf).digest('hex');
  219. expect(sha === fixtureSha, 'sha256 不一致');
  220. });
  221. await check('E2E 非法 uploadId 400', async () => {
  222. const res = await fetch(`${base}/api/ops/uploads/${encodeURIComponent('../etc')}/file`);
  223. expect(res.status === 400, `status ${res.status}`);
  224. });
  225. await check('E2E 不存在的 uploadId 404', async () => {
  226. const res = await fetch(`${base}/api/ops/uploads/20000101-000000-abcdef/file`);
  227. expect(res.status === 404, `status ${res.status}`);
  228. });
  229. await check('E2E 重新解析', async () => {
  230. const r = await fetch(`${base}/api/ops/uploads/${uploadId}/reparse`, { method: 'POST' }).then(readJson);
  231. expect(r.status === 200, `status ${r.status}`);
  232. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  233. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  234. expect(sum.body.rowCount === 1044, `rowCount ${sum.body.rowCount}`);
  235. });
  236. await check('E2E 导出 CSV', async () => {
  237. const res = await fetch(base + '/api/ops/export?metrics=order_cnt');
  238. expect(res.status === 200, `status ${res.status}`);
  239. const text = await res.text();
  240. expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
  241. expect(text.includes('样例学校A'), 'CSV 缺少学校列');
  242. expect(text.trim().split('\n').length === 37, `行数 ${text.trim().split('\n').length}`);
  243. });
  244. await check('E2E 非 xlsx 拒绝', async () => {
  245. const res = await fetch(base + '/api/ops/upload', {
  246. method: 'POST',
  247. headers: { 'Content-Type': 'application/json' },
  248. body: JSON.stringify({ fileName: 'a.txt', contentBase64: Buffer.from('x').toString('base64'), year: 2026 }),
  249. });
  250. expect(res.status === 400, `status ${res.status}`);
  251. });
  252. await check('E2E 坏文件 400 且不落库', async () => {
  253. const before = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  254. const res = await fetch(base + '/api/ops/upload', {
  255. method: 'POST',
  256. headers: { 'Content-Type': 'application/json' },
  257. body: JSON.stringify({ fileName: 'bad.xlsx', contentBase64: Buffer.from('not a zip file at all').toString('base64'), year: 2026 }),
  258. });
  259. expect(res.status === 400, `status ${res.status}`);
  260. const after = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  261. expect(after === before, '坏文件不应产生上传记录');
  262. });
  263. await check('E2E 上传时指定学校 → 覆盖并写入', async () => {
  264. const res = await fetch(base + '/api/ops/upload', {
  265. method: 'POST',
  266. headers: { 'Content-Type': 'application/json' },
  267. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }),
  268. }).then(readJson);
  269. expect(res.status === 200, `status ${res.status}`);
  270. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  271. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  272. });
  273. await check('E2E 重新解析保留指定学校', async () => {
  274. const list = await fetch(base + '/api/ops/uploads').then(readJson);
  275. const id = list.body.uploads[0].id;
  276. const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson);
  277. expect(r.status === 200, `status ${r.status}`);
  278. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  279. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  280. });
  281. await check('E2E 导入记录只含元数据', async () => {
  282. const r = await fetch(base + '/api/ops/uploads').then(readJson);
  283. expect(r.body.uploads.length >= 1, '记录为空');
  284. expect(!Object.prototype.hasOwnProperty.call(r.body.uploads[0], 'content'), '不应返回 content');
  285. });
  286. } finally {
  287. try { child.kill('SIGKILL'); } catch { /* ignore */ }
  288. }
  289. // ---------- 存储不可用时的降级 ----------
  290. const badTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-bad-'));
  291. const badConfig = path.join(badTmp, 'config.json');
  292. fs.writeFileSync(badConfig, JSON.stringify({
  293. aitable: { enabled: false },
  294. storage: { driver: 'sqlite', sqlite: { file: '/dev/null/ops.db' } },
  295. }));
  296. const badPort = await pickPort();
  297. const badChild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  298. 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' },
  299. stdio: ['ignore', 'pipe', 'pipe'],
  300. });
  301. const badBase = `http://127.0.0.1:${badPort}`;
  302. try {
  303. await waitForHealth(badPort);
  304. await check('E2E 存储不可用时 summary 降级为 enabled:false', async () => {
  305. const r = await fetch(badBase + '/api/ops/summary').then(readJson);
  306. expect(r.status === 200, `status ${r.status}`);
  307. expect(r.body.enabled === false, 'enabled 应为 false');
  308. expect(!!r.body.reason, '缺少 reason');
  309. });
  310. await check('E2E 存储不可用时其它接口 503', async () => {
  311. const r = await fetch(badBase + '/api/ops/rows').then(readJson);
  312. expect(r.status === 503, `status ${r.status}`);
  313. });
  314. await check('E2E 存储不可用时主服务仍正常', async () => {
  315. const r = await fetch(badBase + '/healthz');
  316. expect(r.status === 200, `healthz ${r.status}`);
  317. });
  318. } finally {
  319. try { badChild.kill('SIGKILL'); } catch { /* ignore */ }
  320. try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
  321. try { fs.rmSync(badTmp, { recursive: true, force: true }); } catch { /* ignore */ }
  322. }
  323. // ---------- 汇总 ----------
  324. const failed = results.filter((r) => !r.ok);
  325. console.log('\n========== 店铺运营测试结果 ==========');
  326. for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ' ← ' + r.error}`);
  327. console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
  328. if (failed.length) {
  329. if (bootLog) console.log('\n服务启动日志(末尾 800 字):\n' + bootLog.slice(-800));
  330. process.exit(1);
  331. }
  332. }
  333. main().catch((e) => {
  334. console.error('测试脚本自身异常:', e);
  335. process.exit(1);
  336. });