diff --git a/tests/unit/MemoryProxy.service.test.js b/tests/unit/MemoryProxy.service.test.js index 0262da13..85e09cdd 100644 --- a/tests/unit/MemoryProxy.service.test.js +++ b/tests/unit/MemoryProxy.service.test.js @@ -1,30 +1,307 @@ /** * Unit Tests - MemoryProxy Service v3 - * Tests MongoDB-backed governance rule persistence and retrieval + * Tests governance rule persistence and retrieval + * + * All MongoDB operations use in-memory mocks that faithfully replicate + * the Mongoose model interfaces. No database connection required. */ +// ============================================ +// Helper functions (function declarations are hoisted, +// making them available inside jest.mock() factories) +// ============================================ + +/** + * Match a document against a simplified MongoDB-style filter. + * Supports: RegExp values, $in operator, plain equality. + */ +function mockMatchesFilter(doc, filter) { + for (const key of Object.keys(filter)) { + const value = filter[key]; + + if (value instanceof RegExp) { + if (typeof doc[key] !== 'string' || !value.test(doc[key])) return false; + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if ('$in' in value) { + if (!value.$in.includes(doc[key])) return false; + } + // Other operators ignored — extend as needed + } else { + if (doc[key] !== value) return false; + } + } + return true; +} + +/** + * Create a chainable query object for multi-document results. + * Supports .lean(), .sort(), .limit(), and direct await. + */ +function mockChainableQuery(resultArray) { + const chain = { + lean() { + return Promise.resolve(resultArray.map(r => { + const copy = {}; + for (const k of Object.keys(r)) { + if (typeof r[k] !== 'function') copy[k] = r[k]; + } + return copy; + })); + }, + sort() { return chain; }, + limit() { return chain; }, + then(resolve, reject) { + return Promise.resolve(resultArray).then(resolve, reject); + }, + catch(fn) { + return Promise.resolve(resultArray).catch(fn); + } + }; + return chain; +} + +/** + * Create a chainable query object for single-document results (findById, findOne). + * .lean() returns a plain object or null. + */ +function mockSingleDocQuery(doc) { + return { + lean() { + if (!doc) return Promise.resolve(null); + const copy = {}; + for (const k of Object.keys(doc)) { + if (typeof doc[k] !== 'function') copy[k] = doc[k]; + } + return Promise.resolve(copy); + }, + then(resolve, reject) { + return Promise.resolve(doc).then(resolve, reject); + }, + catch(fn) { + return Promise.resolve(doc).catch(fn); + } + }; +} + +// ============================================ +// Jest module mocks +// ============================================ + +jest.mock('mongoose', () => ({ + connection: { readyState: 1 }, + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/utils/logger.util', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +jest.mock('../../src/services/AnthropicMemoryClient.service', () => ({ + getAnthropicMemoryClient: jest.fn(() => { + throw new Error('API key not available in test environment'); + }), +})); + +// GovernanceRule: in-memory store with full Mongoose-compatible interface +jest.mock('../../src/models/GovernanceRule.model', () => { + const _store = []; + + const mock = { + _store, + + countDocuments: jest.fn(async () => _store.length), + + deleteMany: jest.fn(async (filter) => { + const before = _store.length; + const keep = _store.filter(doc => !mockMatchesFilter(doc, filter)); + _store.length = 0; + for (const item of keep) _store.push(item); + return { deletedCount: before - keep.length }; + }), + + /** + * Process bulkWrite upsert operations in memory. + * Handles updateOne with $set, $setOnInsert, and upsert:true. + */ + bulkWrite: jest.fn(async (operations) => { + let upsertedCount = 0; + let modifiedCount = 0; + + for (const op of operations) { + if (!op.updateOne) continue; + + const { filter, update, upsert } = op.updateOne; + const idx = _store.findIndex(doc => { + for (const [k, v] of Object.entries(filter)) { + if (doc[k] !== v) return false; + } + return true; + }); + + if (idx >= 0) { + // Update existing document — apply only $set fields + if (update.$set) { + for (const [k, v] of Object.entries(update.$set)) { + _store[idx][k] = v; + } + } + modifiedCount++; + } else if (upsert) { + // Insert new document — apply $setOnInsert then $set + const newDoc = {}; + if (update.$setOnInsert) Object.assign(newDoc, update.$setOnInsert); + if (update.$set) Object.assign(newDoc, update.$set); + // Include string/number/boolean filter fields (contains the id) + for (const [k, v] of Object.entries(filter)) { + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') { + newDoc[k] = v; + } + } + _store.push(newDoc); + upsertedCount++; + } + } + + return { upsertedCount, modifiedCount, matchedCount: modifiedCount }; + }), + + /** + * Return all active, non-expired rules. + * Mirrors GovernanceRule.findActive() static method. + */ + findActive: jest.fn(async () => { + return _store.filter(doc => doc.active !== false); + }), + + /** + * Find a single rule by its custom id field. + * Returns a Mongoose-doc-like object with .toObject(). + */ + findByRuleId: jest.fn(async (ruleId) => { + const found = _store.find(doc => doc.id === ruleId && doc.active !== false); + if (!found) return null; + const snapshot = { ...found }; + snapshot.toObject = function () { return { ...found }; }; + return snapshot; + }), + + /** + * Filter rules by quadrant. Returns array with .toObject() on each element. + */ + findByQuadrant: jest.fn(async (quadrant, activeOnly = true) => { + let results = _store.filter(doc => doc.quadrant === quadrant); + if (activeOnly) results = results.filter(doc => doc.active !== false); + return results.map(doc => { + const snapshot = { ...doc }; + snapshot.toObject = function () { return { ...doc }; }; + return snapshot; + }); + }), + + /** + * Filter rules by persistence level. Returns array with .toObject() on each element. + */ + findByPersistence: jest.fn(async (persistence, activeOnly = true) => { + let results = _store.filter(doc => doc.persistence === persistence); + if (activeOnly) results = results.filter(doc => doc.active !== false); + return results.map(doc => { + const snapshot = { ...doc }; + snapshot.toObject = function () { return { ...doc }; }; + return snapshot; + }); + }), + + /** + * General find with filter support. Returns chainable query with .lean(). + */ + find: jest.fn((filter) => { + const results = _store.filter(doc => mockMatchesFilter(doc, filter)); + return mockChainableQuery(results); + }), + }; + + return mock; +}); + +// AuditLog: constructor-based mock with in-memory store +jest.mock('../../src/models/AuditLog.model', () => { + const _store = []; + let _idCounter = 0; + + /** + * Mock AuditLog constructor. Works with `new AuditLog(data)`. + * Instance has .save() that persists to in-memory store. + */ + function AuditLogMock(data) { + _idCounter++; + this._id = `audit_${_idCounter}`; + if (data) { + for (const key of Object.keys(data)) { + this[key] = data[key]; + } + } + } + + AuditLogMock.prototype.save = async function () { + const copy = {}; + for (const key of Object.keys(this)) { + if (typeof this[key] !== 'function') { + copy[key] = this[key]; + } + } + _store.push(copy); + return this; + }; + + // Expose store for test cleanup + AuditLogMock._store = _store; + AuditLogMock._resetIdCounter = () => { _idCounter = 0; }; + + AuditLogMock.countDocuments = jest.fn(async () => _store.length); + + AuditLogMock.deleteMany = jest.fn(async (filter) => { + const before = _store.length; + const keep = _store.filter(doc => !mockMatchesFilter(doc, filter)); + _store.length = 0; + for (const item of keep) _store.push(item); + return { deletedCount: before - keep.length }; + }); + + AuditLogMock.findById = jest.fn((id) => { + const found = _store.find(doc => String(doc._id) === String(id)); + return mockSingleDocQuery(found || null); + }); + + AuditLogMock.find = jest.fn((filter) => { + const results = _store.filter(doc => mockMatchesFilter(doc, filter)); + return mockChainableQuery(results); + }); + + return AuditLogMock; +}); + +// ============================================ +// Imports (resolved after mocks are registered) +// ============================================ + const { MemoryProxyService } = require('../../src/services/MemoryProxy.service'); const GovernanceRule = require('../../src/models/GovernanceRule.model'); const AuditLog = require('../../src/models/AuditLog.model'); const mongoose = require('mongoose'); -// Increase timeout for MongoDB operations -jest.setTimeout(30000); +// ============================================ +// Tests +// ============================================ + +jest.setTimeout(10000); describe('MemoryProxyService v3 (MongoDB)', () => { let memoryProxy; - // Connect to test database before all tests - beforeAll(async () => { - const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_test'; - await mongoose.connect(mongoUri); - }); - - // Disconnect after all tests - afterAll(async () => { - await mongoose.disconnect(); - }); - const testRules = [ { id: 'test_inst_001', @@ -50,9 +327,10 @@ describe('MemoryProxyService v3 (MongoDB)', () => { ]; beforeEach(async () => { - // Clear test data from previous tests - await GovernanceRule.deleteMany({ id: /^test_/ }); - await AuditLog.deleteMany({ sessionId: /^test-/ }); + // Clear in-memory stores for clean test isolation + GovernanceRule._store.length = 0; + AuditLog._store.length = 0; + AuditLog._resetIdCounter(); memoryProxy = new MemoryProxyService({ cacheEnabled: true, @@ -63,12 +341,6 @@ describe('MemoryProxyService v3 (MongoDB)', () => { await memoryProxy.initialize(); }); - afterEach(async () => { - // Cleanup test data - await GovernanceRule.deleteMany({ id: /^test_/ }); - await AuditLog.deleteMany({ sessionId: /^test-/ }); - }); - describe('Initialization', () => { test('should connect to MongoDB successfully', async () => { expect(mongoose.connection.readyState).toBe(1); // 1 = connected @@ -165,7 +437,7 @@ describe('MemoryProxyService v3 (MongoDB)', () => { }); test('should load from cache on second call', async () => { - // First call - from MongoDB + // First call - from mock store await memoryProxy.loadGovernanceRules(); // Second call - from cache (much faster) @@ -195,6 +467,9 @@ describe('MemoryProxyService v3 (MongoDB)', () => { // Clear all test rules await GovernanceRule.deleteMany({ id: /^test_/ }); + // Clear cache so it reloads from store + memoryProxy.clearCache(); + const rules = await memoryProxy.loadGovernanceRules(); expect(rules.filter(r => r.id.startsWith('test_'))).toEqual([]); }); @@ -365,7 +640,7 @@ describe('MemoryProxyService v3 (MongoDB)', () => { // Wait for cache to expire await new Promise(resolve => setTimeout(resolve, 150)); - // Should reload from MongoDB (cache expired) + // Should reload from store (cache expired) const allRules = await shortTTLProxy.loadGovernanceRules(); const testRulesLoaded = allRules.filter(r => r.id.startsWith('test_')); expect(testRulesLoaded).toHaveLength(3);