198 lines
7.2 KiB
JavaScript
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);
|
|
}); |