feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user