import test from 'node:test'; import assert from 'node:assert/strict'; import repairModule from '../repair-product-ids.cjs'; import { key } from '../config.ts'; const { repair, buildPatch, BACKUPS } = repairModule; const periodId = 'goldMiner:2026-10-01'; const copy = value => structuredClone(value); const snapshot = i => ({ vipLevel: i, productId: `gold_miner_vip${i}`, priceFen: 300, tasks: [{ taskId: 't1', targetWins: 1, items: [{ type: 'coin', count: 100 }] }] }); const row = () => ({ _id: 'p1', periodId, revision: 3, configSnapshot: snapshot(0), configHash: 'old', vipConfigSnapshot: { vipTiers: Array.from({ length: 6 }, (_, i) => snapshot(i)) }, entitlement: { sourcePeriodId: periodId, configSnapshot: snapshot(0), sourceOrderNo: 'paid-order', state: 'active' }, progressWins: 5, tasks: [{ taskId: 't1', claimStatus: 'claimed', grantId: 'grant1' }], settlementId: 'settlement1', entitlementStatus: 'unlocked', offerLockedAt: 123 }); function values(obj, path) { if (!path.length) return [obj]; if (Array.isArray(obj)) return obj.flatMap(item => values(item, path)); return values(obj?.[path[0]], path.slice(1)); } function matches(obj, query) { return Object.entries(query).every(([path, expected]) => { if (path === '$and') return expected.every(q => matches(obj, q)); if (path === '$or') return expected.some(q => matches(obj, q)); const actual = values(obj, path.split('.')); if (expected?.$in) return actual.some(v => expected.$in.includes(v)); if (expected && '$exists' in Object(expected)) return actual.some(v => v !== undefined) === expected.$exists; return actual.some(v => JSON.stringify(v) === JSON.stringify(expected)); }); } function mock(rows, options = {}) { const backups = [], writes = []; return { rows, backups, writes, getCollection(name) { assert.ok(['goldMinerPlayerPeriods', BACKUPS].includes(name), 'must not touch order/iosOrder/config collections'); if (name === BACKUPS) return { async updateOne(q, u) { if (options.failBackup) throw Error('backup failed'); backups.push(copy(u.$setOnInsert)); if (options.concurrent) rows[0].revision++; } }; return { find(q) { return { async toArray() { return copy(rows.filter(r => matches(r, q))); } }; }, async countDocuments(q) { return rows.filter(r => matches(r, q)).length; }, async updateOne(q, u) { writes.push(copy(u)); const r = rows.find(r => matches(r, q)); if (!r) return { modifiedCount: 0 }; for (const [path, value] of Object.entries(u.$set)) { const parts = path.split('.'); let target = r; for (const part of parts.slice(0, -1)) target = target[part]; target[parts.at(-1)] = value; } r.revision += u.$inc.revision; return { modifiedCount: 1 }; } }; } }; } test('product repair requires explicit scope and dry run makes no writes', async () => { const db = mock([row()]), before = copy(db.rows); await assert.rejects(repair(db), /periodIds/); await assert.rejects(repair(db, { periodIds: [periodId], apply: 'true' }), /boolean/); const result = await repair(db, { periodIds: [periodId] }); assert.equal(result.planned, 1); assert.equal(result.updated, 0); assert.deepEqual(db.rows, before); assert.equal(db.backups.length, 0); assert.equal(db.writes.length, 0); }); test('product repair patches all copies and exact service hash while preserving business state, then becomes a no-op', async () => { const original = row(), db = mock([copy(original)]); const result = await repair(db, { periodIds: [periodId], apply: true }); assert.equal(result.updated, 1); assert.equal(result.remaining, 0); const fixed = db.rows[0]; assert.equal(fixed.configSnapshot.productId, 'gold_miner_0'); assert.equal(fixed.entitlement.configSnapshot.productId, 'gold_miner_0'); assert.deepEqual(fixed.vipConfigSnapshot.vipTiers.map(t => t.productId), Array.from({ length: 6 }, (_, i) => `gold_miner_${i}`)); assert.equal(fixed.configHash, key(fixed.configSnapshot)); assert.equal(fixed.revision, 4); for (const field of ['progressWins', 'tasks', 'settlementId', 'entitlementStatus', 'offerLockedAt']) assert.deepEqual(fixed[field], original[field]); assert.equal(fixed.configSnapshot.priceFen, original.configSnapshot.priceFen); assert.deepEqual(db.backups[0].original, original); assert.equal((await repair(db, { periodIds: [periodId], apply: true })).planned, 0); }); test('product repair includes carried shells from the selected source period but excludes unrelated periods and IDs', async () => { const shell = { _id: 'shell', periodId: 'goldMiner:2026-10-15', revision: 0, entitlement: { sourcePeriodId: periodId, configSnapshot: snapshot(5) } }; const other = { ...row(), _id: 'other', periodId: 'goldMiner:2026-09-17', entitlement: null }; const db = mock([shell, other]); assert.equal((await repair(db, { periodIds: [periodId], apply: true })).updated, 1); assert.equal(shell.entitlement.configSnapshot.productId, 'gold_miner_5'); assert.equal(other.configSnapshot.productId, 'gold_miner_vip0'); assert.deepEqual(buildPatch({ configSnapshot: { productId: 'gold_miner_vip6' } }), {}); }); test('product repair stops on backup failure and skips concurrent writes without overwriting state', async () => { const db = mock([row()], { failBackup: true }), before = copy(db.rows); await assert.rejects(repair(db, { periodIds: [periodId], apply: true }), /backup failed/); assert.deepEqual(db.rows, before); const race = mock([row()], { concurrent: true }); const result = await repair(race, { periodIds: [periodId], apply: true }); assert.deepEqual(result.conflicts, ['p1']); assert.equal(result.remaining, 1); assert.equal(result.updated, 0); assert.equal(race.rows[0].configSnapshot.productId, 'gold_miner_vip0'); }); test('product repair preflight rejects tier mismatch or missing revision before any writes', async () => { const bad = row(); bad._id = 'bad'; bad.configSnapshot.vipLevel = 1; const db = mock([row(), bad]); await assert.rejects(repair(db, { periodIds: [periodId], apply: true }), /disagree/); assert.equal(db.backups.length, 0); assert.equal(db.writes.length, 0); const missing = row(); delete missing.revision; assert.throws(() => buildPatch(missing), /revision/); });