diff --git a/src/roster.mjs b/src/roster.mjs index 09cc423..72f7f9b 100644 --- a/src/roster.mjs +++ b/src/roster.mjs @@ -3,6 +3,23 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadTorchConfig } from './torch-config.mjs'; +const defaultDeps = { + fs, + loadTorchConfig, +}; + +const deps = { ...defaultDeps }; + +/** @internal */ +export function _setRosterDependencies(overrides) { + Object.assign(deps, overrides); +} + +/** @internal */ +export function _restoreRosterDependencies() { + Object.assign(deps, defaultDeps); +} + const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json'); const CWD_ROSTER_FILE = path.resolve(process.cwd(), 'roster.json'); @@ -59,14 +76,14 @@ function loadCanonicalRoster(fsModule = fs) { let rosterPath = ROSTER_FILE; // Prefer user-managed roster if present - if (fsModule.existsSync(USER_ROSTER_FILE)) { + if (deps.fs.existsSync(USER_ROSTER_FILE)) { rosterPath = USER_ROSTER_FILE; - } else if (fsModule.existsSync(CWD_ROSTER_FILE)) { + } else if (deps.fs.existsSync(CWD_ROSTER_FILE)) { rosterPath = CWD_ROSTER_FILE; } try { - const parsed = JSON.parse(fsModule.readFileSync(rosterPath, 'utf8')); + const parsed = JSON.parse(deps.fs.readFileSync(rosterPath, 'utf8')); const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; const weekly = Array.isArray(parsed.weekly) ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) @@ -99,13 +116,8 @@ function parseEnvRoster(value) { .filter(Boolean); } -export async function getRoster(cadence, deps = {}) { - const { - loadTorchConfig: loadTorchConfigFn = loadTorchConfig, - fs: fsModule = fs, - } = deps; - - const config = await loadTorchConfigFn(); +export async function getRoster(cadence) { + const config = await deps.loadTorchConfig(); const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); const canonical = loadCanonicalRoster(fsModule); diff --git a/test/roster.test.mjs b/test/roster.test.mjs index b968b44..88b82a4 100644 --- a/test/roster.test.mjs +++ b/test/roster.test.mjs @@ -1,27 +1,33 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert'; import path from 'node:path'; -import { getRoster, _resetRosterCache } from '../src/roster.mjs'; +import { getRoster, _resetRosterCache, _setRosterDependencies, _restoreRosterDependencies } from '../src/roster.mjs'; + +// Mock dependencies +const mockFs = { + existsSync: mock.fn(), + readFileSync: mock.fn(), +}; + +const mockLoadTorchConfig = mock.fn(); describe('Roster (Unit Tests with Dependency Injection)', () => { const CWD = process.cwd(); const USER_ROSTER_FILE = path.resolve(CWD, 'torch/roster.json'); const CWD_ROSTER_FILE = path.resolve(CWD, 'roster.json'); - const mockFs = { - existsSync: mock.fn(), - readFileSync: mock.fn(), - }; - - const mockLoadTorchConfig = mock.fn(); + // Mock console.error to suppress warnings const originalConsoleError = console.error; + let mockConsoleError; beforeEach(() => { + mockConsoleError = mock.fn(); + console.error = mockConsoleError; + _resetRosterCache(); mockFs.existsSync.mock.resetCalls(); mockFs.readFileSync.mock.resetCalls(); mockLoadTorchConfig.mock.resetCalls(); - console.error = mock.fn(); // Default: config returns empty nostrLock mockLoadTorchConfig.mock.mockImplementation(async () => ({ nostrLock: {} })); @@ -32,22 +38,41 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { // Default: fs.readFileSync returns empty object mockFs.readFileSync.mock.mockImplementation(() => '{}'); + // Inject mocks + _setRosterDependencies({ + fs: mockFs, + loadTorchConfig: mockLoadTorchConfig + }); + // Clear env vars delete process.env.NOSTR_LOCK_DAILY_ROSTER; delete process.env.NOSTR_LOCK_WEEKLY_ROSTER; }); afterEach(() => { - mock.reset(); + _restoreRosterDependencies(); console.error = originalConsoleError; + mock.reset(); }); it('returns fallback roster when no configuration or files exist', async () => { - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const roster = await getRoster('daily', deps); + const roster = await getRoster('daily'); assert.ok(Array.isArray(roster)); assert.ok(roster.length > 0); - assert.ok(roster.includes('audit-agent')); + assert.ok(roster.includes('audit-agent')); // verification of fallback content + }); + + it('loads internal module roster if user/cwd rosters are missing', async () => { + // User/CWD missing + mockFs.existsSync.mock.mockImplementation(() => false); + + // When reading file, return internal roster content + mockFs.readFileSync.mock.mockImplementation(() => { + return JSON.stringify({ daily: ['internal-daily'], weekly: ['internal-weekly'] }); + }); + + const daily = await getRoster('daily'); + assert.deepStrictEqual(daily, ['internal-daily']); }); it('loads roster from torch/roster.json (User Roster) if present', async () => { @@ -59,13 +84,12 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { throw new Error('ENOENT'); }); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); - const weekly = await getRoster('weekly', deps); + const daily = await getRoster('daily'); + const weekly = await getRoster('weekly'); assert.deepStrictEqual(daily, ['user-agent-daily']); assert.deepStrictEqual(weekly, ['user-agent-weekly']); - assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); + assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); // Should be cached after first call }); it('loads roster from roster.json (CWD Roster) if present and torch/roster.json is missing', async () => { @@ -77,8 +101,7 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { throw new Error('ENOENT'); }); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.deepStrictEqual(daily, ['cwd-agent-daily']); }); @@ -86,9 +109,8 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); mockFs.readFileSync.mock.mockImplementation(() => '{ invalid json }'); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; // Should catch error and return fallback - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.ok(daily.includes('audit-agent')); // Should have tried to read file @@ -99,8 +121,7 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ other: [] })); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.ok(daily.includes('audit-agent')); }); @@ -111,8 +132,7 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] })); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.deepStrictEqual(daily, ['env-agent-daily']); }); @@ -125,15 +145,13 @@ describe('Roster (Unit Tests with Dependency Injection)', () => { mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] })); - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.deepStrictEqual(daily, ['config-agent-daily']); }); it('handles multiple items in env var roster', async () => { process.env.NOSTR_LOCK_DAILY_ROSTER = 'agent1, agent2,agent3 '; - const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; - const daily = await getRoster('daily', deps); + const daily = await getRoster('daily'); assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']); }); });