'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const initSqlJs = require('sql.js'); const migration12 = require('../../src/database/migrations/0012-create-trophy-psn-snapshots'); const migration13 = require('../../src/database/migrations/0013-create-trophy-local-snapshots'); const migration14 = require('../../src/database/migrations/0014-create-trophy-account-mappings'); const { createJsonTrophySnapshotProvider, createSqliteTrophySnapshotRepository } = require('../../src/services/trophy-snapshot-service'); const { createSimulatedLocalTrophyProvider, createSqliteLocalTrophyRepository } = require('../../src/services/local-trophy-inventory-service'); const { DIFF_STATES, createSqliteTrophyMappingRepository, createTrophyDiffService } = require('../../src/services/trophy-mapping-diff-service'); const HASH_A = 'a'.repeat(64); const HASH_B = 'b'.repeat(64); const TIME = '2026-01-02T03:04:05.000Z'; function psnPayload(name = 'English title') { return { schemaVersion: 1, provider: { id: 'json-proof', version: '1' }, account: { accountId: '1234567890123456', onlineId: 'Player_One' }, context: { region: 'BE', language: 'en-BE' }, fetchedAt: '2026-07-20T12:00:00Z', titles: [{ npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', name, platform: 'PS5', service: 'trophy2', groups: [{ groupId: 'default', name: 'Base' }], trophies: [ { groupId: 'default', trophyId: 1, name: 'Same', grade: 'gold', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A }, { groupId: 'default', trophyId: 2, name: 'Source earned', grade: 'silver', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A }, { groupId: 'default', trophyId: 3, name: 'Local earned', grade: 'bronze', hidden: false, earned: false, progress: 0, definitionHash: HASH_A }, { groupId: 'default', trophyId: 4, name: 'Metadata differs', grade: 'silver', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A }, { groupId: 'default', trophyId: 5, name: 'Neither', grade: 'bronze', hidden: false, earned: false, progress: 0, definitionHash: HASH_A }, { groupId: 'default', trophyId: 7, name: 'Missing exact local ID', grade: 'gold', hidden: false, earned: true, earnedDateTime: TIME, progress: 100, definitionHash: HASH_A }, ] }], }; } function localPayload(status = 'complete') { return { schemaVersion: 1, provider: { id: 'local-simulator', version: '1', firmware: '9.60', capabilities: ['profile-identity', 'list-title-sets', 'list-earned-trophies', 'earned-timestamps'] }, profile: { profileId: 'profile-1234', localUserId: 'user-1234', accountId: '1234567890123456' }, coverage: { status, completedScopes: ['NPWR12345_00'], failedScopes: status === 'complete' ? [] : ['NPWR99999_00'] }, observedAt: '2026-07-21T12:00:00Z', titles: [{ npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', platform: 'PS5', service: 'trophy2', trophies: [ { groupId: 'default', trophyId: 1, earned: true, earnedDateTime: TIME, definitionHash: HASH_A }, { groupId: 'default', trophyId: 2, earned: false, definitionHash: HASH_A }, { groupId: 'default', trophyId: 3, earned: true, earnedDateTime: TIME, definitionHash: HASH_A }, { groupId: 'default', trophyId: 4, earned: true, earnedDateTime: '2026-01-03T03:04:05Z', definitionHash: HASH_B }, { groupId: 'default', trophyId: 5, earned: false, definitionHash: HASH_A }, { groupId: 'default', trophyId: 6, earned: true, earnedDateTime: TIME, definitionHash: HASH_A }, ] }], }; } async function setup() { const SQL = await initSqlJs(); const db = new SQL.Database(); migration12.up(db); migration13.up(db); migration14.up(db); let now = 1800000000000; const psnProvider = createJsonTrophySnapshotProvider({ repository: createSqliteTrophySnapshotRepository({ db }), now: () => now++ }); const localProvider = createSimulatedLocalTrophyProvider({ repository: createSqliteLocalTrophyRepository({ db }), now: () => now++ }); const mappings = createSqliteTrophyMappingRepository({ db, now: () => now++, idFactory: () => 'mapping-1' }); const mapping = mappings.create({ psnAccountId: '1234567890123456', localProfileId: 'profile-1234', selectedByUser: true, confirmation: 'MAP TROPHY ACCOUNT', reason: 'User selected the matching local profile.' }); return { db, psnProvider, localProvider, mappings, mapping, diff: createTrophyDiffService({ mappingRepository: mappings }) }; } test('exact account/title/trophy mapping produces every evidence diff state', async t => { const env = await setup(); t.after(() => env.db.close()); const psn = env.psnProvider.authenticateOrImport(psnPayload()); const local = env.localProvider.fetchSnapshot(localPayload('complete')); const result = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id }); const byId = Object.fromEntries(result.items.map(item => [item.trophyId, item.state])); assert.deepEqual(byId, { 1: 'both', 2: 'psn_only', 3: 'local_only', 4: 'metadata_mismatch', 5: 'both', 6: 'local_only', 7: 'unmappable' }); assert.equal(result.mappingExact, true); assert.equal(result.readOnly, true); assert.deepEqual(new Set(Object.keys(result.counts)), new Set(DIFF_STATES)); const partial = env.localProvider.fetchSnapshot(localPayload('partial')); const partialResult = env.diff.compare({ psnSnapshot: psn, localSnapshot: partial, mappingId: env.mapping.id }); assert.equal(partialResult.items.find(item => item.trophyId === 2).state, 'unknown'); }); test('missing, mismatched and ambiguous exact identities never fall back to names or position', async t => { const env = await setup(); t.after(() => env.db.close()); const psn = env.psnProvider.authenticateOrImport(psnPayload()); const local = env.localProvider.fetchSnapshot(localPayload()); const missing = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: 'missing' }); assert.equal(missing.mappingReason, 'mapping_missing'); assert.ok(missing.items.every(item => item.state === 'unmappable')); const ambiguous = { ...psn, snapshot: JSON.parse(JSON.stringify(psn.snapshot)) }; ambiguous.snapshot.titles[0].trophies.push({ ...ambiguous.snapshot.titles[0].trophies[0] }); assert.throws(() => env.diff.compare({ psnSnapshot: ambiguous, localSnapshot: local, mappingId: env.mapping.id }), error => error.code === 'TROPHY_DIFF_AMBIGUOUS'); }); test('localized names are diagnostic only and do not change exact diff states', async t => { const env = await setup(); t.after(() => env.db.close()); const local = env.localProvider.fetchSnapshot(localPayload()); const english = env.diff.compare({ psnSnapshot: env.psnProvider.authenticateOrImport(psnPayload('English title')), localSnapshot: local, mappingId: env.mapping.id }); const localizedPayload = psnPayload('Titre francais'); localizedPayload.titles[0].trophies.forEach(item => { item.name = `Nom ${item.trophyId}`; }); const localized = env.diff.compare({ psnSnapshot: env.psnProvider.authenticateOrImport(localizedPayload), localSnapshot: local, mappingId: env.mapping.id }); assert.deepEqual(english.items.map(item => [item.trophyId, item.state]), localized.items.map(item => [item.trophyId, item.state])); }); test('mapping revocation changes state fingerprint and invalidates exact comparison', async t => { const env = await setup(); t.after(() => env.db.close()); const psn = env.psnProvider.authenticateOrImport(psnPayload()); const local = env.localProvider.fetchSnapshot(localPayload()); const before = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id }); assert.throws(() => env.mappings.revoke(env.mapping.id, { confirmation: 'wrong', reason: 'test' }), error => error.code === 'TROPHY_MAPPING_CONFIRMATION'); const revoked = env.mappings.revoke(env.mapping.id, { confirmation: 'REVOKE TROPHY MAPPING', reason: 'Local profile selection changed.' }); const after = env.diff.compare({ psnSnapshot: psn, localSnapshot: local, mappingId: env.mapping.id }); assert.equal(revoked.active, false); assert.notEqual(before.mappingStateHash, after.mappingStateHash); assert.notEqual(before.contextHash, after.contextHash); assert.equal(after.mappingReason, 'mapping_revoked'); assert.ok(after.items.every(item => item.state === 'unmappable')); }); test('mapping implementation compares exact IDs and keeps names diagnostic-only', () => { const source = fs.readFileSync(path.resolve(__dirname, '../../src/services/trophy-mapping-diff-service.js'), 'utf8'); assert.match(source, /npCommunicationId/); assert.match(source, /groupId/); assert.match(source, /trophyId/); assert.doesNotMatch(source, /\.name\s*===|localeCompare\([^)]*\.name/); assert.match(source, /diagnosticName/); });