Files
server/laf-cloud/tests/daily-task-compatibility.test.mjs

150 lines
9.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { isDeepStrictEqual } from 'node:util';
const state = { user: null, beforeWrite: null, writes: 0, databases: [] };
const clone = value => structuredClone(value);
function matches(query) {
return Object.entries(query).every(([key,value]) => value && typeof value === 'object' && '$exists' in value
? Object.hasOwn(state.user,key) === value.$exists : isDeepStrictEqual(state.user[key],value));
}
globalThis.__taskCompatibilityCloud = {
database:()=>({collection:name=>{state.databases.push(name);return {where:query=>({getOne:async()=>({data:state.user && matches(query)?clone(state.user):null})})};}}),
mongo:{db:{collection:name=>{state.databases.push(name);return {updateOne:async(query,update)=>{
if(state.beforeWrite){const run=state.beforeWrite;state.beforeWrite=null;await run();}
if(!matches(query))return {matchedCount:0};
Object.assign(state.user,clone(update.$set));state.writes++;return {matchedCount:1};
}};}}},
};
registerHooks({resolve(specifier,context,next){
if(specifier==='@lafjs/cloud')return {url:'data:text/javascript,export default globalThis.__taskCompatibilityCloud',shortCircuit:true};
if(specifier==='@/Utils')return {url:'data:text/javascript,export default {checkToken:(a,b)=>a===b}',shortCircuit:true};
if(specifier.startsWith('@/'))return {url:new URL('../functions/'+specifier.slice(2)+'.ts',import.meta.url).href,shortCircuit:true};
return next(specifier,context);
}});
const {supportsDailyTaskV2,refreshLegacyTask,ensureLoginTasks,writeTask}=await import('../functions/dailyTaskCompatibility.ts');
const {refreshTaskData}=await import('../functions/dailyTaskService.ts');
const legacyInfo=(await import('../functions/dayTaskInfo.ts')).default;
const legacyClaim=(await import('../functions/getTaskReward.ts')).default;
const modernInfo=(await import('../functions/dayTaskInfoV2.ts')).default;
const modernClaim=(await import('../functions/getTaskRewardV2.ts')).default;
function setup(modern=false){
const now=Date.now(), task=modern?refreshTaskData(null,'user',50,now).data:refreshLegacyTask(null,0,now);
state.user={_id:'user',token:'token',levelAmount:49,task:JSON.stringify(task),taskTime:now+(modern?0:8*3600000)};
state.beforeWrite=null;state.writes=0;state.databases=[];return task;
}
const call=(handler,body={})=>handler({body:{uid:'user',token:'token',...body}});
test('1.0.88 threshold uses numeric comparison; missing, malformed and older versions stay legacy',()=>{
for(const v of [undefined,null,'','1.0.87','1.0.9','1.0.88-beta',{},88])assert.equal(supportsDailyTaskV2(v),false);
for(const v of ['1.0.88','1.0.89','1.0.100','1.1.0','2.0.0'])assert.equal(supportsDailyTaskV2(v),true);
});
test('legacy read/save/batch claim retain the released four-task protocol and claimed state',async()=>{
setup();let read=await call(legacyInfo,{action:'read'});
assert.equal(read.code,1);assert.deepEqual(Object.keys(read.data.task),['levelPass','share','useEnergy','useProp']);
const progress=Object.fromEntries(Object.entries(read.data.task).map(([id,t])=>[id,{value:t.target}]));
const saved=await call(legacyInfo,{action:'save',task:JSON.stringify(progress)});
assert.equal(saved.code,1);assert.ok(Object.values(saved.data.task).every(t=>t.state===1));
const claimed=await call(legacyClaim,{taskType:JSON.stringify(['levelPass','share','useEnergy','useProp'])});
assert.equal(claimed.code,1);assert.ok(Object.values(claimed.data.task).every(t=>t.state===2));
assert.equal('rewards' in claimed.data,false,'old clients grant their own fixed rewards');
assert.ok(claimed.data.taskTime>Date.now());
assert.equal((await call(legacyClaim,{taskType:'["share"]'})).code,0);
read=await call(legacyInfo,{action:'save',task:JSON.stringify(progress)});
assert.ok(Object.values(read.data.task).every(t=>t.state===2));
});
test('legacy reset uses Shanghai midnight and preserves the legacy shifted taskTime convention',()=>{
const before=Date.parse('2026-10-14T15:59:59Z'), after=before+1000;
const task=refreshLegacyTask(null,0,before);task.share.value=1;task.share.state=2;
assert.equal(refreshLegacyTask(JSON.stringify(task),before+8*3600000,before).share.state,2);
assert.equal(refreshLegacyTask(JSON.stringify(task),before+8*3600000,after).share.state,0);
});
test('first V2 login migrates once and later logins preserve rewards, progress and weekly claims',async()=>{
const old=setup();old.share={value:1,state:2,target:1};state.user.task=JSON.stringify(old);
const legacy=await ensureLoginTasks('users',clone(state.user),'1.0.87');
assert.equal(JSON.parse(legacy.user.task).share.state,2);
const modern=await ensureLoginTasks('users',clone(state.user),'1.0.88');
assert.equal(modern.error,undefined);let task=JSON.parse(modern.user.task);
assert.equal(task.version,2);assert.equal(task.dailyTasks.length,5);
assert.ok(task.dailyTasks.every(t=>t.value===0));assert.equal(task.weeklyStars,0);
task.dailyTasks[0].value=task.dailyTasks[0].target;task.dailyTasks[0].state=2;
task.weeklyStars=60;task.weeklyRewards[0].state=2;state.user.task=JSON.stringify(task);
const repeated=await ensureLoginTasks('users',clone(state.user),'1.0.100');
const kept=JSON.parse(repeated.user.task);
assert.deepEqual(kept.dailyTasks,task.dailyTasks);assert.equal(kept.weeklyStars,60);assert.equal(kept.weeklyRewards[0].state,2);
});
test('old login, reads, saves and claims cannot downgrade a migrated account',async()=>{
setup(true);const before=clone(state.user);
const login=await ensureLoginTasks('users',clone(state.user),undefined);assert.equal(login.error.code,0);
for(const [handler,body] of [[legacyInfo,{action:'read'}],[legacyInfo,{action:'save',task:'{}'}],[legacyClaim,{taskType:'["share"]'}]]){
assert.equal((await call(handler,body)).errorCode,'DAILY_TASK_VERSION_REQUIRED');
}
assert.deepEqual(state.user,before);assert.equal(state.writes,0);
});
test('in-flight legacy save and claim lose their atomic write when migration wins',async()=>{
for(const claim of [false,true]){
const old=setup();old.share.value=1;old.share.state=1;state.user.task=JSON.stringify(old);
const migrated=refreshTaskData(null,'user',50).data;
state.beforeWrite=()=>{state.user.task=JSON.stringify(migrated);state.user.taskTime=Date.now();};
const result=await call(claim?legacyClaim:legacyInfo,claim?{taskType:'["share"]'}:{action:'save',task:JSON.stringify(old)});
assert.equal(result.code,0);assert.deepEqual(JSON.parse(state.user.task),migrated);assert.equal(state.writes,0);
}
});
test('login migration retries after an old write wins, while an old login never rolls back a winning migration',async()=>{
setup();state.beforeWrite=()=>{const task=JSON.parse(state.user.task);task.share.value=1;state.user.task=JSON.stringify(task);};
const result=await ensureLoginTasks('users',clone(state.user),'1.0.88');
assert.equal(JSON.parse(result.user.task).version,2);
setup();state.beforeWrite=()=>{state.user.task=JSON.stringify(refreshTaskData(null,'user',50).data);};
assert.equal((await ensureLoginTasks('users',clone(state.user),undefined)).error.code,0);
assert.equal(JSON.parse(state.user.task).version,2);
});
test('new routes reject old versions without modifying data and preserve both response protocols',async()=>{
setup();const before=clone(state.user);
for(const version of [undefined,'1.0.87'])for(const handler of [modernInfo,modernClaim]){
assert.equal((await call(handler,{version,action:'read'})).code,0);
}
assert.deepEqual(state.user,before);
const read=await call(modernInfo,{version:'1.0.88',action:'read'});assert.equal(read.code,1);
const task=read.data.task;task.dailyTasks[0].value=task.dailyTasks[0].target;
assert.equal((await call(modernInfo,{version:'1.0.88',action:'save',task:JSON.stringify(task)})).code,1);
const result=await call(modernClaim,{version:'1.0.88',taskId:task.dailyTasks[0].taskId});
assert.equal(result.code,1);assert.ok(Array.isArray(result.data.rewards));assert.equal(result.data.task.dailyTasks[0].state,2);
});
test('simultaneous claims produce only one successful grant for legacy and V2',async()=>{
for(const modern of [false,true]){
const task=setup(modern);
if(modern){task.dailyTasks[0].value=task.dailyTasks[0].target;task.dailyTasks[0].state=1;}
else {task.share.value=1;task.share.state=1;}
state.user.task=JSON.stringify(task);
const handler=modern?modernClaim:legacyClaim;
const body=modern?{version:'1.0.88',taskId:task.dailyTasks[0].taskId}:{taskType:'["share"]'};
const replies=await Promise.all([call(handler,body),call(handler,body)]);
assert.equal(replies.filter(r=>r.code===1).length,1);assert.equal(state.writes,1);
}
});
test('missing fields initialize via atomic exists filters and usersAd uses its own collection',async()=>{
setup();delete state.user.task;delete state.user.taskTime;
assert.ok((await ensureLoginTasks('usersAd',clone(state.user),'1.0.88')).user);
assert.ok(state.databases.every(name=>name==='usersAd'));
state.databases=[];
assert.equal((await call(modernInfo,{version:'1.0.88',action:'read',gameName:'iaa'})).code,1);
assert.ok(state.databases.every(name=>name==='usersAd'));
});
test('old tasks may be claimed before upgrading and new tasks remain independently claimable afterwards',async()=>{
const old=setup();old.share.value=1;old.share.state=1;state.user.task=JSON.stringify(old);
assert.equal((await call(legacyClaim,{taskType:'["share"]'})).code,1);
await ensureLoginTasks('users',clone(state.user),'1.0.88');
const task=JSON.parse(state.user.task);task.dailyTasks[0].value=task.dailyTasks[0].target;task.dailyTasks[0].state=1;state.user.task=JSON.stringify(task);
assert.equal((await call(modernClaim,{version:'1.0.88',taskId:task.dailyTasks[0].taskId})).code,1);
});