249 lines
14 KiB
JavaScript
249 lines
14 KiB
JavaScript
import test, { beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { registerHooks } from 'node:module';
|
|
|
|
const NOW = Date.parse('2026-09-20T14:20:00+08:00');
|
|
const DAY = 86400000;
|
|
const players = [];
|
|
const goldConfigs = [], goldPlayers = [];
|
|
let goldUnavailable = false;
|
|
let unavailable = false;
|
|
let enabled = true;
|
|
const clone = value => structuredClone(value);
|
|
globalThis.__activityListCloud = {
|
|
mongo: {
|
|
db: {
|
|
collection(name) {
|
|
if (name === 'activityConfigs') return {
|
|
find(query) {
|
|
if (query.activityId === 'goldMiner') return {
|
|
sort() { return this; }, limit() { return this; }, async toArray() {
|
|
if (goldUnavailable) throw Error('simulated gold miner database outage');
|
|
return clone(goldConfigs.filter(row => row.recordType === query.recordType && row.status === query.status
|
|
&& row.effectiveFrom <= query.effectiveFrom.$lte && row.publishedAt <= query.publishedAt.$lte)
|
|
.sort((a, b) => b.effectiveFrom - a.effectiveFrom || b.publishedAt - a.publishedAt).slice(0, 2));
|
|
}
|
|
};
|
|
assert.equal(query.activityId, 'cloudRise');
|
|
return {
|
|
sort() { return this; }, limit() { return this; }, async toArray() {
|
|
if (unavailable) throw Error('simulated database outage');
|
|
return [{ configVersion: 'v1', enabled, config: { unlockLevel: 100, durationHours: 24, pools: [10000, 15000, 20000] } }];
|
|
}
|
|
};
|
|
}, async findOne(query) {
|
|
if (goldUnavailable) throw Error('simulated gold miner database outage');
|
|
return clone(goldConfigs.find(row => Object.entries(query).every(([key, value]) => row[key] === value)) || null);
|
|
}
|
|
};
|
|
if (name === 'goldMinerPlayerPeriods') return { async findOne(query) { return clone(goldPlayers.find(row => row._id === query._id) || null); } };
|
|
assert.equal(name, 'users', 'listing must not query orders or rewards');
|
|
return {
|
|
async findOne(query) {
|
|
if (unavailable) throw Error('simulated database outage');
|
|
return clone(players.find(p => p.onlyId === query.onlyId) || null);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const hooks = registerHooks({
|
|
resolve(specifier, context, next) {
|
|
if (specifier === '@lafjs/cloud') return { url: 'activitylist:cloud', shortCircuit: true };
|
|
if (specifier === '@/Utils') return { url: 'activitylist:utils', shortCircuit: true };
|
|
if (specifier.startsWith('@/')) return { url: new URL('../../' + specifier.slice(2) + '.ts', import.meta.url).href, shortCircuit: true };
|
|
return next(specifier, context);
|
|
},
|
|
load(url, context, next) {
|
|
if (url === 'activitylist:cloud') return { format: 'module', source: 'export default globalThis.__activityListCloud;', shortCircuit: true };
|
|
if (url === 'activitylist:utils') return { format: 'module', source: 'export default {checkToken(a,b){return a===b}};', shortCircuit: true };
|
|
return next(url, context);
|
|
},
|
|
});
|
|
const { default: api, activityList } = await import('../list.ts');
|
|
const G = await import('../../goldMiner/config.ts');
|
|
const S = await import('../../goldMiner/service.ts');
|
|
hooks.deregister();
|
|
const realNow = Date.now;
|
|
const oldEnv = process.env.PAYMENT_APP_ENV, oldFlag = process.env.GOLD_MINER_TEST_PERIODS_ENABLED;
|
|
beforeEach(() => {
|
|
players.length = 0; goldConfigs.length = 0; goldPlayers.length = 0; goldUnavailable = false;
|
|
unavailable = false; enabled = true; Date.now = () => NOW; process.env.PAYMENT_APP_ENV = 'production';
|
|
delete process.env.GOLD_MINER_TEST_PERIODS_ENABLED;
|
|
});
|
|
afterEach(() => {
|
|
Date.now = realNow;
|
|
if (oldEnv === undefined) delete process.env.PAYMENT_APP_ENV; else process.env.PAYMENT_APP_ENV = oldEnv;
|
|
if (oldFlag === undefined) delete process.env.GOLD_MINER_TEST_PERIODS_ENABLED; else process.env.GOLD_MINER_TEST_PERIODS_ENABLED = oldFlag;
|
|
});
|
|
|
|
function personalUser(id = 1001) {
|
|
return {
|
|
onlyId: id, token: 'token-' + id, cloudRisePeriod: {
|
|
periodId: 'personal-' + id, configVersion: 'v1',
|
|
startsAt: NOW - 1000, endsAt: NOW + DAY, unlockLevel: 100, pools: [10000, 15000, 20000], durationHours: 24
|
|
},
|
|
cloudRiseState: JSON.stringify({ version: 1, playedPeriods: [], run: null })
|
|
};
|
|
}
|
|
|
|
test('authenticated list returns only own CloudRise period and hides private state', async () => {
|
|
players.push(personalUser());
|
|
const result = await api({ body: { uid: '1001', token: 'token-1001' } });
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(result.data.activities, [{
|
|
activityId: 'cloudRise', periodId: 'personal-1001', configVersion: 'v1', startsAt: NOW - 1000,
|
|
endsAt: NOW + DAY, unlockLevel: 100, phase: 'active'
|
|
}]);
|
|
assert(!JSON.stringify(result).includes('token-'));
|
|
assert(!JSON.stringify(result).includes('cloudRiseState'));
|
|
});
|
|
|
|
test('anonymous list hides personal CloudRise and gold miner without configuration', async () => {
|
|
players.push(personalUser());
|
|
const result = await api({});
|
|
assert.equal(result.code, 1);
|
|
assert.deepEqual(result.data.activities, []);
|
|
});
|
|
|
|
test('list returns opening facts without starting a countdown and respects disabled templates', async () => {
|
|
const user = { onlyId: 1001, token: 'token-1001' }; players.push(user);
|
|
const first = await api({ body: { uid: 1001, token: user.token } });
|
|
assert.deepEqual(first.data.activities, []);
|
|
assert.deepEqual(first.data.openingRules, [{ activityId: 'cloudRise', enabled: true, unlockLevel: 100, cooldownMs: 43200000, lastEndedAt: null, hasOpenPeriod: false }]);
|
|
assert.equal(user.cloudRisePeriod, undefined);
|
|
Object.assign(user, personalUser());
|
|
user.cloudRiseState = JSON.stringify({ run: { periodId: user.cloudRisePeriod.periodId, status: 'failed', stages: [{ endedAt: NOW - 60000 }] } });
|
|
const failed = await api({ body: { uid: 1001, token: user.token } });
|
|
assert.equal(failed.data.openingRules[0].lastEndedAt, NOW - 60000);
|
|
enabled = false;
|
|
assert.equal((await api({ body: { uid: 1001, token: user.token } })).data.openingRules[0].enabled, false);
|
|
});
|
|
|
|
test('list validates account identity and never returns another player period', async () => {
|
|
players.push(personalUser(1001), personalUser(2002));
|
|
for (const body of [{ uid: '1001', token: 'wrong' }, { uid: '2002', token: 'token-1001' }, { uid: '1x', token: 'token-1001' }]) {
|
|
const result = await api({ body }); assert.equal(result.code, 0); assert.equal(result.data, null);
|
|
}
|
|
const result = await api({ body: { uid: 2002, token: 'token-2002' } });
|
|
assert.equal(result.data.activities[0].periodId, 'personal-2002');
|
|
});
|
|
|
|
test('list neither opens nor extends personal periods and excludes ended challenges', async () => {
|
|
const user = personalUser(); players.push(user); const before = clone(user);
|
|
await api({ body: { uid: 1001, token: user.token } }); assert.deepEqual(user, before);
|
|
for (const status of ['failed', 'completed', 'expired']) {
|
|
user.cloudRiseState = JSON.stringify({ run: { periodId: user.cloudRisePeriod.periodId, status } });
|
|
assert.deepEqual((await activityList(NOW, user)).activities, []);
|
|
}
|
|
user.cloudRiseState = JSON.stringify({ run: { periodId: 'older', status: 'failed' } });
|
|
assert.equal((await activityList(NOW, user)).activities.length, 1);
|
|
delete user.cloudRisePeriod; user.cloudRiseState = '{}';
|
|
assert.deepEqual((await activityList(NOW, user)).activities, [], 'polling must not create a period');
|
|
});
|
|
|
|
test('personal boundaries are inclusive at start, exclusive at end and update refreshAt', async () => {
|
|
const user = personalUser(); user.cloudRisePeriod.startsAt = NOW; user.cloudRisePeriod.endsAt = NOW + 5000;
|
|
assert.equal((await activityList(NOW, user)).refreshAt, NOW + 5000);
|
|
assert.equal((await activityList(NOW, user)).activities.length, 1);
|
|
assert.equal((await activityList(NOW - 1, user)).activities.length, 0);
|
|
assert.equal((await activityList(NOW + 5000, user)).activities.length, 0);
|
|
});
|
|
|
|
test('active legacy challenges remain visible without creating a new period', async () => {
|
|
const user = { cloudRiseState: JSON.stringify({ run: { periodId: 'legacy', startedAt: NOW - 1000, expiresAt: NOW + 5000, status: 'playing' } }) };
|
|
const result = await activityList(NOW, user); assert.equal(result.activities[0].periodId, 'legacy');
|
|
assert.equal(result.activities[0].endsAt, NOW + 5000); assert.equal(user.cloudRisePeriod, undefined);
|
|
});
|
|
|
|
test('empty successful lists and user database failures have distinct responses', async () => {
|
|
assert.deepEqual((await api({})).data.activities, []); unavailable = true;
|
|
const error = console.error; console.error = () => { };
|
|
try {
|
|
const result = await api({ body: { uid: 1001, token: 'token-1001' } });
|
|
assert.equal(result.code, 0); assert.equal(result.data, null);
|
|
} finally { console.error = error; }
|
|
});
|
|
|
|
function publishGold(enabled = true) {
|
|
const effectiveFrom = Date.parse('2026-09-17T00:00:00+08:00');
|
|
goldConfigs.push({
|
|
_id: 'gold-v1', activityId: 'goldMiner', recordType: 'version', status: 'published',
|
|
configVersion: 'goldV1', effectiveFrom, publishedAt: effectiveFrom - 1000, enabled,
|
|
config: {
|
|
effectiveFromPeriodId: 'goldMiner:2026-09-17', unlockPassedLevel: 41, timezone: 'Asia/Shanghai',
|
|
productId: 'gold_miner', priceFen: 300, currency: 'CNY',
|
|
tasks: [{ taskId: 'task1', sequence: 1, targetWins: 3, items: [{ type: 'coin', count: 100 }] }]
|
|
}
|
|
});
|
|
}
|
|
function temporary(startsAt, endsAt) {
|
|
const periodId = 'goldMiner:test:catalog';
|
|
goldConfigs.push({
|
|
_id: G.key('goldMinerTestSchedule'), activityId: 'goldMiner', recordType: 'testSchedule',
|
|
periods: [{ periodId, startsAt, endsAt, enabled: true, configVersion: 'temporaryV1', configSnapshot: { unlockPassedLevel: 41 } }]
|
|
});
|
|
return periodId;
|
|
}
|
|
|
|
test('registered gold miner is public and coexists with personal CloudRise without writes or private fields', async () => {
|
|
publishGold(); const user = { ...personalUser(), _id: 'u1' }; players.push(user);
|
|
const before = clone({ players, goldConfigs, goldPlayers });
|
|
const anonymous = await api({});
|
|
assert.deepEqual(anonymous.data.activities, [{
|
|
activityId: 'goldMiner', periodId: 'goldMiner:2026-09-17',
|
|
configVersion: 'goldV1', startsAt: G.calendar(NOW).startsAt, endsAt: G.calendar(NOW).endsAt,
|
|
unlockLevel: 41, phase: 'active', purchaseEnabled: true
|
|
}]);
|
|
const loggedIn = await api({ body: { uid: 1001, token: user.token } });
|
|
assert.deepEqual(loggedIn.data.activities.map(a => a.activityId), ['cloudRise', 'goldMiner']);
|
|
assert.equal(loggedIn.data.openingRules.length, 1);
|
|
assert.deepEqual({ players, goldConfigs, goldPlayers }, before);
|
|
});
|
|
|
|
test('gold miner uses seven open days per fortnight and nearest upcoming period during cooldown', async () => {
|
|
publishGold(); const start = G.FORTNIGHT_START;
|
|
for (const [now, id, phase] of [[start, '2026-10-01', 'active'], [start + 7 * DAY - 1, '2026-10-01', 'active'],
|
|
[start + 7 * DAY + 1, '2026-10-15', 'upcoming'], [start + 14 * DAY, '2026-10-15', 'active']]) {
|
|
const result = await activityList(now); assert.equal(result.activities.length, 1);
|
|
assert.equal(result.activities[0].periodId, 'goldMiner:' + id); assert.equal(result.activities[0].phase, phase);
|
|
assert.equal(result.activities[0].endsAt - result.activities[0].startsAt, 7 * DAY);
|
|
}
|
|
assert.deepEqual((await activityList(start + 7 * DAY)).activities, [], 'next start exactly at lookahead right edge is excluded');
|
|
assert.equal((await activityList(start + 7 * DAY - 1)).refreshAt, start + 7 * DAY);
|
|
});
|
|
|
|
test('closed gold miner remains visible only to existing participants or assigned entitlements', async () => {
|
|
publishGold(false); const user = { ...personalUser(), _id: 'u1' }; players.push(user);
|
|
assert.deepEqual((await api({})).data.activities, []);
|
|
assert.deepEqual((await activityList(NOW, user)).activities.map(a => a.activityId), ['cloudRise']);
|
|
const row = { _id: S.playerId(user._id, 'goldMiner:2026-09-17'), tasks: [], configVersion: 'goldV1', configSnapshot: { unlockPassedLevel: 41 } };
|
|
goldPlayers.push(row);
|
|
assert.equal((await api({ body: { uid: 1001, token: user.token } })).data.activities[1].activityId, 'goldMiner');
|
|
delete row.tasks; row.entitlement = { state: 'reserved' };
|
|
assert.equal((await activityList(NOW, user)).activities.length, 2);
|
|
row.entitlement.state = 'manual_review'; assert.equal((await activityList(NOW, user)).activities.length, 1);
|
|
});
|
|
|
|
test('gold miner purchase control does not hide its activity', async () => {
|
|
publishGold(); goldConfigs.push({ _id: S.controlId('goldMiner:2026-09-17'), activityId: 'goldMiner', recordType: 'control', purchaseEnabled: false });
|
|
const activity = (await activityList(NOW)).activities[0]; assert.equal(activity.purchaseEnabled, false);
|
|
});
|
|
|
|
test('test periods take precedence only in enabled test environments and refresh at their boundaries', async () => {
|
|
publishGold(); const id = temporary(NOW + 5000, NOW + 15000);
|
|
process.env.PAYMENT_APP_ENV = 'test'; process.env.GOLD_MINER_TEST_PERIODS_ENABLED = 'true';
|
|
const before = await activityList(NOW); assert.equal(before.activities[0].periodId, 'goldMiner:2026-09-17');
|
|
assert.equal(before.refreshAt, NOW + 5000);
|
|
const active = await activityList(NOW + 5000); assert.equal(active.activities[0].periodId, id); assert.equal(active.refreshAt, NOW + 15000);
|
|
assert.equal((await activityList(NOW + 15000)).activities[0].periodId, 'goldMiner:2026-09-17');
|
|
process.env.PAYMENT_APP_ENV = 'production'; assert.equal((await activityList(NOW + 5000)).activities[0].periodId, 'goldMiner:2026-09-17');
|
|
});
|
|
|
|
test('gold miner query failures return failure instead of a misleading partial successful list', async () => {
|
|
goldUnavailable = true; const error = console.error; console.error = () => { };
|
|
try { const result = await api({}); assert.equal(result.code, 0); assert.equal(result.data, null); }
|
|
finally { console.error = error; }
|
|
});
|