Skip to content

Commit 74e3357

Browse files
committed
test(11-04): add comprehensive unit tests for AI services and cache
- IssueEnrichmentAIService.test.ts: 25 tests for enrichment with AI/fallback paths - LabelSuggestionService.test.ts: 23 tests for label suggestions with tiered confidence - DuplicateDetectionService.test.ts: 25 tests for embedding-based duplicate detection - RelatedIssueLinkingService.test.ts: 27 tests for semantic/dependency/component relations - EmbeddingCache.test.ts: 26 tests for TTL, hash validation, eviction Total: 126 new tests covering all Phase 11 AI services
1 parent edf2a2e commit 74e3357

5 files changed

Lines changed: 2105 additions & 0 deletions

File tree

tests/cache/EmbeddingCache.test.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/**
2+
* Unit tests for EmbeddingCache
3+
*
4+
* Tests in-memory embedding cache with TTL expiration,
5+
* content hash validation, and LRU-style eviction.
6+
*/
7+
8+
import { EmbeddingCache } from '../../src/cache/EmbeddingCache';
9+
10+
describe('EmbeddingCache', () => {
11+
let cache: EmbeddingCache;
12+
13+
beforeEach(() => {
14+
jest.useFakeTimers();
15+
cache = new EmbeddingCache();
16+
});
17+
18+
afterEach(() => {
19+
jest.useRealTimers();
20+
});
21+
22+
describe('Basic Operations', () => {
23+
it('should set and get embedding', () => {
24+
const embedding = [0.1, 0.2, 0.3, 0.4, 0.5];
25+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
26+
27+
cache.set('issue-1', contentHash, embedding);
28+
const result = cache.get('issue-1', contentHash);
29+
30+
expect(result).toEqual(embedding);
31+
});
32+
33+
it('should return null for non-existent entry', () => {
34+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
35+
const result = cache.get('non-existent', contentHash);
36+
37+
expect(result).toBeNull();
38+
});
39+
40+
it('should check if issue exists with has()', () => {
41+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
42+
cache.set('issue-1', contentHash, [0.1]);
43+
44+
expect(cache.has('issue-1')).toBe(true);
45+
expect(cache.has('issue-2')).toBe(false);
46+
});
47+
48+
it('should clear all entries', () => {
49+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
50+
cache.set('issue-1', contentHash, [0.1]);
51+
cache.set('issue-2', contentHash, [0.2]);
52+
53+
cache.clear();
54+
55+
expect(cache.size()).toBe(0);
56+
expect(cache.has('issue-1')).toBe(false);
57+
});
58+
59+
it('should return correct size', () => {
60+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
61+
62+
expect(cache.size()).toBe(0);
63+
64+
cache.set('issue-1', contentHash, [0.1]);
65+
expect(cache.size()).toBe(1);
66+
67+
cache.set('issue-2', contentHash, [0.2]);
68+
expect(cache.size()).toBe(2);
69+
});
70+
71+
it('should return all keys', () => {
72+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
73+
cache.set('issue-1', contentHash, [0.1]);
74+
cache.set('issue-2', contentHash, [0.2]);
75+
76+
const keys = cache.keys();
77+
78+
expect(keys).toContain('issue-1');
79+
expect(keys).toContain('issue-2');
80+
expect(keys).toHaveLength(2);
81+
});
82+
83+
it('should overwrite existing entry', () => {
84+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
85+
cache.set('issue-1', contentHash, [0.1]);
86+
cache.set('issue-1', contentHash, [0.9]);
87+
88+
const result = cache.get('issue-1', contentHash);
89+
expect(result).toEqual([0.9]);
90+
});
91+
});
92+
93+
describe('TTL Expiration', () => {
94+
it('should return null for expired entry', () => {
95+
// Create cache with 1 hour TTL
96+
const cacheWithTTL = new EmbeddingCache(60 * 60 * 1000); // 1 hour
97+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
98+
99+
cacheWithTTL.set('issue-1', contentHash, [0.1]);
100+
101+
// Advance time past TTL
102+
jest.advanceTimersByTime(2 * 60 * 60 * 1000); // 2 hours
103+
104+
const result = cacheWithTTL.get('issue-1', contentHash);
105+
expect(result).toBeNull();
106+
});
107+
108+
it('should return entry before TTL expires', () => {
109+
const cacheWithTTL = new EmbeddingCache(60 * 60 * 1000); // 1 hour
110+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
111+
112+
cacheWithTTL.set('issue-1', contentHash, [0.1]);
113+
114+
// Advance time but not past TTL
115+
jest.advanceTimersByTime(30 * 60 * 1000); // 30 minutes
116+
117+
const result = cacheWithTTL.get('issue-1', contentHash);
118+
expect(result).toEqual([0.1]);
119+
});
120+
121+
it('should delete expired entry on get()', () => {
122+
const cacheWithTTL = new EmbeddingCache(60 * 60 * 1000);
123+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
124+
125+
cacheWithTTL.set('issue-1', contentHash, [0.1]);
126+
jest.advanceTimersByTime(2 * 60 * 60 * 1000);
127+
128+
cacheWithTTL.get('issue-1', contentHash);
129+
130+
// Entry should be removed
131+
expect(cacheWithTTL.has('issue-1')).toBe(false);
132+
});
133+
134+
it('should use default TTL of 24 hours', () => {
135+
const defaultCache = new EmbeddingCache();
136+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
137+
138+
defaultCache.set('issue-1', contentHash, [0.1]);
139+
140+
// 12 hours later should still work
141+
jest.advanceTimersByTime(12 * 60 * 60 * 1000);
142+
expect(defaultCache.get('issue-1', contentHash)).toEqual([0.1]);
143+
144+
// 25 hours total should expire
145+
jest.advanceTimersByTime(13 * 60 * 60 * 1000);
146+
expect(defaultCache.get('issue-1', contentHash)).toBeNull();
147+
});
148+
149+
it('should clean expired entries with cleanExpired()', () => {
150+
const cacheWithTTL = new EmbeddingCache(60 * 60 * 1000);
151+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
152+
153+
cacheWithTTL.set('issue-1', contentHash, [0.1]);
154+
cacheWithTTL.set('issue-2', contentHash, [0.2]);
155+
156+
jest.advanceTimersByTime(30 * 60 * 1000); // 30 min
157+
cacheWithTTL.set('issue-3', contentHash, [0.3]); // Added later
158+
159+
jest.advanceTimersByTime(45 * 60 * 1000); // 75 min total
160+
161+
const removed = cacheWithTTL.cleanExpired();
162+
163+
expect(removed).toBe(2); // issue-1 and issue-2 expired
164+
expect(cacheWithTTL.has('issue-3')).toBe(true);
165+
});
166+
});
167+
168+
describe('Content Hash Validation', () => {
169+
it('should return null if content hash changes', () => {
170+
const hash1 = EmbeddingCache.computeContentHash('title', 'body');
171+
const hash2 = EmbeddingCache.computeContentHash('title', 'modified body');
172+
173+
cache.set('issue-1', hash1, [0.1]);
174+
175+
// Request with different hash
176+
const result = cache.get('issue-1', hash2);
177+
expect(result).toBeNull();
178+
});
179+
180+
it('should delete entry when content hash changes', () => {
181+
const hash1 = EmbeddingCache.computeContentHash('title', 'body');
182+
const hash2 = EmbeddingCache.computeContentHash('title', 'modified body');
183+
184+
cache.set('issue-1', hash1, [0.1]);
185+
cache.get('issue-1', hash2);
186+
187+
// Entry should be removed
188+
expect(cache.has('issue-1')).toBe(false);
189+
});
190+
191+
it('should compute consistent hash for same content', () => {
192+
const hash1 = EmbeddingCache.computeContentHash('title', 'body');
193+
const hash2 = EmbeddingCache.computeContentHash('title', 'body');
194+
195+
expect(hash1).toBe(hash2);
196+
});
197+
198+
it('should compute different hash for different content', () => {
199+
const hash1 = EmbeddingCache.computeContentHash('title1', 'body');
200+
const hash2 = EmbeddingCache.computeContentHash('title2', 'body');
201+
202+
expect(hash1).not.toBe(hash2);
203+
});
204+
205+
it('should normalize content before hashing (trim + lowercase)', () => {
206+
const hash1 = EmbeddingCache.computeContentHash(' Title ', ' Body ');
207+
const hash2 = EmbeddingCache.computeContentHash('title', 'body');
208+
209+
expect(hash1).toBe(hash2);
210+
});
211+
212+
it('should handle empty title or body', () => {
213+
const hashEmptyTitle = EmbeddingCache.computeContentHash('', 'body');
214+
const hashEmptyBody = EmbeddingCache.computeContentHash('title', '');
215+
const hashBothEmpty = EmbeddingCache.computeContentHash('', '');
216+
217+
expect(hashEmptyTitle.length).toBe(64); // SHA256 hex length
218+
expect(hashEmptyBody.length).toBe(64);
219+
expect(hashBothEmpty.length).toBe(64);
220+
});
221+
222+
it('should handle null-ish values', () => {
223+
const hash = EmbeddingCache.computeContentHash(null as any, undefined as any);
224+
expect(hash.length).toBe(64);
225+
});
226+
});
227+
228+
describe('Eviction', () => {
229+
it('should evict oldest entries when exceeding maxSize', () => {
230+
const smallCache = new EmbeddingCache(24 * 60 * 60 * 1000, 5); // Max 5 entries
231+
232+
// Add 5 entries
233+
for (let i = 1; i <= 5; i++) {
234+
const hash = EmbeddingCache.computeContentHash(`title-${i}`, 'body');
235+
smallCache.set(`issue-${i}`, hash, [i * 0.1]);
236+
jest.advanceTimersByTime(1000); // Space them out in time
237+
}
238+
239+
expect(smallCache.size()).toBe(5);
240+
241+
// Add 6th entry - should trigger eviction
242+
const hash6 = EmbeddingCache.computeContentHash('title-6', 'body');
243+
smallCache.set('issue-6', hash6, [0.6]);
244+
245+
// Should have evicted oldest (issue-1)
246+
expect(smallCache.size()).toBeLessThanOrEqual(5);
247+
expect(smallCache.has('issue-6')).toBe(true);
248+
});
249+
250+
it('should evict approximately 10% of maxSize entries', () => {
251+
const cache100 = new EmbeddingCache(24 * 60 * 60 * 1000, 100);
252+
253+
// Fill to capacity
254+
for (let i = 1; i <= 100; i++) {
255+
const hash = EmbeddingCache.computeContentHash(`title-${i}`, 'body');
256+
cache100.set(`issue-${i}`, hash, [i * 0.01]);
257+
jest.advanceTimersByTime(10);
258+
}
259+
260+
// Add one more
261+
const hashNew = EmbeddingCache.computeContentHash('new', 'body');
262+
cache100.set('issue-new', hashNew, [0.99]);
263+
264+
// Should have evicted ~10 entries
265+
expect(cache100.size()).toBeLessThanOrEqual(91);
266+
});
267+
268+
it('should not evict when updating existing entry', () => {
269+
const smallCache = new EmbeddingCache(24 * 60 * 60 * 1000, 3);
270+
271+
const hash = EmbeddingCache.computeContentHash('title', 'body');
272+
smallCache.set('issue-1', hash, [0.1]);
273+
smallCache.set('issue-2', hash, [0.2]);
274+
smallCache.set('issue-3', hash, [0.3]);
275+
276+
// Update existing
277+
smallCache.set('issue-1', hash, [0.9]);
278+
279+
expect(smallCache.size()).toBe(3);
280+
expect(smallCache.get('issue-1', hash)).toEqual([0.9]);
281+
});
282+
283+
it('should use default maxSize of 10000', () => {
284+
const defaultCache = new EmbeddingCache();
285+
const stats = defaultCache.getStats();
286+
287+
expect(stats.maxSize).toBe(10000);
288+
});
289+
});
290+
291+
describe('Statistics', () => {
292+
it('should return cache statistics', () => {
293+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
294+
cache.set('issue-1', contentHash, [0.1]);
295+
296+
const stats = cache.getStats();
297+
298+
expect(stats).toHaveProperty('size');
299+
expect(stats).toHaveProperty('maxSize');
300+
expect(stats).toHaveProperty('ttlMs');
301+
expect(stats.size).toBe(1);
302+
});
303+
304+
it('should track oldest and newest entry ages', () => {
305+
const contentHash = EmbeddingCache.computeContentHash('title', 'body');
306+
307+
cache.set('issue-1', contentHash, [0.1]);
308+
jest.advanceTimersByTime(60 * 1000); // 1 minute
309+
cache.set('issue-2', contentHash, [0.2]);
310+
jest.advanceTimersByTime(30 * 1000); // 30 more seconds
311+
312+
const stats = cache.getStats();
313+
314+
expect(stats.oldestEntryAge).toBeGreaterThan(stats.newestEntryAge!);
315+
});
316+
317+
it('should handle empty cache stats', () => {
318+
const stats = cache.getStats();
319+
320+
expect(stats.size).toBe(0);
321+
expect(stats.oldestEntryAge).toBeUndefined();
322+
expect(stats.newestEntryAge).toBeUndefined();
323+
});
324+
});
325+
});

0 commit comments

Comments
 (0)