feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 13:38:24 +08:00
parent 607d709100
commit 9d7cf720a5
73 changed files with 25771 additions and 264 deletions
+34
View File
@@ -0,0 +1,34 @@
const test=require('node:test'), assert=require('node:assert/strict'), fs=require('node:fs'),vm=require('node:vm');
const {createRequire}=require('node:module');
const adminRequire=createRequire(require('node:path').resolve('pets-be/web/admin/package.json'));
const ts=adminRequire('typescript'),axios=adminRequire('axios').default;
const tick=()=>new Promise(resolve=>setImmediate(resolve));
function runtime(adapter) {
const storage=new Map(),events=[];
const localStorage={getItem:k=>storage.get(k)||null,setItem:(k,v)=>storage.set(k,v),removeItem:k=>storage.delete(k)};
axios.defaults.adapter=adapter;
const module={exports:{}};
const code=ts.transpileModule(fs.readFileSync('pets-be/web/admin/src/lib/api.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,esModuleInterop:true,target:ts.ScriptTarget.ES2022}}).outputText;
vm.runInNewContext(code,{module,exports:module.exports,require:adminRequire,localStorage,navigator:{},window:{dispatchEvent:e=>events.push(e.type)},Event:class{constructor(type){this.type=type;}}});
return {...module.exports,storage,events};
}
const response=(config,data)=>({status:200,statusText:'OK',config,headers:{},data:{code:0,data}});
const unauthorized=config=>Promise.reject(new axios.AxiosError('expired','ERR_BAD_REQUEST',config,null,{status:401,data:{message:'expired'},config}));
const pair=token=>({token,refresh_token:'refresh-'+token,expires_at:Date.now()/1000+7200,refresh_expires_at:Date.now()/1000+7200});
test('管理端并发请求只刷新一次,再携带新 token 请求',async()=>{
let releases,refreshes=0,seen=[];
const r=runtime(config=>{if(config.url==='/admin/refresh'){refreshes++;return new Promise(resolve=>releases=()=>resolve(response(config,pair('new'))));}seen.push(config.headers.Authorization);return Promise.resolve(response(config,{users:1}));});
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});const calls=[r.api.stats(),r.api.stats(),r.api.stats()];await tick();assert.equal(refreshes,1);releases();await Promise.all(calls);assert.deepEqual(seen,['Bearer new','Bearer new','Bearer new']);assert.equal(r.getToken(),'new');
});
test('刷新网络失败保留登录,过期拒绝则清空会话',async()=>{
let rejectAuth=false;
const r=runtime(config=>config.url==='/admin/refresh' ? rejectAuth?unauthorized(config):Promise.reject(new Error('offline')):unauthorized(config));
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});await assert.rejects(r.api.stats());assert.equal(r.getToken(),'old');assert.equal(r.events.length,0);
rejectAuth=true;await assert.rejects(r.api.stats());assert.equal(r.getToken(),'');assert.ok(r.events.includes('admin-auth-expired'));
});
test('退出期间的刷新响应不会恢复会话,登录密码错误也不会递归刷新',async()=>{
let release,refreshes=0;
const r=runtime(config=>{if(config.url==='/admin/login')return unauthorized(config);if(config.url==='/admin/refresh'){refreshes++;return new Promise(resolve=>release=()=>resolve(response(config,pair('new'))));}return Promise.resolve(response(config,{}));});
r.setToken({...pair('old'),expires_at:Date.now()/1000+30});const request=r.api.stats();await tick();r.clearToken();release();await assert.rejects(request);assert.equal(r.getToken(),'');
await assert.rejects(r.api.login('sundynix','bad'));assert.equal(refreshes,1);
});
+51
View File
@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const tick = () => new Promise(resolve => setImmediate(resolve));
function authRuntime() {
let app;
const storage=new Map(), requests=[], logins=[];
const wx={ getStorageSync:k=>storage.get(k), setStorageSync:(k,v)=>storage.set(k,v), removeStorageSync:k=>storage.delete(k), login:o=>logins.push(o), request:o=>requests.push(o) };
const module={exports:{}};
vm.runInNewContext(fs.readFileSync('pets-fe/utils/api.js','utf8'), {module,wx,getApp:()=>app,require:()=>({baseURL:'https://test/api'})});
vm.runInNewContext(fs.readFileSync('pets-fe/app.js','utf8'), {wx,App:value=>app=value,require:()=>module.exports});
const reply=(req,data,statusCode=200)=>req.success({statusCode,data:statusCode===200?{code:0,data}:{message:'expired'}});
return {app,storage,requests,logins,api:module.exports,reply};
}
test('临近到期先刷新,启动与多个业务请求共用一次刷新',async()=>{
const r=authRuntime();r.storage.set('pet_token','old');r.storage.set('pet_token_expires_at',Date.now()/1000+30);r.storage.set('pet_refresh_token','refresh-old');
r.app.onLaunch();const pending=[r.api.secure('/pets'),r.api.secure('/tasks')];await tick();
assert.equal(r.requests.length,1);assert.equal(r.requests[0].url,'https://test/api/auth/refresh');assert.equal(r.logins.length,0);
r.reply(r.requests[0],{token:'new',refresh_token:'refresh-new',expires_at:Date.now()/1000+7200});await tick();
for(const req of r.requests.slice(1)){assert.equal(req.header.Authorization,'Bearer new');r.reply(req,'ok');}
assert.deepEqual(await Promise.all(pending),['ok','ok']);assert.equal(r.storage.get('pet_refresh_token'),'refresh-new');
});
test('刷新过期才回到微信登录,网络错误保留刷新凭证以供重试',async()=>{
const r=authRuntime();r.storage.set('pet_refresh_token','expired');const first=r.app.login();r.reply(r.requests[0],null,401);await tick();
assert.equal(r.logins.length,1);r.logins[0].success({code:'new-code'});await tick();r.reply(r.requests[1],{token:'new',refresh_token:'new-refresh',expires_at:Date.now()/1000+7200});await first;
r.app.globalData.token='';const retry=r.app.login();r.requests[2].fail();await assert.rejects(retry,/网络/);assert.equal(r.storage.get('pet_refresh_token'),'new-refresh');assert.equal(r.logins.length,1);
const last=r.app.login();r.reply(r.requests[3],{token:'last',refresh_token:'last-refresh',expires_at:Date.now()/1000+7200});await last;
assert.equal(r.app.globalData.token,'last');
});
test('品种按 A–Z 分组,支持别名、拼音和首字母搜索',()=>{
const module={exports:{}};vm.runInNewContext(fs.readFileSync('pets-fe/utils/breeds.js','utf8'),{module,require:()=>({secure:()=>{}})});
const rows=[{id:'k',name:'柯基',aliases:'小短腿',pinyin:'keji',pinyin_initials:'kj',initial:'K'},{id:'b',name:'边境牧羊犬',aliases:'边牧',pinyin:'bianjingmuyangquan',pinyin_initials:'bjm yq'.replace(/ /g,''),initial:'B'}];
const group=module.exports.groupBreeds;assert.equal(group(rows)[0].letter,'B');for(const q of ['柯基','小短腿','keji','KJ']) assert.equal(group(rows,q)[0].items[0].id,'k');assert.equal(group(rows,'没有这种犬').length,0);
});
test('手动填写清除品种 ID,保存宠物发送自定义品种',async()=>{
let definition;vm.runInNewContext(fs.readFileSync('pets-fe/components/breed-picker/index.js','utf8'),{require:()=>({}),Component:c=>definition=c,wx:{showToast:()=>{}}});
let change;const component={...definition.methods,data:{custom:' 地方犬 ',visible:true},setData:v=>Object.assign(component.data,v),triggerEvent:(_,v)=>change=v};component.useCustom();assert.equal(change.id,null);assert.equal(change.name,'地方犬');assert.equal(component.data.visible,false);
let pageDef,body;vm.runInNewContext(fs.readFileSync('pets-fe/pages/pet-edit/pet-edit.js','utf8'),{Page:p=>pageDef=p,require:()=>({secure:async(_p,_m,b)=>body=b}),wx:{navigateBack:()=>{}}});
const page={...pageDef,data:{...pageDef.data,name:'小狗',breed_id:'old-id'},setData:v=>Object.assign(page.data,v)};page.breedChange({detail:change});await page.save();assert.equal(body.breed_id,null);assert.equal(body.breed,'地方犬');
page.breedChange({detail:{id:'selected-id',name:'柯基'}});await page.save();assert.equal(body.breed_id,'selected-id');assert.equal(body.breed,'柯基');
});
test('百科详情展示各字段出处,只复制用户点击的来源链接',async()=>{
let definition, copied;
const breed={name:'柯基',origin:'英国',traits:'{"运动活力":4,"无效":9}',exercise:'每天走走',field_sources:{origin:'fci',traits:'akc',exercise:'rkc'},references:[{id:'r1',provider:'fci',url:'https://www.fci.be/en/nomenclature/',retrieved_at:'2026-10-05'}]};
vm.runInNewContext(fs.readFileSync('pets-fe/pages/breed-detail/breed-detail.js','utf8'),{Page:p=>definition=p,require:()=>({secure:async()=>breed}),wx:{setClipboardData:options=>copied=options.data,showToast:()=>{}}});
const page={...definition,data:{...definition.data},id:'breed-id',setData:patch=>Object.assign(page.data,patch)};
await page.load();assert.equal(page.data.facts.find(f=>f.key==='origin').source,'FCI');assert.equal(page.data.traits.length,1);assert.equal(page.data.traitSource,'AKC');assert.equal(page.data.exerciseSource,'英国皇家犬业俱乐部');
page.source({currentTarget:{dataset:{id:'r1'}}});assert.equal(copied,breed.references[0].url);
});
+2 -1
View File
@@ -25,7 +25,8 @@ function page(name, secure, wx = {}) {
};
let definition;
global.Page = (value) => { definition = value; };
const filename = path.resolve(__dirname, `../pets-fe/pages/${name}/${name}.js`);
global.Component = (value) => { definition = {data:value.data,...value.methods}; };
const filename = path.resolve(__dirname, name === "meetups" ? "../pets-fe/components/meetup-panel/index.js" : `../pets-fe/pages/${name}/${name}.js`);
delete require.cache[filename];
require(filename);
return {