From e12c415f285519d1fb74e7051e45a55dc8c343c5 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:29:09 +0000 Subject: [PATCH] feat(test): add unit tests for roster loading logic with dependency injection - Refactor `src/roster.mjs` to support dependency injection for `fs` and `loadTorchConfig`. - Add `test/roster.test.mjs` to test roster loading scenarios including file precedence, caching, fallback, and configuration overrides. - Export `_resetRosterCache` in `src/roster.mjs` for testing purposes. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .scheduler-memory/memory-store.json | 98 +++++++++++++++++++- src/roster.mjs | 24 +++-- test/roster.test.mjs | 139 ++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 test/roster.test.mjs diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 702dd60..7ee9c41 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -117,7 +117,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370050385, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -140,7 +140,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370084740, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -163,7 +163,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370148079, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -244,5 +244,97 @@ "merged_into": null, "pinned": false } + ], + [ + "0c0f8d8f-b1c5-4b4f-bd15-4a53462f3a31", + { + "schema_version": 1, + "id": "0c0f8d8f-b1c5-4b4f-bd15-4a53462f3a31", + "agent_id": "agent-telemetry", + "session_id": "s-telemetry", + "type": "batch_window", + "content": "customer token ABC123 secret", + "summary": "customer token ABC123 secret", + "tags": [ + "incident" + ], + "importance": 0.25, + "embedding_id": null, + "created_at": 1771385166452, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "5c3af7fa-cab8-4bcf-b1dc-fc845d611026", + { + "schema_version": 1, + "id": "5c3af7fa-cab8-4bcf-b1dc-fc845d611026", + "agent_id": "agent-telemetry", + "session_id": "s-telemetry", + "type": "batch_window", + "content": "customer token ABC123 secret", + "summary": "customer token ABC123 secret", + "tags": [ + "incident" + ], + "importance": 0.25, + "embedding_id": null, + "created_at": 1771385220726, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "65e6d5f8-74fe-48cb-b1ab-b9b3d5a4ae85", + { + "schema_version": 1, + "id": "65e6d5f8-74fe-48cb-b1ab-b9b3d5a4ae85", + "agent_id": "agent-telemetry", + "session_id": "s-telemetry", + "type": "batch_window", + "content": "customer token ABC123 secret", + "summary": "customer token ABC123 secret", + "tags": [ + "incident" + ], + "importance": 0.25, + "embedding_id": null, + "created_at": 1771385242873, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "f6c9edde-0629-4853-ab41-e90c9700dbc3", + { + "schema_version": 1, + "id": "f6c9edde-0629-4853-ab41-e90c9700dbc3", + "agent_id": "agent-telemetry", + "session_id": "s-telemetry", + "type": "batch_window", + "content": "customer token ABC123 secret", + "summary": "customer token ABC123 secret", + "tags": [ + "incident" + ], + "importance": 0.25, + "embedding_id": null, + "created_at": 1771385274962, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } ] ] \ No newline at end of file diff --git a/src/roster.mjs b/src/roster.mjs index 99ab414..09cc423 100644 --- a/src/roster.mjs +++ b/src/roster.mjs @@ -53,20 +53,20 @@ const FALLBACK_ROSTER = { let cachedCanonicalRoster = null; -function loadCanonicalRoster() { +function loadCanonicalRoster(fsModule = fs) { if (cachedCanonicalRoster) return cachedCanonicalRoster; let rosterPath = ROSTER_FILE; // Prefer user-managed roster if present - if (fs.existsSync(USER_ROSTER_FILE)) { + if (fsModule.existsSync(USER_ROSTER_FILE)) { rosterPath = USER_ROSTER_FILE; - } else if (fs.existsSync(CWD_ROSTER_FILE)) { + } else if (fsModule.existsSync(CWD_ROSTER_FILE)) { rosterPath = CWD_ROSTER_FILE; } try { - const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + const parsed = JSON.parse(fsModule.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 +86,11 @@ function loadCanonicalRoster() { return cachedCanonicalRoster; } +/** @internal */ +export function _resetRosterCache() { + cachedCanonicalRoster = null; +} + function parseEnvRoster(value) { if (!value) return null; return value @@ -94,11 +99,16 @@ function parseEnvRoster(value) { .filter(Boolean); } -export async function getRoster(cadence) { - const config = await loadTorchConfig(); +export async function getRoster(cadence, deps = {}) { + const { + loadTorchConfig: loadTorchConfigFn = loadTorchConfig, + fs: fsModule = fs, + } = deps; + + const config = await loadTorchConfigFn(); const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); - const canonical = loadCanonicalRoster(); + const canonical = loadCanonicalRoster(fsModule); const dailyFromConfig = config.nostrLock.dailyRoster; const weeklyFromConfig = config.nostrLock.weeklyRoster; diff --git a/test/roster.test.mjs b/test/roster.test.mjs new file mode 100644 index 0000000..b968b44 --- /dev/null +++ b/test/roster.test.mjs @@ -0,0 +1,139 @@ +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'; + +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(); + const originalConsoleError = console.error; + + beforeEach(() => { + _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: {} })); + + // Default: fs.existsSync returns false (no files found) + mockFs.existsSync.mock.mockImplementation(() => false); + + // Default: fs.readFileSync returns empty object + mockFs.readFileSync.mock.mockImplementation(() => '{}'); + + // Clear env vars + delete process.env.NOSTR_LOCK_DAILY_ROSTER; + delete process.env.NOSTR_LOCK_WEEKLY_ROSTER; + }); + + afterEach(() => { + mock.reset(); + console.error = originalConsoleError; + }); + + it('returns fallback roster when no configuration or files exist', async () => { + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const roster = await getRoster('daily', deps); + assert.ok(Array.isArray(roster)); + assert.ok(roster.length > 0); + assert.ok(roster.includes('audit-agent')); + }); + + 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 deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + const weekly = await getRoster('weekly', deps); + + assert.deepStrictEqual(daily, ['user-agent-daily']); + assert.deepStrictEqual(weekly, ['user-agent-weekly']); + assert.strictEqual(mockFs.readFileSync.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) => { + if (filepath === CWD_ROSTER_FILE) { + return JSON.stringify({ daily: ['cwd-agent-daily'], weekly: ['cwd-agent-weekly'] }); + } + throw new Error('ENOENT'); + }); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + 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 }'); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + // Should catch error and return fallback + const daily = await getRoster('daily', deps); + 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 deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + 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 deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + 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 deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + 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); + assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']); + }); +});