server/laf-cloud/tests/pass-check-settlement.test.mjs

299 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const NOW = 1800000000000, END = NOW - 1000, PERIOD = 30 * 86400000;
let now = NOW, drop = false, failFreeze = '', beforeFind = null;
const reads = [], scans = [], writes = [];
const tables = { users: [], usersAd: [], order: [], idcount: [], passCheckJobCursors: [] };
const clone = v => v == null ? v : structuredClone(v);
const get = (r, k) => k.split('.').reduce((v, key) => v?.[key], r);
const match = (r, q) => Object.entries(q).every(([k, v]) => {
if (k === '$expr') return now < new Date(v.$lt[1]).getTime();
if (v === null) return get(r, k) == null;
if (v && typeof v === 'object' && '$gt' in v) return get(r, k) > v.$gt;
if (v && typeof v === 'object' && '$lte' in v) return get(r, k) <= v.$lte;
return JSON.stringify(get(r, k)) === JSON.stringify(v);
});
const collection = name => ({
async findOne(q) {
reads.push({ name, q: clone(q) });
return clone(tables[name].find(r => match(r, q)));
},
find(q) {
let limit = Infinity;
const query = {
sort() { return query; },
limit(value) { limit = value; return query; },
async toArray() {
scans.push({ name, q: clone(q) });
if (beforeFind) await beforeFind(name);
return clone(tables[name].filter(r => match(r, q)).sort((a, b) => a._id < b._id ? -1 : a._id > b._id ? 1 : 0).slice(0, limit));
}
};
return query;
},
async updateOne(q, update, options = {}) {
writes.push({ name, q: clone(q), update: clone(update) });
if (q._id === failFreeze && update.$set?.passSettlements) throw Error('freeze failed');
let row = tables[name].find(r => match(r, q));
if (!row && options.upsert) {
row = { ...clone(q), ...clone(update.$setOnInsert || {}) };
tables[name].push(row);
}
if (!row) return { matchedCount: 0, modifiedCount: 0 };
for (const [k, v] of Object.entries(update.$set || {})) {
const keys = k.split('.'); let target = row;
for (const part of keys.slice(0, -1)) target = target[part] ||= {};
target[keys.at(-1)] = clone(v);
}
for (const [k, v] of Object.entries(update.$inc || {})) row[k] = (row[k] || 0) + v;
if (drop && update.$set.timestamp) { drop = false; throw Error('lost response'); }
return { matchedCount: 1, modifiedCount: 1 };
}
});
globalThis.__passTestCloud = {
mongo: { db: { collection } },
database: () => ({ collection: name => ({ where: q => ({
getOne: async () => ({ data: await collection(name).findOne(q) }),
get: async () => ({ data: clone(tables[name].filter(r => match(r, q))) }),
update: async values => ({ updated: (await collection(name).updateOne(q, { $set: values })).modifiedCount })
}) }) })
};
registerHooks({ resolve(specifier, context, next) {
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__passTestCloud', 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 { default: settlement, currentPassStart } = await import('../functions/passCheckSettlement.ts');
const { default: upgrade, passOrderFields } = await import('../functions/passCheckUpgrade.ts');
const { default: runJob } = await import('../functions/passCheckJobs.ts');
const realNow = Date.now;
Date.now = () => now;
test.after(() => { Date.now = realNow; });
function reset() {
now = NOW; drop = false; failFreeze = ''; beforeFind = null;
reads.length = 0; scans.length = 0; writes.length = 0;
for (const key of Object.keys(tables)) tables[key] = [];
process.env.PASSCHECKTIME_ID = 'clock';
tables.idcount.push({ _id: 'clock', passcheckTime: END - PERIOD });
const stage = { time: String(END), experience: 4, free: [0, 1, 1], passCheck: [1, 1, 1], activate: false };
tables.users.push({ _id: 'u', openid: 'o', token: 't', coinAmount: 10, passCheck: JSON.stringify({ 2: stage }) });
return tables.users[0];
}
const body = data => ({ body: { uid: 'u', token: 't', ...data } });
const call = data => settlement(body(data));
test('offline freeze is immutable, never credits resources, and uses millisecond periods', async () => {
const u = reset(); await call({ action: 'read' });
assert.equal(u.coinAmount, 10);
u.passCheck = JSON.stringify({ 2: { time: END, experience: 999 } });
assert.equal((await call({ action: 'read' })).data.rows[0].stage.experience, 4);
assert.equal(await currentPassStart(), END);
now = END + PERIOD; assert.equal(await currentPassStart(), now);
});
test('concurrent requests and a lost response cannot award twice', async () => {
const u = reset(), request = { action: 'claim', end: END, rewards: [{ type: 'coin', count: 15 }] };
drop = true;
const replies = await Promise.all([call(request), call(request)]);
assert.equal(u.coinAmount, 25);
assert.ok(replies.some(r => r.code === 1));
assert.equal((await call(request)).data.alreadyGranted, true);
assert.equal(u.coinAmount, 25);
});
test('offer is scoped to the rendered view and permanent abandonment revokes ordering', async () => {
const u = reset(), offerToken = 'original_render_token';
assert.equal((await call({ action: 'offer_shown', end: END, offerToken })).code, 1);
assert.equal((await call({ action: 'offer_shown', end: END, offerToken: 'another_render_token' })).code, 410);
const order = { itemid: 'battlepass', passVersion: 2, passEnd: END, passOfferToken: offerToken };
assert.equal(passOrderFields(u, order).passPostSeason, true);
await call({ action: 'decline', end: END });
assert.throws(() => passOrderFields(u, order));
});
test('paid duplicate gets a manual-review marker, not a second grant', async () => {
const u = reset(); await call({ action: 'read' });
const order = { openid: 'o', itemid: 'battlepass', passEnd: String(END), passPostSeason: true, state: 1 };
tables.order.push({ ...order, outTradeNo: 'first' }, { ...order, outTradeNo: 'second' });
const request = { action: 'purchase_claim', end: END, rewards: [{ type: 'coin', count: 300 }] };
assert.equal((await call({ ...request, outTradeNo: 'first' })).code, 1);
assert.equal((await call({ ...request, outTradeNo: 'second' })).code, 410);
assert.equal(u.coinAmount, 310);
assert.equal(tables.order[1].passReview, 'duplicate_post_season_payment');
});
test('late paid entitlement preserves prior claim masks and original verified XP', async () => {
const u = reset();
await call({ action: 'claim', end: END, rewards: [], claimFlags: { free: [0, 0, 0], passCheck: [1, 1, 1] } });
tables.order.push({ openid: 'o', itemid: 'battlepass_30', passVersion: 2, passEnd: String(END), state: 1, outTradeNo: 'late' });
const reply = await upgrade(body({ action: 'save', outTradeNo: 'late', revision: 0, coinAmount: 3010,
passCheck: JSON.stringify({ 2: { time: String(END), experience: 9999 } }), legacy: u.passCheck }));
assert.equal(reply.code, 1);
assert.equal(u.passSettlements[END].stage.experience, 24);
assert.deepEqual(u.passSettlements[END].stage.free, [0, 0, 0]);
assert.equal(u.passSettlements[END].stage.tier, 30);
assert.equal(u.passSettlements[END].claimed, false);
assert.equal((await upgrade(body({ action: 'order', outTradeNo: 'late' }))).data.alreadyGranted, true);
});
test('unauthenticated requests and expired ordinary writes cannot alter the snapshot', async () => {
const u = reset();
assert.equal((await call({ action: 'claim', end: END, token: 'wrong', rewards: [] })).code, 0);
const result = await upgrade(body({ action: 'save', revision: 0, passCheck: u.passCheck, legacy: u.passCheck }));
assert.equal(result.code, 410);
assert.equal(u.passSettlements[END].stage.experience, 4);
});
const jobCursor = table => tables.passCheckJobCursors.find(r => r._id === 'season:' + table);
function populate(count) {
const original = reset();
tables.users = Array.from({ length: count }, (_, i) => ({ ...clone(original), _id: 'u' + String(i).padStart(4, '0') }));
}
test('job scans only users once and idle ticks never scan users or write cursors', async () => {
const u = reset();
tables.usersAd.push({ ...clone(u), _id: 'ad' });
const adBefore = clone(tables.usersAd);
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
assert.equal(jobCursor('users').periodEnd, END);
assert.ok(u.passSettlements[END]);
assert.equal(u.coinAmount, 10);
assert.equal(jobCursor('usersAd'), undefined);
assert.deepEqual(tables.usersAd, adBefore);
assert.ok([...reads, ...scans, ...writes].every(r => r.name !== 'usersAd' && r.q._id !== 'season:usersAd'));
scans.length = 0; writes.length = 0;
for (let i = 0; i < 5; i++) { now += 60000; assert.equal((await runJob()).count, 0); }
assert.deepEqual(scans, []);
assert.deepEqual(writes, []);
});
test('job resumes batches without rewinding and only opens a new sweep at the next boundary', async () => {
populate(201);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').after, 'u0199');
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
now = END + PERIOD - 1;
assert.equal((await runJob()).count, 0);
now++;
// Online reads can advance the global clock first; this must not hide a new season.
await currentPassStart();
const u = tables.users[0];
u.passCheck = JSON.stringify({ 2: { time: String(now), experience: 8, free: [1] } });
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').periodEnd, now);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal(u.passSettlements[now].stage.experience, 8);
assert.equal(u.coinAmount, 10);
});
test('exact full batches terminate instead of restarting', async () => {
populate(100);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').completed, false);
assert.equal(jobCursor('usersAd'), undefined);
assert.equal((await runJob()).count, 0);
assert.equal(jobCursor('users').completed, true);
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
});
test('failed batch preserves its cursor, releases the lease and retries idempotently', async () => {
populate(102);
await runJob();
failFreeze = 'u0101';
await assert.rejects(runJob(), /freeze failed/);
assert.equal(jobCursor('users').after, 'u0099');
assert.equal(jobCursor('users').completed, false);
assert.equal(jobCursor('users').leaseUntil, 0);
assert.ok(tables.users[100].passSettlements[END]);
const revision = tables.users[100].passSettlementRevision;
failFreeze = '';
assert.equal((await runJob()).count, 2);
assert.equal(jobCursor('users').completed, true);
assert.equal(tables.users[100].passSettlementRevision, revision);
});
test('legacy circular cursors are preserved but never used to skip the initial backfill', async () => {
reset();
tables.passCheckJobCursors.push({ _id: 'users', after: 'z' });
await runJob();
assert.ok(tables.users[0].passSettlements[END]);
assert.deepEqual(tables.passCheckJobCursors.find(r => r._id === 'users'), { _id: 'users', after: 'z' });
});
test('obsolete usersAd job state is never read or updated, including across periods', async () => {
const u = reset();
tables.usersAd.push({ ...clone(u), _id: 'ad' });
const oldCursor = { _id: 'season:usersAd', periodEnd: END - PERIOD, after: null,
completed: false, revision: 2, leaseUntil: 0 };
tables.passCheckJobCursors.push(clone(oldCursor));
const adBefore = clone(tables.usersAd);
await runJob();
await runJob();
now = END + PERIOD;
await runJob();
assert.deepEqual(jobCursor('usersAd'), oldCursor);
assert.deepEqual(tables.usersAd, adBefore);
assert.ok([...reads, ...scans, ...writes].every(r => r.name !== 'usersAd' && r.q._id !== 'season:usersAd'));
});
test('cross-period recovery finishes the old sweep before starting the latest period', async () => {
populate(101);
await runJob();
now = END + 3 * PERIOD;
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').periodEnd, END);
assert.equal(jobCursor('users').completed, true);
assert.equal((await runJob()).count, 100);
assert.equal(jobCursor('users').periodEnd, now);
});
test('overlapping workers skip leased batches and cannot overwrite newer progress after expiry', async () => {
populate(101);
let resume, started;
const paused = new Promise(resolve => { started = resolve; });
beforeFind = async name => {
if (name !== 'users') return;
beforeFind = null;
started();
await new Promise(resolve => { resume = resolve; });
};
const stale = runJob();
await paused;
assert.equal((await runJob()).count, 0);
now += 5 * 60000 + 1;
assert.equal((await runJob()).count, 100);
assert.equal((await runJob()).count, 1);
assert.equal(jobCursor('users').completed, true);
const finished = clone(jobCursor('users'));
resume();
await stale;
assert.deepEqual(jobCursor('users'), finished);
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
});
test('online reads still freeze late-arriving records after the period scan is complete', async () => {
reset();
tables.users = [];
await runJob();
tables.users.push({ _id: 'u', token: 't', coinAmount: 10,
passCheck: JSON.stringify({ 2: { time: String(END), experience: 4 } }) });
scans.length = 0;
await runJob();
assert.deepEqual(scans, []);
assert.equal((await call({ action: 'read' })).data.rows[0].end, END);
assert.equal(tables.users[0].coinAmount, 10);
});
test('a future configured start never begins a settlement scan', async () => {
reset();
tables.idcount[0].passcheckTime = NOW + PERIOD;
assert.equal((await runJob()).count, 0);
assert.deepEqual(scans, []);
assert.deepEqual(tables.passCheckJobCursors, []);
});