UNPKG

mcp-server-gpt-image

Version:

MCP server for OpenAI GPT Image-1 and Responses API with dual-mode support, real-time streaming, intelligent caching, and automatic image optimization

276 lines 12.8 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { ImageCache } from './cache'; vi.mock('fs/promises'); vi.mock('crypto'); describe('ImageCache', () => { let cache; const mockCacheDir = '/test/cache/dir'; const mockTTL = 3600; const mockMaxSize = 100; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); // Mock fs.mkdir fs.mkdir.mockResolvedValue(undefined); // Mock crypto.createHash const mockHash = { update: vi.fn().mockReturnThis(), digest: vi.fn().mockReturnValue('mockedhash123456'), }; crypto.createHash.mockReturnValue(mockHash); cache = new ImageCache({ cacheDir: mockCacheDir, defaultTTL: mockTTL, maxSize: mockMaxSize, }); }); afterEach(() => { vi.useRealTimers(); }); describe('constructor and initialization', () => { it('should create cache directory on initialization', async () => { await vi.runAllTimersAsync(); expect(fs.mkdir).toHaveBeenCalledWith(mockCacheDir, { recursive: true }); }); it('should handle cache directory creation failure', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); fs.mkdir.mockRejectedValue(new Error('Permission denied')); new ImageCache({ cacheDir: mockCacheDir }); await vi.runAllTimersAsync(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to create cache directory:', expect.any(Error)); consoleErrorSpy.mockRestore(); }); it('should use default values when options are not provided', () => { const defaultCache = new ImageCache(); expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('.cache/images'), { recursive: true }); }); }); describe('generateCacheKey', () => { it('should generate consistent keys for generation inputs', () => { const input = { prompt: 'A beautiful sunset', size: '1024x1024', quality: 'high', format: 'png', }; // Call private method through get fs.stat.mockRejectedValue(new Error('Not found')); cache.get('generate', input); const firstCallCount = crypto.createHash.mock.calls.length; vi.clearAllMocks(); fs.stat.mockRejectedValue(new Error('Not found')); cache.get('generate', input); const secondCallCount = crypto.createHash.mock.calls.length; // Should create same hash for same input expect(firstCallCount).toBeGreaterThan(0); expect(secondCallCount).toBe(firstCallCount); }); it('should generate different keys for edit inputs with images', () => { const editInput = { prompt: 'Add a red bridge', images: ['base64imagedata'], mask: 'base64maskdata', }; fs.stat.mockRejectedValue(new Error('Not found')); cache.get('edit', editInput); const hashCalls = crypto.createHash.mock.calls; expect(hashCalls.some(call => call[0] === 'md5')).toBe(true); }); }); describe('get', () => { const mockInput = { prompt: 'Test prompt', size: '1024x1024', }; it('should return cached data from memory when not expired', async () => { const mockData = { url: 'https://example.com/image.png' }; const key = 'mockedhash123456'; // Manually set memory cache cache.memoryCache.set(key, { key, timestamp: Date.now(), ttl: mockTTL, data: mockData, }); const result = await cache.get('generate', mockInput); expect(result).toEqual(mockData); expect(fs.stat).not.toHaveBeenCalled(); }); it('should return null when memory cache is expired', async () => { const mockData = { url: 'https://example.com/image.png' }; const key = 'mockedhash123456'; // Set expired memory cache cache.memoryCache.set(key, { key, timestamp: Date.now() - (mockTTL + 1) * 1000, ttl: mockTTL, data: mockData, }); fs.stat.mockRejectedValue(new Error('Not found')); const result = await cache.get('generate', mockInput); expect(result).toBeNull(); }); it('should return cached data from disk when not expired', async () => { const mockData = { url: 'https://example.com/image.png' }; const mockFilePath = path.join(mockCacheDir, 'mockedhash123456.json'); fs.stat.mockResolvedValue({ mtimeMs: Date.now() - 1000, // 1 second ago }); fs.readFile.mockResolvedValue(JSON.stringify(mockData)); const result = await cache.get('generate', mockInput); expect(result).toEqual(mockData); expect(fs.readFile).toHaveBeenCalledWith(mockFilePath, 'utf-8'); }); it('should remove expired disk cache files', async () => { const mockFilePath = path.join(mockCacheDir, 'mockedhash123456.json'); fs.stat.mockResolvedValue({ mtimeMs: Date.now() - (mockTTL + 1) * 1000, // Expired }); fs.unlink.mockResolvedValue(undefined); const result = await cache.get('generate', mockInput); expect(result).toBeNull(); expect(fs.unlink).toHaveBeenCalledWith(mockFilePath); }); it('should handle disk read errors gracefully', async () => { fs.stat.mockRejectedValue(new Error('File not found')); const result = await cache.get('generate', mockInput); expect(result).toBeNull(); }); }); describe('set', () => { const mockInput = { prompt: 'Test prompt', }; const mockData = { url: 'https://example.com/image.png' }; it('should store data in memory and disk', async () => { fs.writeFile.mockResolvedValue(undefined); fs.readdir.mockResolvedValue([]); await cache.set('generate', mockInput, mockData); expect(cache.memoryCache.size).toBe(1); expect(fs.writeFile).toHaveBeenCalledWith(path.join(mockCacheDir, 'mockedhash123456.json'), JSON.stringify(mockData, null, 2)); }); it('should use custom TTL when provided', async () => { const customTTL = 7200; fs.writeFile.mockResolvedValue(undefined); fs.readdir.mockResolvedValue([]); await cache.set('generate', mockInput, mockData, customTTL); const memEntry = cache.memoryCache.get('mockedhash123456'); expect(memEntry.ttl).toBe(customTTL); }); it('should handle write errors gracefully', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); fs.writeFile.mockRejectedValue(new Error('Write failed')); await cache.set('generate', mockInput, mockData); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to write cache:', expect.any(Error)); consoleErrorSpy.mockRestore(); }); it('should trigger cleanup when needed', async () => { fs.writeFile.mockResolvedValue(undefined); fs.readdir.mockResolvedValue(['file1.json', 'file2.json']); // Mock large file sizes to trigger cleanup fs.stat.mockResolvedValue({ size: 60 * 1024 * 1024, // 60MB per file mtimeMs: Date.now() - 1000, }); await cache.set('generate', mockInput, mockData); expect(fs.readdir).toHaveBeenCalledWith(mockCacheDir); }); }); describe('cleanupIfNeeded', () => { it('should remove oldest files when cache exceeds max size', async () => { const mockFiles = ['old.json', 'newer.json', 'newest.json']; fs.readdir.mockResolvedValue(mockFiles); fs.stat .mockResolvedValueOnce({ size: 40 * 1024 * 1024, mtimeMs: 1000 }) .mockResolvedValueOnce({ size: 40 * 1024 * 1024, mtimeMs: 2000 }) .mockResolvedValueOnce({ size: 40 * 1024 * 1024, mtimeMs: 3000 }); fs.unlink.mockResolvedValue(undefined); fs.writeFile.mockResolvedValue(undefined); await cache.set('generate', { prompt: 'test' }, { data: 'test' }); expect(fs.unlink).toHaveBeenCalledWith(path.join(mockCacheDir, 'old.json')); }); it('should remove expired entries from memory cache', async () => { fs.readdir.mockResolvedValue([]); fs.writeFile.mockResolvedValue(undefined); // Add expired entry to memory cache cache.memoryCache.set('expired', { key: 'expired', timestamp: Date.now() - (mockTTL + 1) * 1000, ttl: mockTTL, data: {}, }); // Add valid entry cache.memoryCache.set('valid', { key: 'valid', timestamp: Date.now(), ttl: mockTTL, data: {}, }); await cache.set('generate', { prompt: 'test' }, { data: 'test' }); expect(cache.memoryCache.has('expired')).toBe(false); expect(cache.memoryCache.has('valid')).toBe(true); }); it('should handle cleanup errors gracefully', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); fs.readdir.mockRejectedValue(new Error('Read failed')); fs.writeFile.mockResolvedValue(undefined); await cache.set('generate', { prompt: 'test' }, { data: 'test' }); expect(consoleErrorSpy).toHaveBeenCalledWith('Cache cleanup error:', expect.any(Error)); consoleErrorSpy.mockRestore(); }); }); describe('clear', () => { it('should clear memory cache and delete all files', async () => { const mockFiles = ['file1.json', 'file2.json']; fs.readdir.mockResolvedValue(mockFiles); fs.unlink.mockResolvedValue(undefined); // Add items to memory cache cache.memoryCache.set('key1', { data: 'test1' }); cache.memoryCache.set('key2', { data: 'test2' }); await cache.clear(); expect(cache.memoryCache.size).toBe(0); expect(fs.unlink).toHaveBeenCalledTimes(2); expect(fs.unlink).toHaveBeenCalledWith(path.join(mockCacheDir, 'file1.json')); expect(fs.unlink).toHaveBeenCalledWith(path.join(mockCacheDir, 'file2.json')); }); it('should handle clear errors gracefully', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); fs.readdir.mockRejectedValue(new Error('Read failed')); await cache.clear(); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to clear cache:', expect.any(Error)); consoleErrorSpy.mockRestore(); }); }); describe('getCacheStats', () => { it('should return correct cache statistics', () => { // Add items to memory cache cache.memoryCache.set('key1', { data: 'test1' }); cache.memoryCache.set('key2', { data: 'test2' }); cache.memoryCache.set('key3', { data: 'test3' }); const stats = cache.getCacheStats(); expect(stats).toEqual({ memoryEntries: 3, estimatedDiskUsage: '1.5 MB', }); }); }); describe('singleton instance', () => { it('should create singleton with environment variables', async () => { const originalEnv = process.env; vi.resetModules(); process.env = { ...originalEnv, CACHE_DIR: '/custom/cache', CACHE_TTL: '7200', CACHE_MAX_SIZE: '200', }; const { imageCache } = await import('./cache'); expect(fs.mkdir).toHaveBeenCalledWith('/custom/cache', { recursive: true }); process.env = originalEnv; }); }); }); //# sourceMappingURL=cache.test.js.map