Files
juzi/taobao/test/admin-channels.test.cjs
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

128 lines
9.2 KiB
JavaScript

'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), net = require('node:net');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const root = path.resolve(__dirname, '../..');
async function freePort() {
const server = net.createServer(); server.listen(0, '127.0.0.1'); await once(server, 'listening');
const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port;
}
function seed(dir, channel, extra = false) {
fs.mkdirSync(path.join(dir, 'photos', 'RCOLLIDE'), { recursive: true });
fs.mkdirSync(path.join(dir, 'results'), { recursive: true });
fs.mkdirSync(path.join(dir, 'accounts', 'reports', 'owner'), { recursive: true });
const row = { record_id: 'RCOLLIDE', created_at: '2026-11-02T01:00:00Z',
updated_at: '2026-11-02T01:00:00Z', owner_id: 'owner', real_age: channel === 'h5' ? 24 : 41,
detect_status: '失败', fail_code: channel + '_FAIL', img_front: 'data/photos/RCOLLIDE/front.jpg', raw_file: 'raw.json' };
if (channel === 'h5') row.presentation = { marker: channel };
// Old H5 rows have no channel; a Taobao source can also contain older rows.
fs.writeFileSync(path.join(dir, 'records.jsonl'), JSON.stringify(row) + '\n' +
(extra ? JSON.stringify({ ...row, record_id: 'RNEW', channel, created_at: '2026-11-02T02:00:00Z', detect_status: '检测中' }) + '\n' : ''));
fs.writeFileSync(path.join(dir, 'photos', 'RCOLLIDE', 'front.jpg'), channel + '-photo');
fs.writeFileSync(path.join(dir, 'results', 'raw.json'), JSON.stringify({ marker: channel, result: { face_maps: { sample: 'base64-' + channel } } }));
fs.writeFileSync(path.join(dir, 'accounts', 'reports', 'owner', 'RCOLLIDE.json'), JSON.stringify({ presentation: { marker: channel } }));
return row;
}
function files(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory()
? files(path.join(dir, e.name)) : [{ file: path.join(dir, e.name), content: fs.readFileSync(path.join(dir, e.name)).toString('base64') }]);
}
test('admin aggregates separate stores with channel filters, collision-safe details/media/export and no remote writes', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-admin-channels-'));
const local = path.join(dir, 'h5'), remote = path.join(dir, 'taobao');
seed(local, 'h5'); seed(remote, 'taobao', true);
const beforeRemote = files(remote), port = await freePort(), base = 'http://127.0.0.1:' + port;
let stderr = '';
const child = spawn(process.execPath, ['server.js'], { cwd: path.join(root, 'h5'),
env: { ...process.env, HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'test', JUZI_ENTRY_MODE: 'state',
JUZI_DATA_ROOT: local, JUZI_ADMIN_TAOBAO_DATA_ROOT: remote, SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture',
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.on('data', value => { stderr += value; });
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('server startup timed out: ' + stderr)), 15000);
child.stdout.on('data', value => { if (String(value).includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code + ': ' + stderr)); });
});
const json = async route => { const response = await fetch(base + route); assert.equal(response.status, 200, route); return response.json(); };
const first = await json('/api/admin/records?page_size=2');
assert.equal(first.total, 3); assert.equal(first.items[0].record_id, 'RNEW');
const second = await json('/api/admin/records?page_size=2&page=2');
assert.equal(new Set(first.items.concat(second.items).map(r => r.admin_key)).size, 3);
const tb = await json('/api/admin/records?channel=taobao');
assert.equal(tb.total, 2); assert(tb.items.every(r => r.source === 'taobao' && r.readonly));
assert.equal((await json('/api/admin/records?channel=h5')).total, 1);
assert.equal((await json('/api/admin/stats')).total, 3);
const stats = await json('/api/admin/stats?channel=taobao');
assert.equal(stats.total, 2); assert.equal(stats.fail_total, 1); assert.equal(stats.by_channel.h5.total, 1);
for (const source of ['local', 'taobao']) {
const expected = source === 'local' ? 'h5' : 'taobao';
const detail = await json('/api/admin/records/RCOLLIDE?source=' + source);
assert.equal(detail.raw_json.marker, expected); assert.equal(detail.record.presentation.marker, expected);
assert.equal(detail.readonly, source === 'taobao');
assert.match(detail.raw_json.result.face_maps.sample, /base64 图层已省略/);
assert.equal(await (await fetch(base + detail.photos.front)).text(), expected + '-photo');
assert.equal((await json('/api/admin/records/RCOLLIDE/raw?source=' + source)).marker, expected);
}
const csv = await (await fetch(base + '/api/admin/export?channel=taobao&detect_status=失败&age_min=40')).text();
const lines = csv.trim().split('\n');
assert.equal(lines.length, 2); assert.match(lines[0], /渠道,数据来源,记录唯一键/); assert.match(lines[1], /taobao,taobao,taobao:RCOLLIDE/);
assert.equal(lines[0].replace(/^\uFEFF/, '').split(',')[2], '年龄');
assert.equal((await fetch(base + '/api/admin/records/RCOLLIDE/retry?source=taobao', { method: 'POST' })).status, 409);
assert.equal((await fetch(base + '/api/admin/records/RCOLLIDE?source=../../outside')).status, 404);
assert.equal((await fetch(base + '/api/admin/stats?channel=unknown')).status, 400);
assert.equal((await fetch(base + '/api/admin/photos/RMISSING/front?source=taobao')).status, 404);
assert.deepEqual(files(remote), beforeRemote, 'aggregation never modifies the external store');
assert(!JSON.stringify(first.sources).includes(dir), 'physical paths are never exposed to the browser');
if (process.env.JUZI_ADMIN_BROWSER_QA === '1') {
const { chromium } = require('playwright');
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }), errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base + '/admin.html');
await page.waitForFunction(() => document.querySelectorAll('#tbl-records tbody tr').length === 3);
await page.selectOption('#f-channel', 'taobao');
await page.waitForFunction(() => document.querySelectorAll('#tbl-records tbody tr').length === 2);
assert.match(await page.locator('#tbl-records').innerText(), /淘宝小程序/);
await page.locator('#tbl-records tbody tr').filter({ hasText: 'RCOLLIDE' }).click();
await page.waitForFunction(() => document.querySelector('#d-body').textContent.includes('只读汇总'));
assert(await page.locator('#btn-retry').isDisabled());
assert.match(await page.locator('.d-photo img').getAttribute('src'), /source=taobao$/);
await page.click('#btn-close');
await page.click('[data-tab="stats"]');
await page.waitForFunction(() => document.querySelector('#channel-stats').textContent.includes('淘宝小程序'));
assert.match(await page.locator('#stat-cards').innerText(), /累计检测\s*2/);
const screenshots = path.join(root, 'temp', 'taobao-adaptation'); fs.mkdirSync(screenshots, { recursive: true });
await page.screenshot({ path: path.join(screenshots, 'admin-channels.png'), fullPage: true });
await page.click('[data-tab="fails"]');
await page.waitForFunction(() => document.querySelectorAll('#tbl-fails tbody tr').length === 1);
assert.equal(await page.locator('#tbl-fails button').count(), 0);
assert.deepEqual(errors, []);
} finally { await browser.close(); }
}
} finally {
if (child.exitCode === null) { const exited = once(child, 'exit'); child.kill(); await exited; }
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('admin sources reject nested/public roots and cannot read arbitrary filesystem paths', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-admin-source-'));
try {
const local = path.join(dir, 'local'), remote = path.join(dir, 'remote');
fs.mkdirSync(local); fs.mkdirSync(remote);
const factory = require('../../h5/lib/admin-sources');
assert.throws(() => factory({ localRoot: local, taobaoRoot: local }), /separate/);
assert.throws(() => factory({ localRoot: local, taobaoRoot: remote, publicRoot: dir }), /outside/);
const sources = factory({ localRoot: local, taobaoRoot: remote });
assert.equal(sources.file('taobao', '..', 'secret'), null);
assert.equal(sources.file('anything', 'records.jsonl'), null);
assert.equal(sources.find('RCOLLIDE', '../../local'), null);
fs.writeFileSync(path.join(local, 'private.json'), 'private');
fs.symlinkSync(local, path.join(remote, 'escape'), process.platform === 'win32' ? 'junction' : 'dir');
assert.equal(sources.file('taobao', 'escape', 'private.json'), null, 'symlinks cannot escape the configured source');
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});