mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-05 20:31:54 +00:00
Merge branch 'main' into test/upload-validation-15691315589845316357
This commit is contained in:
33 files changed
+9337
-1079
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cmdBackup, listBackups } from '../src/cmd-backup.mjs';
|
||||
|
||||
const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-backup-'));
|
||||
|
||||
// Helper to setup dummy state files
|
||||
function setupStateFiles(cwd) {
|
||||
const memoryDir = path.join(cwd, '.scheduler-memory');
|
||||
const logsDir = path.join(cwd, 'task-logs', 'daily');
|
||||
|
||||
fs.mkdirSync(memoryDir, { recursive: true });
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(memoryDir, 'memory-store.json'), '{"dummy": "memory"}');
|
||||
fs.writeFileSync(path.join(logsDir, '.scheduler-run-state.json'), '{"dummy": "state"}');
|
||||
}
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tempBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('cmdBackup creates backup directory and manifest', async () => {
|
||||
const cwd = path.join(tempBase, 'scenario-1');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
setupStateFiles(cwd);
|
||||
|
||||
const { backupDir } = await cmdBackup({ cwd });
|
||||
|
||||
assert.ok(fs.existsSync(backupDir), 'Backup directory created');
|
||||
assert.ok(fs.existsSync(path.join(backupDir, 'backup-manifest.json')), 'Manifest created');
|
||||
|
||||
const manifestContent = JSON.parse(fs.readFileSync(path.join(backupDir, 'backup-manifest.json'), 'utf8'));
|
||||
assert.strictEqual(manifestContent.backupDir, backupDir);
|
||||
assert.ok(manifestContent.createdAt);
|
||||
|
||||
// Verify files are copied
|
||||
const backupMemory = path.join(backupDir, '.scheduler-memory__memory-store.json');
|
||||
assert.ok(fs.existsSync(backupMemory), 'Memory file copied');
|
||||
|
||||
// captured should contain the 2 files
|
||||
assert.strictEqual(manifestContent.captured.length, 2);
|
||||
});
|
||||
|
||||
test('cmdBackup handles missing files gracefully', async () => {
|
||||
const cwd = path.join(tempBase, 'scenario-2');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
// Do NOT setup files
|
||||
|
||||
const { manifest } = await cmdBackup({ cwd });
|
||||
|
||||
assert.strictEqual(manifest.captured.length, 0);
|
||||
assert.strictEqual(manifest.skipped.length, 2);
|
||||
assert.strictEqual(manifest.skipped[0].reason, 'not found');
|
||||
});
|
||||
|
||||
test('cmdBackup respects output override', async () => {
|
||||
const cwd = path.join(tempBase, 'scenario-3');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
setupStateFiles(cwd);
|
||||
|
||||
const outputDir = 'custom-backup-location';
|
||||
const { backupDir } = await cmdBackup({ cwd, output: outputDir });
|
||||
|
||||
const expectedPath = path.resolve(cwd, outputDir);
|
||||
assert.strictEqual(backupDir, expectedPath);
|
||||
assert.ok(fs.existsSync(expectedPath));
|
||||
assert.ok(fs.existsSync(path.join(expectedPath, 'backup-manifest.json')), 'Manifest in custom output');
|
||||
});
|
||||
|
||||
test('listBackups lists created backups', async () => {
|
||||
const cwd = path.join(tempBase, 'scenario-4');
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
setupStateFiles(cwd);
|
||||
|
||||
// Create two backups
|
||||
await cmdBackup({ cwd });
|
||||
|
||||
// Ensure we have a slight delay or just rely on execution time for timestamp diff
|
||||
// Windows/Filesystem might have low resolution, but listBackups sorts by ISO string in manifest
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
await cmdBackup({ cwd });
|
||||
|
||||
const backups = await listBackups({ cwd });
|
||||
assert.strictEqual(backups.length, 2);
|
||||
|
||||
// Verify sorting (newest first)
|
||||
const t0 = new Date(backups[0].createdAt).getTime();
|
||||
const t1 = new Date(backups[1].createdAt).getTime();
|
||||
assert.ok(t0 >= t1, 'Backups sorted newest first');
|
||||
|
||||
// Verify properties
|
||||
assert.ok(backups[0].id);
|
||||
assert.ok(backups[0].backupDir);
|
||||
assert.ok(Array.isArray(backups[0].captured));
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdProposal } from '../src/cmd-proposal.mjs';
|
||||
import { ExitError } from '../src/errors.mjs';
|
||||
|
||||
describe('cmdProposal', () => {
|
||||
const noop = () => {};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a proposal successfully', async () => {
|
||||
const logs = [];
|
||||
const errors = [];
|
||||
const mockCreate = async (args) => {
|
||||
return { id: 'prop-123', status: 'open', ...args };
|
||||
};
|
||||
const mockReadFile = async () => 'file-content';
|
||||
|
||||
const deps = {
|
||||
createProposal: mockCreate,
|
||||
readFile: mockReadFile,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
const args = {
|
||||
agent: 'test-agent',
|
||||
target: 'src/foo.js',
|
||||
contentFile: 'new-content.txt',
|
||||
reason: 'fix bug',
|
||||
};
|
||||
|
||||
await cmdProposal('create', args, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
assert.strictEqual(errors.length, 0);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.id, 'prop-123');
|
||||
assert.strictEqual(output.newContent, 'file-content');
|
||||
assert.strictEqual(output.agent, 'test-agent');
|
||||
});
|
||||
|
||||
it('should throw if arguments are missing', async () => {
|
||||
const errors = [];
|
||||
const deps = {
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('create', {}, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing arguments'
|
||||
);
|
||||
assert.ok(errors[0].includes('Usage:'));
|
||||
});
|
||||
|
||||
it('should throw if file read fails', async () => {
|
||||
const errors = [];
|
||||
const mockReadFile = async () => { throw new Error('File not found'); };
|
||||
const deps = {
|
||||
readFile: mockReadFile,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
const args = {
|
||||
agent: 'test-agent',
|
||||
target: 'src/foo.js',
|
||||
contentFile: 'missing.txt',
|
||||
reason: 'fix bug',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('create', args, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'File read error'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to read content file'));
|
||||
});
|
||||
|
||||
it('should throw if createProposal fails', async () => {
|
||||
const errors = [];
|
||||
const mockCreate = async () => { throw new Error('DB error'); };
|
||||
const mockReadFile = async () => 'content';
|
||||
const deps = {
|
||||
createProposal: mockCreate,
|
||||
readFile: mockReadFile,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
const args = {
|
||||
agent: 'test-agent',
|
||||
target: 'src/foo.js',
|
||||
contentFile: 'content.txt',
|
||||
reason: 'fix bug',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('create', args, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Proposal creation failed'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to create proposal'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list all proposals', async () => {
|
||||
const logs = [];
|
||||
const mockList = async () => [{ id: '1', status: 'open' }, { id: '2', status: 'closed' }];
|
||||
const deps = {
|
||||
listProposals: mockList,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: noop,
|
||||
};
|
||||
|
||||
await cmdProposal('list', {}, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.length, 2);
|
||||
});
|
||||
|
||||
it('should filter proposals by status', async () => {
|
||||
const logs = [];
|
||||
const mockList = async () => [{ id: '1', status: 'open' }, { id: '2', status: 'closed' }];
|
||||
const deps = {
|
||||
listProposals: mockList,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: noop,
|
||||
};
|
||||
|
||||
await cmdProposal('list', { status: 'open' }, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.length, 1);
|
||||
assert.strictEqual(output[0].id, '1');
|
||||
});
|
||||
|
||||
it('should handle list failure', async () => {
|
||||
const errors = [];
|
||||
const mockList = async () => { throw new Error('List error'); };
|
||||
const deps = {
|
||||
listProposals: mockList,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('list', {}, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'List failed'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to list proposals'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('apply', () => {
|
||||
it('should apply a proposal', async () => {
|
||||
const logs = [];
|
||||
const mockApply = async (id) => ({ id, status: 'applied' });
|
||||
const deps = {
|
||||
applyProposal: mockApply,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: noop,
|
||||
};
|
||||
|
||||
await cmdProposal('apply', { id: 'prop-1' }, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.status, 'applied');
|
||||
assert.strictEqual(output.id, 'prop-1');
|
||||
});
|
||||
|
||||
it('should throw if id is missing', async () => {
|
||||
const errors = [];
|
||||
const deps = {
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('apply', {}, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing id'
|
||||
);
|
||||
assert.ok(errors[0].includes('Usage:'));
|
||||
});
|
||||
|
||||
it('should handle apply failure', async () => {
|
||||
const errors = [];
|
||||
const mockApply = async () => { throw new Error('Apply error'); };
|
||||
const deps = {
|
||||
applyProposal: mockApply,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('apply', { id: 'prop-1' }, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Apply failed'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to apply proposal'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should reject a proposal', async () => {
|
||||
const logs = [];
|
||||
const mockReject = async (id, reason) => ({ id, status: 'rejected', reason });
|
||||
const deps = {
|
||||
rejectProposal: mockReject,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: noop,
|
||||
};
|
||||
|
||||
await cmdProposal('reject', { id: 'prop-1', reason: 'bad code' }, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.status, 'rejected');
|
||||
assert.strictEqual(output.reason, 'bad code');
|
||||
});
|
||||
|
||||
it('should throw if arguments are missing', async () => {
|
||||
const errors = [];
|
||||
const deps = {
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('reject', { id: 'prop-1' }, deps), // missing reason
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing arguments'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle reject failure', async () => {
|
||||
const errors = [];
|
||||
const mockReject = async () => { throw new Error('Reject error'); };
|
||||
const deps = {
|
||||
rejectProposal: mockReject,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('reject', { id: 'prop-1', reason: 'bad' }, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Reject failed'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to reject proposal'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
it('should show a proposal', async () => {
|
||||
const logs = [];
|
||||
const mockGet = async (id) => ({ id, content: 'stuff' });
|
||||
const deps = {
|
||||
getProposal: mockGet,
|
||||
log: (msg) => logs.push(msg),
|
||||
error: noop,
|
||||
};
|
||||
|
||||
await cmdProposal('show', { id: 'prop-1' }, deps);
|
||||
|
||||
assert.strictEqual(logs.length, 1);
|
||||
const output = JSON.parse(logs[0]);
|
||||
assert.strictEqual(output.id, 'prop-1');
|
||||
assert.strictEqual(output.content, 'stuff');
|
||||
});
|
||||
|
||||
it('should throw if id is missing', async () => {
|
||||
const errors = [];
|
||||
const deps = {
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('show', {}, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Missing id'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle show failure', async () => {
|
||||
const errors = [];
|
||||
const mockGet = async () => { throw new Error('Show error'); };
|
||||
const deps = {
|
||||
getProposal: mockGet,
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('show', { id: 'prop-1' }, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Show failed'
|
||||
);
|
||||
assert.ok(errors[0].includes('Failed to show proposal'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown subcommand', () => {
|
||||
it('should throw ExitError for unknown subcommand', async () => {
|
||||
const errors = [];
|
||||
const deps = {
|
||||
log: noop,
|
||||
error: (msg) => errors.push(msg),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => cmdProposal('foo', {}, deps),
|
||||
(err) => err instanceof ExitError && err.code === 1 && err.message === 'Unknown subcommand'
|
||||
);
|
||||
assert.ok(errors[0].includes('Unknown proposal subcommand'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
import { _resetTorchConfigCache } from '../src/torch-config.mjs';
|
||||
|
||||
@@ -147,4 +148,43 @@ test('Dashboard Authentication', async (t) => {
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('validates correctly using scrypt hashed credentials', async () => {
|
||||
// Generate a valid scrypt hash
|
||||
const password = 'admin:hashedpassword';
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, 64);
|
||||
const storedAuth = `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||
|
||||
process.env.TORCH_DASHBOARD_AUTH = storedAuth;
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const makeRequest = (credentials) => {
|
||||
const auth = Buffer.from(credentials).toString('base64');
|
||||
return new Promise((resolve) => {
|
||||
http.get({
|
||||
host: '127.0.0.1',
|
||||
port: testPort,
|
||||
path: '/dashboard/',
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
}, resolve);
|
||||
});
|
||||
};
|
||||
|
||||
// Correct password
|
||||
const resCorrect = await makeRequest(password);
|
||||
assert.notStrictEqual(resCorrect.statusCode, 401, 'correct hashed credentials must not be rejected');
|
||||
|
||||
// Incorrect password
|
||||
const resIncorrect = await makeRequest('admin:wrong');
|
||||
assert.strictEqual(resIncorrect.statusCode, 401, 'incorrect hashed credentials must be rejected');
|
||||
} finally {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@ test('cmdInit should validate install directory name', async (t) => {
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
assert.doesNotMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ test('cmdInit should validate install directory name', async (t) => {
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
assert.doesNotMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,7 +119,7 @@ test('cmdInit should validate install directory name', async (t) => {
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
assert.doesNotMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { publishLock, _resetRelayHealthState } from '../src/lock-ops.mjs';
|
||||
|
||||
function createScriptedPool(scriptedOutcomesByAttempt, relaysSeen) {
|
||||
let publishCallCount = 0;
|
||||
return {
|
||||
publish: (relays) => {
|
||||
publishCallCount += 1;
|
||||
relaysSeen.push({ publishAttempt: publishCallCount, relays: [...relays] });
|
||||
const attemptOutcomes = scriptedOutcomesByAttempt[publishCallCount - 1] || {};
|
||||
return relays.map((relay) => {
|
||||
const outcome = attemptOutcomes[relay] || { ok: true };
|
||||
return outcome.ok ? Promise.resolve('ok') : Promise.reject(new Error(outcome.message));
|
||||
});
|
||||
},
|
||||
close: () => {},
|
||||
getPublishCallCount: () => publishCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
const parseTelemetry = (logs) => logs.map((line) => JSON.parse(line));
|
||||
|
||||
describe('publishLock retry behavior', { concurrency: false }, () => {
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it('succeeds immediately on first attempt without retries', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const event = { id: 'evt-immediate' };
|
||||
const result = await publishLock(['wss://relay-a'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.strictEqual(metEvent.publishAttempt, 1);
|
||||
});
|
||||
|
||||
it('retries a transient failure then succeeds within retry budget with bounded jitter', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'connection reset by peer' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const result = await publishLock(['wss://relay-a'], { id: 'evt-retry-success' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 500,
|
||||
randomFn: () => 0.25,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-retry-success');
|
||||
assert.deepStrictEqual(sleepCalls, [25]);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 2);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 1);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
});
|
||||
|
||||
it('returns terminal relay_publish_quorum_failure after exhausting retry budget', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const randomValues = [0.1, 0.9];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 250,
|
||||
randomFn: () => randomValues.shift() ?? 0,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_quorum_failure/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 3);
|
||||
assert.deepStrictEqual(sleepCalls, [10, 180]);
|
||||
assert.deepStrictEqual(relaysSeen.map((entry) => entry.publishAttempt), [1, 2, 3]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
assert.strictEqual(retryEvents[1].publishAttempt, 2);
|
||||
assert.strictEqual(retryEvents[1].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[1].nextDelayMs >= retryEvents[0].nextDelayMs);
|
||||
assert.ok(retryEvents[1].nextDelayMs <= 200);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
assert.strictEqual(failedEvent.attempts, 3);
|
||||
});
|
||||
|
||||
it('short-circuits non-retryable failures without extra attempts', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'validation failed: invalid signature' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_non_retryable/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_non_retryable');
|
||||
assert.notStrictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
parseLockEvent,
|
||||
publishLock,
|
||||
queryLocks,
|
||||
_resetRelayHealthState,
|
||||
} from '../src/lock-ops.mjs';
|
||||
@@ -111,322 +110,4 @@ describe('lock-ops', () => {
|
||||
assert.strictEqual(locks[0].agent, 'agent1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishLock', () => {
|
||||
it('retries transient failures and succeeds on a later attempt', async () => {
|
||||
const telemetry = [];
|
||||
const sleepCalls = [];
|
||||
let publishCalls = 0;
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
if (publishCalls === 1) {
|
||||
return relays.map(() => Promise.reject(new Error('connection reset by peer')));
|
||||
}
|
||||
return relays.map(() => Promise.resolve('ok'));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-retry' };
|
||||
const result = await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 500,
|
||||
retryCapDelayMs: 8_000,
|
||||
randomFn: () => 0.5,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: (line) => {
|
||||
telemetry.push(JSON.parse(line));
|
||||
},
|
||||
healthLogger: () => {},
|
||||
diagnostics: {
|
||||
attemptId: 'attempt-1',
|
||||
correlationId: 'corr-1',
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 2);
|
||||
assert.deepStrictEqual(sleepCalls, [250]);
|
||||
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.deepStrictEqual(retryEvents[0], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-a',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[0].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.deepStrictEqual(retryEvents[1], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-b',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[1].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.ok(Number.isInteger(retryEvents[0].elapsedMs));
|
||||
assert.ok(Number.isInteger(retryEvents[1].elapsedMs));
|
||||
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.equal(metEvent.correlationId, 'corr-1');
|
||||
assert.equal(metEvent.attemptId, 'attempt-1');
|
||||
assert.equal(metEvent.publishAttempt, 2);
|
||||
assert.ok(Array.isArray(metEvent.retryTimeline));
|
||||
});
|
||||
|
||||
it('fails after retry budget is exhausted for persistent transient failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const sleepCalls = [];
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('relay unavailable')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-down'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=3,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 3);
|
||||
assert.deepStrictEqual(sleepCalls, [500, 1000]);
|
||||
});
|
||||
|
||||
it('fails without retry for persistent validation failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('validation failed: invalid signature')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-bad'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep for permanent failures');
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=4,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('includes per-relay reason categories and retry metadata in failure diagnostics', async () => {
|
||||
const telemetry = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
if (relay.includes('dns')) return Promise.reject(new Error('getaddrinfo ENOTFOUND relay.example'));
|
||||
if (relay.includes('tcp')) return Promise.reject(new Error('connect ETIMEDOUT 1.2.3.4:443'));
|
||||
if (relay.includes('tls')) return Promise.reject(new Error('TLS handshake failure'));
|
||||
if (relay.includes('ws')) return Promise.reject(new Error('websocket: unexpected server response'));
|
||||
return Promise.reject(new Error('[publish:primary] Publish timed out after 200ms'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://dns', 'wss://tcp', 'wss://tls', 'wss://ws', 'relay-timeout'], { id: 'evt-diag' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetry.push(JSON.parse(line)),
|
||||
healthLogger: () => {},
|
||||
diagnostics: { attemptId: 'attempt-42', correlationId: 'corr-42' },
|
||||
}),
|
||||
);
|
||||
|
||||
const summaryEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(summaryEvent);
|
||||
const failureEvents = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
const failureText = failureEvents.map((entry) => `${entry.relayUrl}:${entry.reason}`).join('\n');
|
||||
|
||||
assert.match(failureText, /wss:\/\/dns:dns_resolution/);
|
||||
assert.match(failureText, /wss:\/\/tcp:tcp_connect_timeout/);
|
||||
assert.match(failureText, /wss:\/\/tls:tls_handshake/);
|
||||
assert.match(failureText, /wss:\/\/ws:websocket_open_failure/);
|
||||
assert.match(failureText, /relay-timeout:publish_timeout/);
|
||||
|
||||
|
||||
assert.deepStrictEqual(summaryEvent.reasonDistribution, {
|
||||
dns_resolution: 1,
|
||||
tcp_connect_timeout: 1,
|
||||
tls_handshake: 1,
|
||||
websocket_open_failure: 1,
|
||||
publish_timeout: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success when mixed relay outcomes still satisfy quorum', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map((relay) => (
|
||||
relay === 'wss://relay-ok'
|
||||
? Promise.resolve('ok')
|
||||
: Promise.reject(new Error('validation failed: rejected event'))
|
||||
));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-mixed' };
|
||||
const result = await publishLock(['wss://relay-ok', 'wss://relay-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep when quorum is satisfied');
|
||||
},
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('uses fallback relays to satisfy min success quorum', async () => {
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => (
|
||||
relay.includes('fallback-ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline'))
|
||||
)),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-2' };
|
||||
const result = await publishLock(['wss://primary-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-ok'],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
});
|
||||
|
||||
it('quarantines repeatedly failing relays and still reaches quorum with one healthy relay', async () => {
|
||||
const callOrder = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
callOrder.push(relay);
|
||||
if (relay === 'wss://healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('offline'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-health' };
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
callOrder.length = 0;
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(callOrder, ['wss://healthy']);
|
||||
});
|
||||
|
||||
it('uses fallback and min active pool to reintroduce quarantined relay when needed', async () => {
|
||||
const attempts = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
attempts.push(relay);
|
||||
if (relay === 'wss://fallback-healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('relay timeout'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-quorum' };
|
||||
await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-healthy'],
|
||||
getMinActiveRelayPoolFn: () => 2,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 1,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.ok(attempts.includes('wss://fallback-healthy'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { publishLock } from '../src/lock-publisher.mjs';
|
||||
import { defaultHealthManager } from '../src/relay-health-manager.mjs';
|
||||
|
||||
function _resetRelayHealthState() {
|
||||
defaultHealthManager.reset();
|
||||
}
|
||||
|
||||
function createScriptedPool(scriptedOutcomesByAttempt, relaysSeen) {
|
||||
let publishCallCount = 0;
|
||||
return {
|
||||
publish: (relays) => {
|
||||
publishCallCount += 1;
|
||||
relaysSeen.push({ publishAttempt: publishCallCount, relays: [...relays] });
|
||||
const attemptOutcomes = scriptedOutcomesByAttempt[publishCallCount - 1] || {};
|
||||
return relays.map((relay) => {
|
||||
const outcome = attemptOutcomes[relay] || { ok: true };
|
||||
return outcome.ok ? Promise.resolve('ok') : Promise.reject(new Error(outcome.message));
|
||||
});
|
||||
},
|
||||
close: () => {},
|
||||
getPublishCallCount: () => publishCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
const parseTelemetry = (logs) => logs.map((line) => JSON.parse(line));
|
||||
|
||||
describe('LockPublisher', () => {
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
describe('publishLock basic and retry logic', () => {
|
||||
it('retries transient failures and succeeds on a later attempt', async () => {
|
||||
const telemetry = [];
|
||||
const sleepCalls = [];
|
||||
let publishCalls = 0;
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
if (publishCalls === 1) {
|
||||
return relays.map(() => Promise.reject(new Error('connection reset by peer')));
|
||||
}
|
||||
return relays.map(() => Promise.resolve('ok'));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-retry' };
|
||||
const result = await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 500,
|
||||
retryCapDelayMs: 8_000,
|
||||
randomFn: () => 0.5,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: (line) => {
|
||||
telemetry.push(JSON.parse(line));
|
||||
},
|
||||
healthLogger: () => {},
|
||||
diagnostics: {
|
||||
attemptId: 'attempt-1',
|
||||
correlationId: 'corr-1',
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 2);
|
||||
assert.deepStrictEqual(sleepCalls, [250]);
|
||||
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.deepStrictEqual(retryEvents[0], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-a',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[0].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.deepStrictEqual(retryEvents[1], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-b',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[1].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails after retry budget is exhausted for persistent transient failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const sleepCalls = [];
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('relay unavailable')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-down'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=3,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 3);
|
||||
assert.deepStrictEqual(sleepCalls, [500, 1000]);
|
||||
});
|
||||
|
||||
it('fails without retry for persistent validation failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('validation failed: invalid signature')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-bad'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep for permanent failures');
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=4,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('includes per-relay reason categories and retry metadata in failure diagnostics', async () => {
|
||||
const telemetry = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
if (relay.includes('dns')) return Promise.reject(new Error('getaddrinfo ENOTFOUND relay.example'));
|
||||
if (relay.includes('tcp')) return Promise.reject(new Error('connect ETIMEDOUT 1.2.3.4:443'));
|
||||
if (relay.includes('tls')) return Promise.reject(new Error('TLS handshake failure'));
|
||||
if (relay.includes('ws')) return Promise.reject(new Error('websocket: unexpected server response'));
|
||||
return Promise.reject(new Error('[publish:primary] Publish timed out after 200ms'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://dns', 'wss://tcp', 'wss://tls', 'wss://ws', 'relay-timeout'], { id: 'evt-diag' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetry.push(JSON.parse(line)),
|
||||
healthLogger: () => {},
|
||||
diagnostics: { attemptId: 'attempt-42', correlationId: 'corr-42' },
|
||||
}),
|
||||
);
|
||||
|
||||
const summaryEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(summaryEvent);
|
||||
const failureEvents = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
const failureText = failureEvents.map((entry) => `${entry.relayUrl}:${entry.reason}`).join('\n');
|
||||
|
||||
assert.match(failureText, /wss:\/\/dns:dns_resolution/);
|
||||
assert.match(failureText, /wss:\/\/tcp:tcp_connect_timeout/);
|
||||
assert.match(failureText, /wss:\/\/tls:tls_handshake/);
|
||||
assert.match(failureText, /wss:\/\/ws:websocket_open_failure/);
|
||||
assert.match(failureText, /relay-timeout:publish_timeout/);
|
||||
|
||||
|
||||
assert.deepStrictEqual(summaryEvent.reasonDistribution, {
|
||||
dns_resolution: 1,
|
||||
tcp_connect_timeout: 1,
|
||||
tls_handshake: 1,
|
||||
websocket_open_failure: 1,
|
||||
publish_timeout: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success when mixed relay outcomes still satisfy quorum', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map((relay) => (
|
||||
relay === 'wss://relay-ok'
|
||||
? Promise.resolve('ok')
|
||||
: Promise.reject(new Error('validation failed: rejected event'))
|
||||
));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-mixed' };
|
||||
const result = await publishLock(['wss://relay-ok', 'wss://relay-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep when quorum is satisfied');
|
||||
},
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('uses fallback relays to satisfy min success quorum', async () => {
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => (
|
||||
relay.includes('fallback-ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline'))
|
||||
)),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-2' };
|
||||
const result = await publishLock(['wss://primary-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-ok'],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
});
|
||||
|
||||
it('quarantines repeatedly failing relays and still reaches quorum with one healthy relay', async () => {
|
||||
const callOrder = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
callOrder.push(relay);
|
||||
if (relay === 'wss://healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('offline'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-health' };
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
callOrder.length = 0;
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(callOrder, ['wss://healthy']);
|
||||
});
|
||||
|
||||
it('uses fallback and min active pool to reintroduce quarantined relay when needed', async () => {
|
||||
const attempts = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
attempts.push(relay);
|
||||
if (relay === 'wss://fallback-healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('relay timeout'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-quorum' };
|
||||
await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-healthy'],
|
||||
getMinActiveRelayPoolFn: () => 2,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 1,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.ok(attempts.includes('wss://fallback-healthy'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scripted retry scenarios', () => {
|
||||
it('succeeds immediately on first attempt without retries', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const event = { id: 'evt-immediate' };
|
||||
const result = await publishLock(['wss://relay-a'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.strictEqual(metEvent.publishAttempt, 1);
|
||||
});
|
||||
|
||||
it('retries a transient failure then succeeds within retry budget with bounded jitter', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'connection reset by peer' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const result = await publishLock(['wss://relay-a'], { id: 'evt-retry-success' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 500,
|
||||
randomFn: () => 0.25,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-retry-success');
|
||||
assert.deepStrictEqual(sleepCalls, [25]);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 2);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 1);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
});
|
||||
|
||||
it('returns terminal relay_publish_quorum_failure after exhausting retry budget', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const randomValues = [0.1, 0.9];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 250,
|
||||
randomFn: () => randomValues.shift() ?? 0,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_quorum_failure/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 3);
|
||||
assert.deepStrictEqual(sleepCalls, [10, 180]);
|
||||
assert.deepStrictEqual(relaysSeen.map((entry) => entry.publishAttempt), [1, 2, 3]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
assert.strictEqual(retryEvents[1].publishAttempt, 2);
|
||||
assert.strictEqual(retryEvents[1].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[1].nextDelayMs >= retryEvents[0].nextDelayMs);
|
||||
assert.ok(retryEvents[1].nextDelayMs <= 200);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
assert.strictEqual(failedEvent.attempts, 3);
|
||||
});
|
||||
|
||||
it('short-circuits non-retryable failures without extra attempts', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'validation failed: invalid signature' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_non_retryable/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_non_retryable');
|
||||
assert.notStrictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,3 +77,16 @@ test('blocks decrypted/session-sensitive data from durable promotion without san
|
||||
assert.equal(events[1].durable_eligible, true);
|
||||
assert.equal(cache.getMetrics().runtime_sensitive_events_blocked, 1);
|
||||
});
|
||||
|
||||
test('prunes cache entries based on predicate', () => {
|
||||
const cache = createMemoryCache();
|
||||
cache.set('key1', 'value1');
|
||||
cache.set('key2', 'value2');
|
||||
cache.set('other', 'value3');
|
||||
|
||||
cache.prune((key) => key.startsWith('key'));
|
||||
|
||||
assert.equal(cache.get('key1'), undefined);
|
||||
assert.equal(cache.get('key2'), undefined);
|
||||
assert.equal(cache.get('other'), 'value3');
|
||||
});
|
||||
+69
-29
@@ -5,8 +5,9 @@ import { getRoster, _resetRosterCache, _setRosterDependencies, _restoreRosterDep
|
||||
|
||||
// Mock dependencies
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(),
|
||||
readFileSync: mock.fn(),
|
||||
promises: {
|
||||
readFile: mock.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockLoadTorchConfig = mock.fn();
|
||||
@@ -25,18 +26,18 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
console.error = mockConsoleError;
|
||||
|
||||
_resetRosterCache();
|
||||
mockFs.existsSync.mock.resetCalls();
|
||||
mockFs.readFileSync.mock.resetCalls();
|
||||
mockFs.promises.readFile.mock.resetCalls();
|
||||
mockLoadTorchConfig.mock.resetCalls();
|
||||
|
||||
// Default: config returns empty nostrLock
|
||||
mockLoadTorchConfig.mock.mockImplementation(async () => ({ nostrLock: {} }));
|
||||
|
||||
// Default: fs.existsSync returns false (no files found)
|
||||
mockFs.existsSync.mock.mockImplementation(() => false);
|
||||
|
||||
// Default: fs.readFileSync returns empty object
|
||||
mockFs.readFileSync.mock.mockImplementation(() => '{}');
|
||||
// Default: fs.promises.readFile returns ENOENT
|
||||
mockFs.promises.readFile.mock.mockImplementation(async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Inject mocks
|
||||
_setRosterDependencies({
|
||||
@@ -63,11 +64,20 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
});
|
||||
|
||||
it('loads internal module roster if user/cwd rosters are missing', async () => {
|
||||
// User/CWD missing
|
||||
mockFs.existsSync.mock.mockImplementation(() => false);
|
||||
// When reading file, if it's not USER or CWD (implied by default ENOENT), check ROSTER_FILE logic?
|
||||
// Wait, the logic tries USER -> CWD -> ROSTER.
|
||||
// So if first two fail (default), third one should succeed for this test.
|
||||
|
||||
// When reading file, return internal roster content
|
||||
mockFs.readFileSync.mock.mockImplementation(() => {
|
||||
// We need to verify that it eventually reads internal roster (ROSTER_FILE).
|
||||
// But since we don't know the exact path of ROSTER_FILE easily in test (it depends on import.meta.url),
|
||||
// we can just say "if not USER or CWD, return content".
|
||||
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE || filepath === CWD_ROSTER_FILE) {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
return JSON.stringify({ daily: ['internal-daily'], weekly: ['internal-weekly'] });
|
||||
});
|
||||
|
||||
@@ -76,12 +86,13 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
});
|
||||
|
||||
it('loads roster from torch/roster.json (User Roster) if present', async () => {
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation((filepath) => {
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE) {
|
||||
return JSON.stringify({ daily: ['user-agent-daily'], weekly: ['user-agent-weekly'] });
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
@@ -89,16 +100,21 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
|
||||
assert.deepStrictEqual(daily, ['user-agent-daily']);
|
||||
assert.deepStrictEqual(weekly, ['user-agent-weekly']);
|
||||
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); // Should be cached after first call
|
||||
|
||||
// Should be cached after first call
|
||||
// Note: getRoster calls loadCanonicalRoster each time but it caches result internally.
|
||||
// However, since we call getRoster twice, and result is cached, readFile should be called once.
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('loads roster from roster.json (CWD Roster) if present and torch/roster.json is missing', async () => {
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === CWD_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation((filepath) => {
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === CWD_ROSTER_FILE) {
|
||||
return JSON.stringify({ daily: ['cwd-agent-daily'], weekly: ['cwd-agent-weekly'] });
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
@@ -106,20 +122,32 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
});
|
||||
|
||||
it('falls back to default if roster file is malformed', async () => {
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation(() => '{ invalid json }');
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE) {
|
||||
return '{ invalid json }';
|
||||
}
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Should catch error and return fallback
|
||||
const daily = await getRoster('daily');
|
||||
assert.ok(daily.includes('audit-agent'));
|
||||
|
||||
// Should have tried to read file
|
||||
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1);
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('falls back to default if roster file is missing daily/weekly arrays', async () => {
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ other: [] }));
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE) {
|
||||
return JSON.stringify({ other: [] });
|
||||
}
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
assert.ok(daily.includes('audit-agent'));
|
||||
@@ -129,8 +157,14 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
process.env.NOSTR_LOCK_DAILY_ROSTER = 'env-agent-daily';
|
||||
|
||||
// Setup file to exist
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] }));
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE) {
|
||||
return JSON.stringify({ daily: ['file-agent'] });
|
||||
}
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
assert.deepStrictEqual(daily, ['env-agent-daily']);
|
||||
@@ -142,8 +176,14 @@ describe('Roster (Unit Tests with Dependency Injection)', () => {
|
||||
}));
|
||||
|
||||
// Setup file to exist
|
||||
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
|
||||
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] }));
|
||||
mockFs.promises.readFile.mock.mockImplementation(async (filepath) => {
|
||||
if (filepath === USER_ROSTER_FILE) {
|
||||
return JSON.stringify({ daily: ['file-agent'] });
|
||||
}
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
assert.deepStrictEqual(daily, ['config-agent-daily']);
|
||||
|
||||
Reference in new issue
Block a user