Files
juzi/taobao/test/backend.test.cjs
T
xsl 9eec4369da feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
2026-10-19 20:14:31 +08:00

161 lines
12 KiB
JavaScript

'use strict';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
const { spawn } = require('node:child_process'), { once } = require('node:events');
const root = path.resolve(__dirname, '../..'), auth = require('../../h5/lib/taobao-auth');
const env = { TAOBAO_APP_KEY: 'fixture-app', TAOBAO_APP_SECRET: 'fixture-secret' };
const dir = fs.mkdtempSync(path.join(root, 'temp/taobao-integration-'));
const port = 18201, base = 'http://127.0.0.1:' + port;
let child, wechat, providerCalls = 0;
const fixtureContext = { window: {} };
vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fixtureContext);
const fixture = fixtureContext.window.MOCK_FIXTURE;
async function post(route, body = {}, token, params, cookie) {
const raw = JSON.stringify(body), q = { ...params };
if (params) q.sign = auth.signature(q, raw, env.TAOBAO_APP_SECRET);
const res = await fetch(base + route + (params ? '?' + new URLSearchParams(q) : ''), {
method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), ...(cookie ? { cookie } : {}) }, body: raw });
return { response: res, data: await res.json() };
}
async function login(openId) {
const challenge = (await post('/api/taobao/challenge')).data.data.challenge;
const body = { consent: true, challenge };
const params = { app_key: env.TAOBAO_APP_KEY, open_id: openId, sign_method: 'md5' };
const result = await post('/api/taobao/session', body, null, params);
assert.equal(result.data.data.ok, true);
return { ...result.data.data, body, params };
}
async function upload(token) {
const refs = {};
for (const [i, view] of ['front', 'left', 'right'].entries()) {
const b64 = (fixture.photos[i] || fixture.photos[0]).split(',')[1], total = Math.ceil(b64.length / 65536), id = view + '-fixture-' + Date.now();
for (let index = 0; index < total; index++) {
const result = await post('/api/taobao/photo', { view, upload_id: id, index, total, data: b64.slice(index * 65536, (index + 1) * 65536) }, token);
assert.equal(result.data.data.ok, true);
assert.equal(result.data.data.complete, index === total - 1);
}
refs[view] = id;
}
return refs;
}
async function poll(token, job) {
for (let i = 0; i < 60; i++) {
const result = (await post('/api/taobao/analysis', { job }, token)).data.data;
if (result.status === 'complete') return result.payload;
await new Promise(resolve => setTimeout(resolve, 30));
}
throw Error('job did not finish');
}
before(async () => {
wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(dir, 'accounts'))[0];
child = spawn(process.execPath, ['-e', `
const fs=require('fs'),vm=require('vm'),w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
global.fetch=async()=>{fs.appendFileSync(process.env.JUZI_DATA_ROOT+'/provider-count','1\\n');await new Promise(r=>setTimeout(r,120));return {ok:true,status:200,json:async()=>({request_id:'fixture',result:structuredClone(w.MOCK_FIXTURE.result),face_rectangle:w.MOCK_FIXTURE.face_rectangle,left_side_result:w.MOCK_FIXTURE.side.left,right_side_result:w.MOCK_FIXTURE.side.right})};};
require('./server');
`], { cwd: path.join(root, 'h5'), env: { ...process.env, ...env, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1',
JUZI_DATA_ROOT: dir, PUBLIC_BASE_URL: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('backend startup timeout')), 15000);
child.stdout.on('data', data => { if (data.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code)); });
child.stderr.on('data', data => process.stderr.write(data));
});
});
after(async () => {
if (child && child.exitCode == null) { const exit = once(child, 'exit'); child.kill(); await exit; }
assert(dir.startsWith(path.join(root, 'temp') + path.sep)); fs.rmSync(dir, { recursive: true, force: true });
});
test('signed Taobao identities, replay defense, H5 isolation, shared report contract, persisted history and channel stats', async () => {
const unsigned = (await post('/api/taobao/session', { consent: true, challenge: 'fake', open_id: 'forged-user' })).data.data;
assert.equal(unsigned.http_status, 401);
const alice = await login('fixture-alice'), bob = await login('fixture-bob');
assert.notEqual(alice.user.id, bob.user.id);
assert.equal((await login('fixture-alice')).user.id, alice.user.id, 'same platform account maps to stable identity');
assert.equal((await post('/api/taobao/session', alice.body, null, alice.params)).data.data.http_status, 401, 'signed login cannot replay');
const h5Session = await (await fetch(base + '/api/auth/session', { headers: { cookie: 'juzi_session=' + alice.token } })).json();
assert.equal(h5Session.user, null, 'Taobao credentials cannot become a WeChat/H5 cookie');
assert.equal((await post('/api/taobao/me', {}, wechat.token)).data.data.http_status, 401);
const refs = await upload(alice.token);
assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs }, bob.token)).data.data.http_status, 409, 'photos are owner bound');
const profile = { real_age: 44, gender: '女', region: '上海市', daily_skin_type: '干性肌肤' };
const started = (await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data;
assert.equal(started.http_status, 202);
assert.equal((await post('/api/taobao/skin-analyze', { photo_refs: refs, profile }, alice.token)).data.data.http_status, 409, 'consumed photos cannot charge twice');
assert.equal((await post('/api/taobao/analysis', { job: started.job }, bob.token)).data.data.http_status, 404);
const result = await poll(alice.token, started.job);
assert.equal(result.ok, true); assert.equal(result.report_saved, true);
const SS = require('../../h5/js/skin-score'), UI = require('../../h5/js/report-ui');
const expected = SS.buildViewModel(result.result, result.side, { realAge: 44, skinType: '干性肌肤' });
assert.deepEqual(result.vm, expected); assert.deepEqual(result.presentation, UI.build(expected, result.result));
assert.equal(result.presentation.views.length, 7); assert.equal(result.presentation.care.length, 3);
assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, 1, 'primary report does not start side calls');
const history = (await post('/api/taobao/reports', {}, alice.token)).data.data;
assert.equal(history.total, 1);
assert.equal((await post('/api/taobao/reports', { id: result.record_id }, bob.token)).data.data.http_status, 404);
assert.equal((await post('/api/taobao/reports', {}, bob.token)).data.data.total, 0);
assert.equal((await post('/api/taobao/reports', { id: result.record_id }, alice.token)).data.data.report.id, result.record_id);
const sideJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data;
assert.equal((await poll(alice.token, sideJob.job)).ok, true);
const count = fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length;
const cachedJob = (await post('/api/taobao/wrinkle-views', { token: result.wrinkle_job.token }, alice.token)).data.data;
await poll(alice.token, cachedJob.job);
assert.equal(fs.readFileSync(path.join(dir, 'provider-count'), 'utf8').trim().split('\n').length, count, 'side requests are cached');
const h5Body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile };
const h5Wechat = await post('/api/skin-analyze', h5Body, null, null, 'juzi_session=' + wechat.token);
assert.equal(h5Wechat.data.ok, true); assert.equal(h5Wechat.data.report_saved, true);
assert.deepEqual(h5Wechat.data.vm, result.vm); assert.deepEqual(h5Wechat.data.presentation, result.presentation);
const guestLogin = await post('/api/auth/browser', { consent: true });
const cookie = guestLogin.response.headers.getSetCookie().map(c => c.split(';')[0]).join(';');
const recordsBefore = fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8');
const guest = await post('/api/skin-analyze', { ...h5Body, channel: 'taobao' }, null, null, cookie);
assert.equal(guest.data.ok, true); assert.equal(guest.data.report_saved, false);
assert.equal(fs.readFileSync(path.join(dir, 'records.jsonl'), 'utf8'), recordsBefore, 'spoofed channel cannot change H5 guest retention');
const stats = await (await fetch(base + '/api/admin/stats')).json();
assert.equal(stats.by_channel.taobao.total, 1); assert.equal(stats.by_channel.h5.total, 1);
const filtered = await (await fetch(base + '/api/admin/records?channel=taobao')).json();
assert.equal(filtered.total, 1); assert.equal(filtered.items[0].channel, 'taobao');
const csv = await (await fetch(base + '/api/admin/export?channel=taobao')).text();
const csvLines = csv.trim().split('\n');
assert.equal(csvLines.length, 2); assert.equal(csvLines[0].split(',')[2], '年龄', 'existing CSV column positions remain stable');
assert.equal(csvLines[0].split(',').at(-1).trim(), '渠道'); assert.equal(csvLines[1].split(',').at(-1).trim(), 'taobao');
const oldAccounts = require('../../h5/lib/accounts')(path.join(dir, 'accounts'), {});
assert.equal(oldAccounts.taobaoSession({ headers: { authorization: 'Bearer ' + alice.token } }).id, alice.user.id, 'restart reloads account/session');
assert.equal(oldAccounts.reports(alice.user.id).length, 1);
await post('/api/taobao/logout', {}, bob.token);
assert.equal((await post('/api/taobao/me', {}, bob.token)).data.data.http_status, 401);
});
test('native API client logs in, uploads within gateway limit, polls one detection, restores identity and honors cancellation', async () => {
const config = require('../config'), storage = new Map();
config.baseUrl = base; config.transport = 'direct'; config.basePath = '';
let requests = 0;
global.my = {
setStorageSync({ key, data }) { storage.set(key, data); },
getStorageSync({ key }) { return { data: storage.get(key) }; },
removeStorageSync({ key }) { storage.delete(key); },
request(options) {
requests++; assert(Buffer.byteLength(options.data) < 128 * 1024);
const url = new URL(options.url);
if (url.pathname === '/api/taobao/session') {
const params = { app_key: env.TAOBAO_APP_KEY, open_id: 'native-client-user', sign_method: 'md5' };
params.sign = auth.signature(params, options.data, env.TAOBAO_APP_SECRET);
url.search = new URLSearchParams(params).toString();
}
fetch(url, { method: options.method, headers: options.headers, body: options.data }).then(async res => {
options.success({ status: res.status, data: await res.json() });
}).catch(options.fail);
}
};
const api = require('../utils/api');
const user = await api.login(true); assert.equal(user.kind, 'taobao');
const result = await api.analyze([fixture.photos[0], fixture.photos[0], fixture.photos[0]], { real_age: 30 }, { canceled: false });
assert.equal(result.ok, true); assert.equal(result.report_saved, true);
assert.equal((await api.request('reports')).total, 1);
assert.equal((await api.restore()).id, user.id);
const before = requests;
await assert.rejects(api.analyze(['unused', 'unused', 'unused'], {}, { canceled: true }), err => err.canceled === true);
assert.equal(requests, before, 'cancelled pipeline does not send photos or start a paid job');
await api.logout(); assert.equal(await api.restore(), null);
delete global.my;
});