Skip to main content
Glama
base-file-repository.test.ts13.2 kB
/** * BaseFileRepository Tests * * Tests for the abstract base class shared across file repositories: * - LRU cache operations (cacheGet, cacheSet, cacheInvalidate, cacheClear) * - Lazy initialization pattern (ensureInitialized, markInitialized, isInitializedState) * - Cache options merging with DEFAULT_CACHE_OPTIONS * * Uses a concrete TestRepository subclass to test protected methods. * * TDD Markers: REVIEW (all tests verified) */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { BaseFileRepository } from '../../src/infrastructure/repositories/file/base-file-repository'; import { DEFAULT_CACHE_OPTIONS, type CacheOptions } from '../../src/infrastructure/repositories/file/types'; /** * Concrete test implementation of BaseFileRepository * Exposes protected methods for testing */ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ class TestRepository extends BaseFileRepository { public testCache = new Map<string, string>(); public initializeCallCount = 0; public initialize(): Promise<void> { this.initializeCallCount++; this.markInitialized(); return Promise.resolve(); } // Expose protected methods for testing public testCacheGet(key: string): string | undefined { return this.cacheGet(this.testCache, key); } public testCacheSet(key: string, value: string): void { this.cacheSet(this.testCache, key, value); } public testCacheInvalidate(key: string): void { this.cacheInvalidate(this.testCache, key); } public testCacheClear(): void { this.cacheClear(this.testCache); } public testEnsureInitialized(): Promise<void> { return this.ensureInitialized(); } public testIsInitializedState(): boolean { return this.isInitializedState(); } public testMarkInitialized(): void { this.markInitialized(); } public getCacheOptions(): Required<CacheOptions> { return this.cacheOptions; } public getBaseDir(): string { return this.baseDir; } // Expose file operations for integration tests public testAtomicWriteJSON(filePath: string, data: unknown): Promise<void> { return this.atomicWriteJSON(filePath, data); } public testLoadJSON<T>(filePath: string): Promise<T> { return this.loadJSON<T>(filePath); } } /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ describe('BaseFileRepository', () => { let testDir: string; beforeEach(async () => { testDir = path.join(os.tmpdir(), `base-repo-test-${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(testDir, { recursive: true }); }); afterEach(async () => { await fs.rm(testDir, { recursive: true, force: true }); }); // ============================================================================ // REVIEW: Constructor and Cache Options // ============================================================================ describe('constructor', () => { it('should use DEFAULT_CACHE_OPTIONS when no options provided', () => { const repo = new TestRepository(testDir); expect(repo.getCacheOptions()).toEqual(DEFAULT_CACHE_OPTIONS); }); it('should merge provided options with defaults', () => { const repo = new TestRepository(testDir, { maxSize: 50 }); expect(repo.getCacheOptions()).toEqual({ ...DEFAULT_CACHE_OPTIONS, maxSize: 50, }); }); it('should allow overriding all cache options', () => { const customOptions = { enabled: false, ttl: 10000, maxSize: 200, invalidation: 'ttl' as const, }; const repo = new TestRepository(testDir, customOptions); expect(repo.getCacheOptions()).toEqual(customOptions); }); it('should store baseDir correctly', () => { const repo = new TestRepository(testDir); expect(repo.getBaseDir()).toBe(testDir); }); }); // ============================================================================ // REVIEW: Initialization Pattern // ============================================================================ describe('initialization', () => { it('should start uninitialized', () => { const repo = new TestRepository(testDir); expect(repo.testIsInitializedState()).toBe(false); }); it('should be initialized after markInitialized()', () => { const repo = new TestRepository(testDir); repo.testMarkInitialized(); expect(repo.testIsInitializedState()).toBe(true); }); it('should call initialize() on first ensureInitialized()', async () => { const repo = new TestRepository(testDir); await repo.testEnsureInitialized(); expect(repo.initializeCallCount).toBe(1); expect(repo.testIsInitializedState()).toBe(true); }); it('should not call initialize() again on subsequent ensureInitialized()', async () => { const repo = new TestRepository(testDir); await repo.testEnsureInitialized(); await repo.testEnsureInitialized(); await repo.testEnsureInitialized(); expect(repo.initializeCallCount).toBe(1); }); it('should be idempotent - multiple initialize() calls should be safe', async () => { const repo = new TestRepository(testDir); await repo.initialize(); await repo.initialize(); await repo.initialize(); // Our test implementation counts calls, but markInitialized is idempotent expect(repo.testIsInitializedState()).toBe(true); }); }); // ============================================================================ // REVIEW: LRU Cache Operations // ============================================================================ describe('cache operations', () => { describe('cacheGet', () => { it('should return undefined for non-existent key', () => { const repo = new TestRepository(testDir); expect(repo.testCacheGet('nonexistent')).toBeUndefined(); }); it('should return cached value', () => { const repo = new TestRepository(testDir); repo.testCache.set('key1', 'value1'); expect(repo.testCacheGet('key1')).toBe('value1'); }); }); describe('cacheSet', () => { it('should set value in cache', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key1', 'value1'); expect(repo.testCache.get('key1')).toBe('value1'); }); it('should overwrite existing value', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key1', 'value1'); repo.testCacheSet('key1', 'value2'); expect(repo.testCache.get('key1')).toBe('value2'); }); it('should evict oldest entry when cache is full (LRU)', () => { const repo = new TestRepository(testDir, { maxSize: 3 }); repo.testCacheSet('a', '1'); repo.testCacheSet('b', '2'); repo.testCacheSet('c', '3'); // Cache is now full, adding 'd' should evict 'a' (oldest) repo.testCacheSet('d', '4'); expect(repo.testCacheGet('a')).toBeUndefined(); // Evicted expect(repo.testCacheGet('b')).toBe('2'); expect(repo.testCacheGet('c')).toBe('3'); expect(repo.testCacheGet('d')).toBe('4'); expect(repo.testCache.size).toBe(3); }); it('should evict multiple entries as cache grows', () => { const repo = new TestRepository(testDir, { maxSize: 2 }); repo.testCacheSet('a', '1'); repo.testCacheSet('b', '2'); repo.testCacheSet('c', '3'); // Evicts 'a' repo.testCacheSet('d', '4'); // Evicts 'b' expect(repo.testCacheGet('a')).toBeUndefined(); expect(repo.testCacheGet('b')).toBeUndefined(); expect(repo.testCacheGet('c')).toBe('3'); expect(repo.testCacheGet('d')).toBe('4'); }); it('should handle maxSize of 1', () => { const repo = new TestRepository(testDir, { maxSize: 1 }); repo.testCacheSet('a', '1'); expect(repo.testCache.size).toBe(1); repo.testCacheSet('b', '2'); expect(repo.testCache.size).toBe(1); expect(repo.testCacheGet('a')).toBeUndefined(); expect(repo.testCacheGet('b')).toBe('2'); }); it('should not evict when updating existing key', () => { const repo = new TestRepository(testDir, { maxSize: 2 }); repo.testCacheSet('a', '1'); repo.testCacheSet('b', '2'); // Update 'a' - should not evict anything repo.testCacheSet('a', 'updated'); expect(repo.testCache.size).toBe(2); expect(repo.testCacheGet('a')).toBe('updated'); expect(repo.testCacheGet('b')).toBe('2'); }); }); describe('cacheInvalidate', () => { it('should remove entry from cache', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key1', 'value1'); repo.testCacheInvalidate('key1'); expect(repo.testCacheGet('key1')).toBeUndefined(); }); it('should handle non-existent key gracefully', () => { const repo = new TestRepository(testDir); // Should not throw expect(() => { repo.testCacheInvalidate('nonexistent'); }).not.toThrow(); }); it('should only remove specified key', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key1', 'value1'); repo.testCacheSet('key2', 'value2'); repo.testCacheInvalidate('key1'); expect(repo.testCacheGet('key1')).toBeUndefined(); expect(repo.testCacheGet('key2')).toBe('value2'); }); }); describe('cacheClear', () => { it('should remove all entries from cache', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key1', 'value1'); repo.testCacheSet('key2', 'value2'); repo.testCacheSet('key3', 'value3'); repo.testCacheClear(); expect(repo.testCache.size).toBe(0); expect(repo.testCacheGet('key1')).toBeUndefined(); expect(repo.testCacheGet('key2')).toBeUndefined(); expect(repo.testCacheGet('key3')).toBeUndefined(); }); it('should handle empty cache gracefully', () => { const repo = new TestRepository(testDir); // Should not throw expect(() => { repo.testCacheClear(); }).not.toThrow(); expect(repo.testCache.size).toBe(0); }); }); }); // ============================================================================ // REVIEW: File Operations (delegated to file-utils.ts) // ============================================================================ describe('file operations', () => { it('should write and read JSON via delegated methods', async () => { const repo = new TestRepository(testDir); const filePath = path.join(testDir, 'test.json'); const data = { key: 'value', nested: { arr: [1, 2, 3] } }; await repo.testAtomicWriteJSON(filePath, data); const loaded = await repo.testLoadJSON(filePath); expect(loaded).toEqual(data); }); it('should throw on loadJSON for non-existent file', async () => { const repo = new TestRepository(testDir); const filePath = path.join(testDir, 'nonexistent.json'); await expect(repo.testLoadJSON(filePath)).rejects.toThrow(); }); }); // ============================================================================ // REVIEW: Edge Cases // ============================================================================ describe('edge cases', () => { it('should handle cache with maxSize 0 (effectively disabled)', () => { const repo = new TestRepository(testDir, { maxSize: 0 }); // With maxSize 0, every set should trigger eviction logic // But since cache is empty, nothing to evict - item still gets added repo.testCacheSet('a', '1'); // The >= check means at size 0, we try to evict but cache is empty // Then we add the item, so cache has 1 item // This is edge case behavior - maxSize 0 doesn't truly disable caching expect(repo.testCache.size).toBeLessThanOrEqual(1); }); it('should handle special characters in cache keys', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key/with/slashes', 'value1'); repo.testCacheSet('key:with:colons', 'value2'); repo.testCacheSet('key with spaces', 'value3'); expect(repo.testCacheGet('key/with/slashes')).toBe('value1'); expect(repo.testCacheGet('key:with:colons')).toBe('value2'); expect(repo.testCacheGet('key with spaces')).toBe('value3'); }); it('should handle empty string as cache key', () => { const repo = new TestRepository(testDir); repo.testCacheSet('', 'empty-key-value'); expect(repo.testCacheGet('')).toBe('empty-key-value'); }); it('should handle empty string as cache value', () => { const repo = new TestRepository(testDir); repo.testCacheSet('key', ''); expect(repo.testCacheGet('key')).toBe(''); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cppmyjob/cpp-mcp-planner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server