Files
chimera-Public/test/server/ch-0904-trophy-reconstruction-plan.test.js
T

107 lines
7.1 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const initSqlJs = require('sql.js');
const migration = require('../../src/database/migrations/0015-create-trophy-reconstruction-plans');
const { REQUIRED_CAPABILITIES, createSqliteTrophyPlanRepository, createTrophyReconstructionPlanService } = require('../../src/services/trophy-reconstruction-plan-service');
const PSN_HASH = 'a'.repeat(64);
const LOCAL_HASH = 'b'.repeat(64);
const MAP_HASH = 'c'.repeat(64);
const CONTEXT_HASH = 'd'.repeat(64);
const EARNED_AT = '2026-01-02T03:04:05.000Z';
function evidence() {
const psnSnapshot = { id: 'psn-proof', canonicalSha256: PSN_HASH, immutable: true, snapshot: { account: { accountId: '1234567890123456' } } };
const localSnapshot = { id: 'local-proof', canonicalSha256: LOCAL_HASH, immutable: true, snapshot: { provider: { firmware: '9.60' }, coverage: { status: 'complete', canConcludeAbsence: true } } };
const item = { state: 'psn_only', npCommunicationId: 'NPWR12345_00', platform: 'PS5', service: 'trophy2', groupId: 'default', trophyId: 2, sourceEarned: true, localEarned: false, sourceEarnedDateTime: EARNED_AT, exactAccountMapping: true, exactTitleMapping: true, exactTrophyMapping: true };
const diff = { readOnly: true, psnSnapshotId: psnSnapshot.id, localSnapshotId: localSnapshot.id, mappingId: 'mapping-1', mappingRevision: 1, mappingExact: true, mappingStateHash: MAP_HASH, contextHash: CONTEXT_HASH, items: [item] };
const adapter = { id: 'simulated-local-adapter', version: '1.0.0', timestampPolicy: 'preserve_exact', capabilities: [...REQUIRED_CAPABILITIES], firmwares: ['9.60'], services: ['trophy2'] };
const selections = [{ npCommunicationId: item.npCommunicationId, platform: item.platform, service: item.service, groupId: item.groupId, trophyId: item.trophyId }];
return { psnSnapshot, localSnapshot, diff, adapter, selections };
}
async function setup(nowRef = { value: 1800000000000 }) {
const SQL = await initSqlJs();
const db = new SQL.Database();
migration.up(db);
const repository = createSqliteTrophyPlanRepository({ db });
return { db, repository, service: createTrophyReconstructionPlanService({ repository, now: () => nowRef.value }), nowRef };
}
test('eligible PSN-earned exact evidence creates one immutable idempotent plan', async t => {
const env = await setup(); t.after(() => env.db.close());
const input = evidence();
const first = env.service.create(input);
env.nowRef.value += 1000;
const second = env.service.create(input);
assert.equal(first.id, second.id);
assert.equal(first.idempotencyKey, second.idempotencyKey);
assert.equal(env.repository.list().length, 1);
assert.deepEqual(first.psnSnapshot, { id: 'psn-proof', sha256: PSN_HASH });
assert.deepEqual(first.localSnapshot, { id: 'local-proof', sha256: LOCAL_HASH });
assert.equal(first.items[0].sourceEarned, true);
assert.equal(first.items[0].sourceEarnedDateTime, EARNED_AT);
assert.deepEqual(first.requiredBackup, { status: 'required', artifactRequired: true });
assert.throws(() => env.db.run('UPDATE trophy_reconstruction_plans SET adapter_version = ?', ['2']), /immutable/);
});
test('eligibility matrix blocks unearned, wrong-state, incomplete and incapable inputs', async t => {
const env = await setup(); t.after(() => env.db.close());
const cases = [
['STATE_NOT_PSN_ONLY', input => { input.diff.items[0].state = 'both'; }],
['SOURCE_NOT_EARNED', input => { input.diff.items[0].sourceEarned = false; }],
['ACCOUNT_MAPPING_NOT_EXACT', input => { input.diff.mappingExact = false; }],
['LOCAL_COVERAGE_INCOMPLETE', input => { input.localSnapshot.snapshot.coverage = { status: 'partial', canConcludeAbsence: false }; }],
['ADAPTER_CAPABILITY_MISSING:verified-backup', input => { input.adapter.capabilities = input.adapter.capabilities.filter(item => item !== 'verified-backup'); }],
['ADAPTER_FIRMWARE_UNSUPPORTED', input => { input.adapter.firmwares = ['10.00']; }],
['ADAPTER_SERVICE_UNSUPPORTED:trophy2', input => { input.adapter.services = ['trophy']; }],
];
for (const [reason, mutate] of cases) {
const input = evidence(); mutate(input);
const assessment = env.service.assess(input);
assert.equal(assessment.eligible, false, reason);
assert.ok(assessment.reasons.some(item => item === reason || item.startsWith(`${reason}:`)), `${reason}: ${assessment.reasons.join(',')}`);
}
});
test('arbitrary, duplicate and unearned trophy selections are rejected', async t => {
const env = await setup(); t.after(() => env.db.close());
const arbitrary = evidence(); arbitrary.selections[0].trophyId = 999;
assert.throws(() => env.service.create(arbitrary), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.some(reason => reason.startsWith('ARBITRARY_TROPHY_ID:')));
const duplicate = evidence(); duplicate.selections.push({ ...duplicate.selections[0] });
assert.throws(() => env.service.create(duplicate), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.includes('DUPLICATE_SELECTION'));
const unearned = evidence(); unearned.diff.items[0].sourceEarned = false;
assert.throws(() => env.service.create(unearned), error => error.code === 'TROPHY_PLAN_INELIGIBLE' && error.reasons.some(reason => reason.startsWith('SOURCE_NOT_EARNED:')));
});
test('snapshot, mapping, adapter and expiry changes invalidate frozen plans', async t => {
const env = await setup(); t.after(() => env.db.close());
const input = evidence();
const plan = env.service.create({ ...input, ttlMs: 60000 });
assert.equal(env.service.validate(plan, input).valid, true);
const sourceChanged = evidence(); sourceChanged.psnSnapshot = { ...sourceChanged.psnSnapshot, canonicalSha256: 'e'.repeat(64) };
assert.ok(env.service.validate(plan, sourceChanged).reasons.includes('SOURCE_SNAPSHOT_CHANGED'));
const localChanged = evidence(); localChanged.localSnapshot = { ...localChanged.localSnapshot, canonicalSha256: 'f'.repeat(64) };
assert.ok(env.service.validate(plan, localChanged).reasons.includes('LOCAL_SNAPSHOT_CHANGED'));
const mappingChanged = evidence(); mappingChanged.diff = { ...mappingChanged.diff, mappingStateHash: 'e'.repeat(64), contextHash: 'f'.repeat(64), mappingRevision: 2 };
assert.ok(env.service.validate(plan, mappingChanged).reasons.includes('MAPPING_OR_DIFF_CHANGED'));
const adapterChanged = evidence(); adapterChanged.adapter = { ...adapterChanged.adapter, version: '2.0.0' };
assert.ok(env.service.validate(plan, adapterChanged).reasons.includes('ADAPTER_CHANGED'));
env.nowRef.value = plan.expiresAt;
assert.ok(env.service.validate(plan, evidence()).reasons.includes('PLAN_EXPIRED'));
});
test('only preserve_exact timestamp policy can create a plan', async t => {
const env = await setup(); t.after(() => env.db.close());
for (const policy of ['preserve_date_only', 'current_time_only', 'unsupported']) {
const input = evidence(); input.adapter.timestampPolicy = policy;
const assessment = env.service.assess(input);
assert.equal(assessment.eligible, false);
assert.ok(assessment.reasons.includes('TIMESTAMP_POLICY_UNSUPPORTED'));
}
const exact = env.service.assess(evidence());
assert.equal(exact.eligible, true);
});