server/laf-cloud/tests/activity-unlock-levels.test.mjs
2026-10-09 20:15:02 +08:00

140 lines
7.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const functionsUrl = new URL('../functions/', import.meta.url).href;
const moduleUrl = name => new URL(name + '.ts', functionsUrl).href;
const R = await import(moduleUrl('goldMiner/config'));
const NOW = Date.parse('2026-10-02T00:00:00+08:00');
const clone = value => structuredClone(value);
const players = new Map();
let temporary = [], writes = 0;
const legacyConfig = {
configVersion: 'legacy41', effectiveFromPeriodId: 'goldMiner:2026-10-01', publishedAt: NOW - 2 * R.DAY,
unlockPassedLevel: 41, timezone: 'Asia/Shanghai', currency: 'CNY', productId: 'gold_miner', priceFen: 100,
tasks: [{ taskId: 'task1', sequence: 1, targetWins: 10, items: [{ type: 'coin', count: 100 }] }]
};
const legacyVersion = { enabled: true, configVersion: legacyConfig.configVersion,
publishedAt: legacyConfig.publishedAt, config: legacyConfig };
const mocks = {
cloud: { mongo: { db: { collection(name) {
assert.ok(['activityConfigs', R.TABLES.players].includes(name), 'unlock checks must not touch orders or balances');
return {
async findOne(query) { return name === R.TABLES.players ? clone(players.get(query._id) || null) : null; },
async updateOne(query, update, options = {}) {
assert.equal(name, R.TABLES.players);
let row = players.get(query._id);
if (!row && options.upsert) {
row = { _id: query._id, ...clone(update.$setOnInsert) };
players.set(query._id, row); writes++;
return { modifiedCount: 0, upsertedCount: 1 };
}
if (!row || (query.revision !== undefined && row.revision !== query.revision)) return { modifiedCount: 0 };
if (query.$expr && !(Date.now() < query.$expr.$lt[1].getTime())) return { modifiedCount: 0 };
Object.assign(row, clone(update.$set || {}));
for (const [key, value] of Object.entries(update.$inc || {})) row[key] = (row[key] || 0) + value;
writes++; return { modifiedCount: 1 };
}
};
} } } },
store: { latest: async () => clone(legacyVersion), isEnabled: value => !!value && value.enabled !== false },
periods: {
testPeriods: async () => clone(temporary),
resolvePeriodDates: async id => R.isTestPeriod(id) ? clone(temporary.find(p => p.periodId === id)) : R.periodDates(id)
}
};
globalThis.__activityUnlockMocks = mocks;
// The existing missing paymentProductId export otherwise prevents service.ts from loading.
// Only provide a fail-fast placeholder: these cases do not exercise payment offer refreshes.
const configShim = `export * from ${JSON.stringify(moduleUrl('goldMiner/config'))};`
+ ('paymentProductId' in R ? '' : 'export function paymentProductId() { throw Error("payment offer refresh is outside unlock tests"); }');
const modules = {
'@lafjs/cloud': 'export default globalThis.__activityUnlockMocks.cloud;',
'@/Utils': 'export default { checkToken: (a, b) => a === b };',
'@/goldMiner/config': configShim,
'@/activityConfig/store': 'export const { latest, isEnabled } = globalThis.__activityUnlockMocks.store;',
'@/goldMiner/testPeriods': 'export const { testPeriods, resolvePeriodDates } = globalThis.__activityUnlockMocks.periods;',
'@/cloudRise/periods': 'export const openPeriodForUser = () => null, openingRuleForUser = async () => ({});'
};
const hooks = registerHooks({ resolve(specifier, context, next) {
if (modules[specifier]) return { url: 'data:text/javascript,' + encodeURIComponent(modules[specifier]), shortCircuit: true };
if (specifier.startsWith('@/')) return { url: moduleUrl(specifier.slice(2)), shortCircuit: true };
return next(specifier, context);
} });
const S = await import(moduleUrl('goldMiner/service'));
const { activityList } = await import(moduleUrl('activityConfig/list'));
hooks.deregister();
const realNow = Date.now;
test.beforeEach(() => { players.clear(); temporary = []; writes = 0; Date.now = () => NOW; });
test.afterEach(() => { Date.now = realNow; });
test.after(() => { delete globalThis.__activityUnlockMocks; });
const user = levelAmount => ({ _id: 'unlock-user', levelAmount });
const period = () => S.periodById(R.calendar(NOW).periodId);
const victory = level => ({ periodId: R.calendar(NOW).periodId, eventId: 'win-' + level,
mode: 'main', outcome: 'win', clearedMainLevel: level });
function historicalPlayer() {
const configSnapshot = R.validateConfig(legacyConfig), dates = R.calendar(NOW);
const row = {
_id: S.playerId('unlock-user', dates.periodId), uid: 'unlock-user', ...dates, revision: 7,
configSnapshot, configVersion: configSnapshot.configVersion, configHash: R.key(configSnapshot),
qualifiedPassedLevel: 41, progressWins: 7, progressDedup: { entries: [] },
entitlementStatus: 'unlocked', paidOrderNo: 'existing-paid-order', claimedThrough: 1,
tasks: [{ taskId: 'task1', targetWins: 10, itemsSnapshot: [{ type: 'coin', count: 100 }], claimStatus: 'claimed', grantId: 'existing-grant' }],
settlementState: 'client_saved', settlementAcknowledgedAt: NOW - 1000
};
players.set(row._id, row); return row;
}
test('49 cleared levels remain locked despite a legacy 41-level configuration', async () => {
const before = clone(legacyVersion);
assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked');
await assert.rejects(S.ensurePlayer('unlock-user', user(49), await period()), { errorCode: 'NOT_QUALIFIED' });
assert.equal(players.size, 0); assert.equal(writes, 0); assert.deepEqual(legacyVersion, before);
});
test('50 cleared levels allow joining without rewriting legacy reward snapshots', async () => {
const info = await S.activityInfo('unlock-user', user(50));
assert.equal(info.status, 'purchasable'); assert.equal(info.progressWins, 0);
const row = [...players.values()][0];
assert.equal(row.qualifiedPassedLevel, 50);
assert.deepEqual(row.configSnapshot, R.validateConfig(legacyConfig));
assert.equal(row.configSnapshot.unlockPassedLevel, 41);
assert.equal(row.configHash, R.key(row.configSnapshot));
});
test('qualification win 50 is excluded and win 51 is the first activity win', async () => {
assert.deepEqual(await S.recordProgress('unlock-user', user(50), victory(50)),
{ activityCounted: false, reason: 'qualification_level', progressWins: 0 });
assert.equal(players.size, 0); assert.equal(writes, 0);
assert.deepEqual(await S.recordProgress('unlock-user', user(51), victory(51)),
{ activityCounted: true, duplicate: false, progressWins: 1 });
const row = [...players.values()][0];
assert.equal(row.configSnapshot.unlockPassedLevel, 41);
assert.equal(row.progressDedup.entries[0].businessKey, 'main:51');
});
test('regular and temporary lists advertise 50 without modifying configurations or history', async () => {
historicalPlayer();
for (const isTemporary of [false, true]) {
temporary = isTemporary ? [{ periodId: 'goldMiner:test:unlock', startsAt: NOW - 1000,
endsAt: NOW + R.DAY, enabled: true, configVersion: 'legacy41', configSnapshot: clone(legacyConfig) }] : [];
const before = clone({ legacyVersion, temporary, players: [...players.values()] });
const result = await activityList(NOW, user(49));
assert.equal(result.activities.length, 1);
assert.equal(result.activities[0].periodId, isTemporary ? temporary[0].periodId : R.calendar(NOW).periodId);
assert.equal(result.activities[0].unlockLevel, 50);
assert.deepEqual({ legacyVersion, temporary, players: [...players.values()] }, before);
assert.equal(writes, 0);
}
});
test('existing paid progress, claims and snapshots survive the new unlock threshold', async () => {
const row = historicalPlayer(), before = clone(row);
assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked');
const info = await S.activityInfo('unlock-user', user(50));
assert.equal(info.status, 'unlocked'); assert.equal(info.progressWins, 7);
assert.equal(info.tasks[0].claimStatus, 'claimed');
assert.deepEqual(players.get(row._id), before); assert.equal(writes, 0);
});