server/laf-cloud/functions/goldMiner/tests/repair-product-ids.test.mjs

104 lines
6.2 KiB
JavaScript

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