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); });