104 lines
6.2 KiB
JavaScript
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/);
|
|
});
|