import { describe, it, expect, beforeEach } from 'vitest'; import type { StorageAdapter } from '@mosaicstack/storage'; import { KeywordAdapter } from './keyword.js'; /* ------------------------------------------------------------------ */ /* In-memory mock StorageAdapter */ /* ------------------------------------------------------------------ */ function createMockStorage(): StorageAdapter { const collections = new Map>>(); let idCounter = 0; function getCollection(name: string): Map> { if (!collections.has(name)) collections.set(name, new Map()); return collections.get(name)!; } const adapter: StorageAdapter = { name: 'mock', async create>( collection: string, data: T, ): Promise { const id = String(++idCounter); const record = { ...data, id }; getCollection(collection).set(id, record); return record as T & { id: string }; }, async read>( collection: string, id: string, ): Promise { const record = getCollection(collection).get(id); return (record as T) ?? null; }, async update(collection: string, id: string, data: Record): Promise { const col = getCollection(collection); const existing = col.get(id); if (!existing) return false; col.set(id, { ...existing, ...data }); return true; }, async delete(collection: string, id: string): Promise { return getCollection(collection).delete(id); }, async find>( collection: string, filter?: Record, ): Promise { const col = getCollection(collection); const results: T[] = []; for (const record of col.values()) { if (filter && !matchesFilter(record, filter)) continue; results.push(record as T); } return results; }, async findOne>( collection: string, filter: Record, ): Promise { const col = getCollection(collection); for (const record of col.values()) { if (matchesFilter(record, filter)) return record as T; } return null; }, async count(collection: string, filter?: Record): Promise { const rows = await adapter.find(collection, filter); return rows.length; }, async transaction(fn: (tx: StorageAdapter) => Promise): Promise { return fn(adapter); }, async migrate(): Promise {}, async close(): Promise {}, }; return adapter; } function matchesFilter(record: Record, filter: Record): boolean { for (const [key, value] of Object.entries(filter)) { if (record[key] !== value) return false; } return true; } /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe('KeywordAdapter', () => { let adapter: KeywordAdapter; beforeEach(() => { adapter = new KeywordAdapter({ type: 'keyword', storage: createMockStorage() }); }); /* ---- Preferences ---- */ describe('preferences', () => { it('should set and get a preference', async () => { await adapter.setPreference('u1', 'theme', 'dark'); const value = await adapter.getPreference('u1', 'theme'); expect(value).toBe('dark'); }); it('should return null for missing preference', async () => { const value = await adapter.getPreference('u1', 'nonexistent'); expect(value).toBeNull(); }); it('should upsert an existing preference', async () => { await adapter.setPreference('u1', 'theme', 'dark'); await adapter.setPreference('u1', 'theme', 'light'); const value = await adapter.getPreference('u1', 'theme'); expect(value).toBe('light'); }); it('should delete a preference', async () => { await adapter.setPreference('u1', 'theme', 'dark'); const deleted = await adapter.deletePreference('u1', 'theme'); expect(deleted).toBe(true); const value = await adapter.getPreference('u1', 'theme'); expect(value).toBeNull(); }); it('should return false when deleting nonexistent preference', async () => { const deleted = await adapter.deletePreference('u1', 'nope'); expect(deleted).toBe(false); }); it('should list preferences by userId', async () => { await adapter.setPreference('u1', 'theme', 'dark', 'appearance'); await adapter.setPreference('u1', 'lang', 'en', 'locale'); await adapter.setPreference('u2', 'theme', 'light', 'appearance'); const prefs = await adapter.listPreferences('u1'); expect(prefs).toHaveLength(2); expect(prefs.map((p) => p.key).sort()).toEqual(['lang', 'theme']); }); it('should filter preferences by category', async () => { await adapter.setPreference('u1', 'theme', 'dark', 'appearance'); await adapter.setPreference('u1', 'lang', 'en', 'locale'); const prefs = await adapter.listPreferences('u1', 'appearance'); expect(prefs).toHaveLength(1); expect(prefs[0]!.key).toBe('theme'); }); }); /* ---- Insights ---- */ describe('insights', () => { it('should store and retrieve an insight', async () => { const insight = await adapter.storeInsight({ userId: 'u1', content: 'TypeScript is great for type safety', source: 'chat', category: 'technical', relevanceScore: 0.9, }); expect(insight.id).toBeDefined(); expect(insight.content).toBe('TypeScript is great for type safety'); const fetched = await adapter.getInsight(insight.id); expect(fetched).not.toBeNull(); expect(fetched!.content).toBe('TypeScript is great for type safety'); }); it('should return null for missing insight', async () => { const result = await adapter.getInsight('nonexistent'); expect(result).toBeNull(); }); it('should delete an insight', async () => { const insight = await adapter.storeInsight({ userId: 'u1', content: 'test', source: 'chat', category: 'general', relevanceScore: 0.5, }); const deleted = await adapter.deleteInsight(insight.id); expect(deleted).toBe(true); const fetched = await adapter.getInsight(insight.id); expect(fetched).toBeNull(); }); }); /* ---- Keyword Search ---- */ describe('searchInsights', () => { beforeEach(async () => { await adapter.storeInsight({ userId: 'u1', content: 'TypeScript provides excellent type safety for JavaScript projects', source: 'chat', category: 'technical', relevanceScore: 0.9, }); await adapter.storeInsight({ userId: 'u1', content: 'React hooks simplify state management in components', source: 'chat', category: 'technical', relevanceScore: 0.8, }); await adapter.storeInsight({ userId: 'u1', content: 'TypeScript and React work great together for type safe components', source: 'chat', category: 'technical', relevanceScore: 0.85, }); await adapter.storeInsight({ userId: 'u2', content: 'TypeScript is popular', source: 'chat', category: 'general', relevanceScore: 0.5, }); }); it('should find insights by exact keyword', async () => { const results = await adapter.searchInsights('u1', 'hooks'); expect(results).toHaveLength(1); expect(results[0]!.content).toContain('hooks'); }); it('should be case-insensitive', async () => { const results = await adapter.searchInsights('u1', 'TYPESCRIPT'); expect(results.length).toBeGreaterThanOrEqual(1); for (const r of results) { expect(r.content.toLowerCase()).toContain('typescript'); } }); it('should rank multi-word matches higher', async () => { const results = await adapter.searchInsights('u1', 'TypeScript React'); // The insight mentioning both "TypeScript" and "React" should rank first (score=2) expect(results[0]!.score).toBe(2); expect(results[0]!.content).toContain('TypeScript'); expect(results[0]!.content).toContain('React'); }); it('should return empty for no matches', async () => { const results = await adapter.searchInsights('u1', 'python django'); expect(results).toHaveLength(0); }); it('should filter by userId', async () => { const results = await adapter.searchInsights('u2', 'TypeScript'); expect(results).toHaveLength(1); expect(results[0]!.content).toBe('TypeScript is popular'); }); it('should respect limit option', async () => { const results = await adapter.searchInsights('u1', 'TypeScript', { limit: 1 }); expect(results).toHaveLength(1); }); it('should return empty for empty query', async () => { const results = await adapter.searchInsights('u1', ' '); expect(results).toHaveLength(0); }); }); /* ---- Lifecycle ---- */ describe('lifecycle', () => { it('should have name "keyword"', () => { expect(adapter.name).toBe('keyword'); }); it('should have null embedder', () => { expect(adapter.embedder).toBeNull(); }); it('should close without error', async () => { await expect(adapter.close()).resolves.toBeUndefined(); }); }); });