'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const { canonicalCatalogDigest } = require('../../src/services/legacy-detached-trophy-preflight-service'); const { CONSENT, canonicalTrophyDigest, confirmationPhrase, createInactiveLegacyTrophyUnlockService, } = require('../../src/services/inactive-legacy-trophy-unlock-service'); const game = { title: 'Inactive fixture game', trophies: [ { id: 0, grade: 'bronze', name: 'Locked', description: 'Target', hidden: false, earned: false }, { id: 1, grade: 'silver', name: 'Earned', description: 'Existing', hidden: false, earned: true }, ], }; const catalogSha256 = canonicalCatalogDigest(game); const trophySha256 = canonicalTrophyDigest(catalogSha256, game.trophies[0]); const backupSha256 = 'b'.repeat(64); function dependencies(overrides = {}) { const calls = []; return { calls, values: { installedCatalog: async () => ({ games: [game] }), mutationStatus: async () => ({ available: true }), detachedPreflight: async (input) => { calls.push(['preflight', input]); return { ready: true, cleanupComplete: true, targetInactive: true }; }, backup: async (input) => { calls.push(['backup', input]); return { complete: true, sha256: backupSha256 }; }, dryRun: async (input) => { calls.push(['dry-run', input]); return { ready: true, writes: 0, unlockCalls: 0 }; }, unlockAdapter: async (input) => { calls.push(['unlock', input]); return { status: 'unlocked', catalogSha256, trophySha256, targetInactive: true, unlockCalls: 1, psnRequests: 0, contextDestroyed: true, handleDestroyed: true, filesPersisted: 0, }; }, postScan: async (input) => { calls.push(['post-scan', input]); return { selectedUnlocked: true, changedTrophies: 1, otherChanges: 0 }; }, rollback: async (input) => { calls.push(['rollback', input]); return { verified: true }; }, audit: async (input) => calls.push(['audit', input]), ...overrides, }, }; } function input() { return { catalogSha256, trophySha256, confirmation: confirmationPhrase(catalogSha256, trophySha256), }; } test('inactive unlock is unavailable until a native mutation adapter is proven', async () => { const service = createInactiveLegacyTrophyUnlockService({ installedCatalog: async () => ({ games: [game] }), }); assert.deepEqual(await service.status(), { available: false, exactOne: true, inactiveOnly: true, backupRequired: true, dryRunRequired: true, rollbackRequired: true, reasonCode: 'NATIVE_MUTATION_ADAPTER_UNAVAILABLE', }); await assert.rejects(service.unlockOne(input()), /unavailable/i); }); test('inactive unlock resolves an opaque exact-one selection and verifies the full recovery flow', async () => { const fixture = dependencies(); const service = createInactiveLegacyTrophyUnlockService(fixture.values); const result = await service.unlockOne(input()); assert.equal(result.status, 'unlocked'); assert.equal(result.postScanVerified, true); assert.equal(result.backupSha256, backupSha256); assert.deepEqual(fixture.calls.map(([name]) => name), [ 'preflight', 'backup', 'dry-run', 'unlock', 'post-scan', 'audit', ]); const adapterInput = fixture.calls.find(([name]) => name === 'unlock')[1]; assert.equal(adapterInput.consent, CONSENT); assert.equal(adapterInput.trophyId, 0); assert.equal(Object.hasOwn(adapterInput, 'titleId'), false); assert.equal(Object.hasOwn(adapterInput, 'npCommunicationId'), false); }); test('inactive unlock rejects free ids, stale digests, earned trophies and wrong confirmation', async () => { const fixture = dependencies(); const service = createInactiveLegacyTrophyUnlockService(fixture.values); await assert.rejects(service.unlockOne({ ...input(), trophyId: 99 }), /forbidden input field/i); await assert.rejects(service.unlockOne({ ...input(), trophySha256: '0'.repeat(64) }), /stale or ambiguous/i); await assert.rejects(service.unlockOne({ ...input(), confirmation: 'yes' }), /exact inactive trophy confirmation/i); const earnedSha256 = canonicalTrophyDigest(catalogSha256, game.trophies[1]); await assert.rejects(service.unlockOne({ catalogSha256, trophySha256: earnedSha256, confirmation: confirmationPhrase(catalogSha256, earnedSha256), }), /already unlocked/i); assert.equal(fixture.calls.length, 0); }); test('inactive unlock rolls back when exact-one post-scan proof fails', async () => { const fixture = dependencies({ postScan: async () => ({ selectedUnlocked: true, changedTrophies: 2, otherChanges: 1 }), }); const service = createInactiveLegacyTrophyUnlockService(fixture.values); await assert.rejects(service.unlockOne(input()), /exact-one trophy mutation/i); assert.equal(fixture.calls.some(([name]) => name === 'rollback'), true); const audit = fixture.calls.find(([name]) => name === 'audit')[1]; assert.equal(audit.outcome, 'rolled-back'); }); test('inactive unlock rejects an unverifiable backup or side-effecting dry-run before mutation', async () => { const noBackup = dependencies({ backup: async () => ({ complete: false }) }); await assert.rejects( createInactiveLegacyTrophyUnlockService(noBackup.values).unlockOne(input()), /verified trophy backup/i, ); const dirtyDryRun = dependencies({ dryRun: async () => ({ ready: true, writes: 1, unlockCalls: 0 }) }); await assert.rejects( createInactiveLegacyTrophyUnlockService(dirtyDryRun.values).unlockOne(input()), /dry-run did not pass/i, ); assert.equal(dirtyDryRun.calls.some(([name]) => name === 'unlock'), false); });