Files
chimera-Public/test/server/ch-0905-trophy-action.test.js

198 lines
7.2 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const initSqlJs = require('sql.js');
const migration = require('../../src/database/migrations/0016-create-trophy-reconstruction-journals');
const {
ACTION_CAPABILITIES,
createSqliteTrophyActionRepository,
createTrophyBackupService,
createTrophyReconstructionActionService,
createTrophyReconstructionSimulator,
createUnsupportedLiveTrophyAdapter,
} = require('../../src/services/trophy-reconstruction-action-service');
const FUTURE = '2099-01-01T00:00:00.000Z';
function item(trophyId, earnedDateTime = `2026-01-${String(trophyId).padStart(2, '0')}T03:04:05.000Z`) {
return {
npCommunicationId: 'NPWR12345_00',
platform: 'PS5',
service: 'trophy2',
groupId: 'default',
trophyId,
sourceEarned: true,
sourceEarnedDateTime: earnedDateTime,
expectedLocalPreState: { earned: false, earnedDateTime: null },
};
}
function plan(items = [item(2)]) {
return {
id: `trophy-plan-${'a'.repeat(64)}`,
planSha256: 'a'.repeat(64),
idempotencyKey: 'b'.repeat(64),
immutable: true,
createdAt: '2026-07-22T00:00:00.000Z',
expiresAt: FUTURE,
adapter: {
id: 'simulated-local-adapter',
version: '1.0.0',
timestampPolicy: 'preserve_exact',
capabilities: [...ACTION_CAPABILITIES],
firmwares: ['9.60'],
services: ['trophy2'],
},
target: { firmware: '9.60', service: 'trophy2' },
items,
requiredBackup: { status: 'required', artifactRequired: true },
};
}
function initialState(items) {
return items.map((entry) => ({
npCommunicationId: entry.npCommunicationId,
platform: entry.platform,
service: entry.service,
groupId: entry.groupId,
trophyId: entry.trophyId,
earned: false,
earnedDateTime: null,
}));
}
async function harness(t, options = {}) {
const SQL = await initSqlJs();
const db = new SQL.Database();
migration.up(db);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0905-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
let sequence = 0;
const now = () => 1784721600000;
const repository = createSqliteTrophyActionRepository({
db,
now,
idFactory: () => `run-${++sequence}`,
});
const backupService = createTrophyBackupService({
backupRoot: root,
repository,
now,
idFactory: () => `backup-${++sequence}`,
});
const selectedPlan = options.plan || plan();
const adapter = createTrophyReconstructionSimulator({
initialItems: initialState(selectedPlan.items),
...options.simulator,
});
const service = createTrophyReconstructionActionService({
repository,
backupService,
now,
planValidator: async () => ({ valid: true, reasons: [] }),
});
return { adapter, backupService, db, repository, selectedPlan, service };
}
function confirmation(selectedPlan) {
return `RECONSTRUCT LOCAL TROPHIES ${selectedPlan.items.length}`;
}
test('verified backup is mandatory and backup failure prevents all mutation', async (t) => {
const context = await harness(t, { simulator: { backupFailure: true } });
await assert.rejects(
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
(error) => error.code === 'TROPHY_BACKUP_FAILED',
);
assert.equal(context.adapter.metrics().applyCalls, 0);
assert.deepEqual(context.repository.listRuns(context.selectedPlan.id), []);
});
test('successful apply uses frozen items and rerun is idempotent', async (t) => {
const context = await harness(t);
const first = await context.service.execute({
plan: context.selectedPlan,
adapter: context.adapter,
confirmation: confirmation(context.selectedPlan),
});
assert.equal(first.status, 'completed');
assert.equal(first.postVerification.verified, true);
assert.equal(context.adapter.metrics().applyCalls, context.selectedPlan.items.length);
const storedBackup = await context.backupService.load(first.backupId);
assert.equal(storedBackup.verified, true);
assert.throws(
() => context.db.run("UPDATE trophy_reconstruction_backups SET manifest_json = '{}'"),
/immutable/i,
);
const second = await context.service.execute({
plan: context.selectedPlan,
adapter: context.adapter,
confirmation: confirmation(context.selectedPlan),
});
assert.equal(second.idempotent, true);
assert.equal(second.runId, first.runId);
assert.equal(context.adapter.metrics().applyCalls, context.selectedPlan.items.length);
});
test('mid-action failure journals items and restores the verified backup', async (t) => {
const selectedPlan = plan([item(2), item(3)]);
const context = await harness(t, {
plan: selectedPlan,
simulator: { failAtItem: 'NPWR12345_00:PS5:trophy2:default:3' },
});
await assert.rejects(
context.service.execute({ plan: selectedPlan, adapter: context.adapter, confirmation: confirmation(selectedPlan) }),
(error) => error.code === 'TROPHY_ACTION_FAILED' && error.rollbackStatus === 'rolled_back',
);
assert.ok(context.adapter.state().every(([, entry]) => entry.earned === false));
const [run] = context.repository.listRuns(selectedPlan.id);
assert.equal(run.status, 'rolled_back');
const journal = run.items;
assert.equal(journal[0].status, 'verified');
assert.equal(journal[1].status, 'failed');
});
test('post-scan mismatch triggers rollback instead of reporting success', async (t) => {
const context = await harness(t, { simulator: { postScanMismatch: true } });
await assert.rejects(
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
(error) => error.code === 'TROPHY_ACTION_FAILED' && error.rollbackStatus === 'rolled_back',
);
assert.ok(context.adapter.state().every(([, entry]) => entry.earned === false));
});
test('unsupported exact timestamp capability is rejected before backup or mutation', async (t) => {
const context = await harness(t, { simulator: { timestampPolicy: 'current_time_only' } });
await assert.rejects(
context.service.execute({ plan: context.selectedPlan, adapter: context.adapter, confirmation: confirmation(context.selectedPlan) }),
(error) => error.code === 'TROPHY_TIMESTAMP_UNSUPPORTED',
);
assert.equal(context.adapter.metrics().applyCalls, 0);
assert.deepEqual(context.repository.listRuns(context.selectedPlan.id), []);
});
test('action service has no network or PSN provider dependency and live binding is explicitly unavailable', () => {
const source = fs.readFileSync(
path.join(__dirname, '../../src/services/trophy-reconstruction-action-service.js'),
'utf8',
);
assert.doesNotMatch(source, /node:https?|node-fetch|axios|\bfetch\s*\(/);
assert.doesNotMatch(source, /psn-provider|psn-client/i);
const live = createUnsupportedLiveTrophyAdapter();
assert.equal(live.status().available, false);
assert.match(live.status().reason, /unsupported|version-pinned/i);
const status = fs.readFileSync(
path.join(__dirname, '../../docs/autonomous/CH-0905_TROPHY_ADAPTER_STATUS.md'),
'utf8',
);
assert.match(status, /unsupported/i);
assert.match(status, /version-pinned/i);
});