From a31332457db25a3b9ecaff03d1f1d84039353003 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:31:12 +0000 Subject: [PATCH] test: add unit tests for roster loading logic - Implemented dependency injection in `src/roster.mjs` to allow mocking `fs` and `loadTorchConfig`. - Added `test/roster.test.mjs` with comprehensive test cases: - Fallback behavior when no files/config exist. - Priority of `torch/roster.json` (User Roster) over `roster.json` (CWD). - Internal module roster fallback. - Environment variable overrides. - Configuration overrides. - Error handling for malformed JSON or missing fields. - Exported `_resetRosterCache`, `_setRosterDependencies`, and `_restoreRosterDependencies` for testing purposes. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/roster.mjs | 30 +++++++-- test/roster.test.mjs | 157 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 test/roster.test.mjs diff --git a/src/roster.mjs b/src/roster.mjs index 99ab414..8bdf6f3 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() { let rosterPath = ROSTER_FILE; // Prefer user-managed roster if present - if (fs.existsSync(USER_ROSTER_FILE)) { + if (deps.fs.existsSync(USER_ROSTER_FILE)) { rosterPath = USER_ROSTER_FILE; - } else if (fs.existsSync(CWD_ROSTER_FILE)) { + } else if (deps.fs.existsSync(CWD_ROSTER_FILE)) { rosterPath = CWD_ROSTER_FILE; } try { - const parsed = JSON.parse(fs.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) @@ -86,6 +103,11 @@ function loadCanonicalRoster() { return cachedCanonicalRoster; } +/** @internal */ +export function _resetRosterCache() { + cachedCanonicalRoster = null; +} + function parseEnvRoster(value) { if (!value) return null; return value @@ -95,7 +117,7 @@ function parseEnvRoster(value) { } export async function getRoster(cadence) { - const config = await loadTorchConfig(); + 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(); diff --git a/test/roster.test.mjs b/test/roster.test.mjs new file mode 100644 index 0000000..88b82a4 --- /dev/null +++ b/test/roster.test.mjs @@ -0,0 +1,157 @@ +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert'; +import path from 'node:path'; +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'); + + // 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(); + + // 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(() => '{}'); + + // 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(() => { + _restoreRosterDependencies(); + console.error = originalConsoleError; + mock.reset(); + }); + + it('returns fallback roster when no configuration or files exist', async () => { + const roster = await getRoster('daily'); + assert.ok(Array.isArray(roster)); + assert.ok(roster.length > 0); + 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 () => { + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation((filepath) => { + if (filepath === USER_ROSTER_FILE) { + return JSON.stringify({ daily: ['user-agent-daily'], weekly: ['user-agent-weekly'] }); + } + throw new Error('ENOENT'); + }); + + 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); // Should be cached after first call + }); + + 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) => { + if (filepath === CWD_ROSTER_FILE) { + return JSON.stringify({ daily: ['cwd-agent-daily'], weekly: ['cwd-agent-weekly'] }); + } + throw new Error('ENOENT'); + }); + + const daily = await getRoster('daily'); + assert.deepStrictEqual(daily, ['cwd-agent-daily']); + }); + + 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 }'); + + // 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); + }); + + 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: [] })); + + const daily = await getRoster('daily'); + assert.ok(daily.includes('audit-agent')); + }); + + it('prioritizes environment variables over file and config', async () => { + 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'] })); + + const daily = await getRoster('daily'); + assert.deepStrictEqual(daily, ['env-agent-daily']); + }); + + it('prioritizes config over file', async () => { + mockLoadTorchConfig.mock.mockImplementation(async () => ({ + nostrLock: { dailyRoster: ['config-agent-daily'] } + })); + + // Setup file to exist + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] })); + + 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 daily = await getRoster('daily'); + assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']); + }); +});