Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/search/search.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const mockQueryBuilder = {
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
getMany: jest.fn().mockResolvedValue([]),
};

const mockCourseRepo = {
Expand Down Expand Up @@ -105,4 +107,47 @@ describe('SearchService', () => {
expect(result).toBeDefined();
expect(Date.now() - start).toBeLessThan(100);
});

describe('Autocomplete LRU Cache', () => {
it('should evict oldest entries when cache cap is reached', async () => {
const mockCourses = [
{ id: '1', title: 'Course 1' },
{ id: '2', title: 'Course 2' },
];
mockQueryBuilder.getMany.mockResolvedValue(mockCourses);

// Fill cache beyond its max size (1000 entries)
for (let i = 0; i < 1001; i++) {
await service.getAutoComplete(`query${i}`);
}

// First entry should have been evicted
const firstResult = await service.getAutoComplete('query0');
expect(mockQueryBuilder.getMany).toHaveBeenCalled(); // Cache miss, so DB query is made

// Last entry should still be cached
mockQueryBuilder.getMany.mockClear();
const lastResult = await service.getAutoComplete('query1000');
expect(mockQueryBuilder.getMany).not.toHaveBeenCalled(); // Cache hit, no DB query
});

it('should enforce TTL via cache backend', async () => {
const mockCourses = [{ id: '1', title: 'Course 1' }];
mockQueryBuilder.getMany.mockResolvedValue(mockCourses);

// First call - cache miss
await service.getAutoComplete('test');
expect(mockQueryBuilder.getMany).toHaveBeenCalledTimes(1);

// Second call immediately - cache hit
await service.getAutoComplete('test');
expect(mockQueryBuilder.getMany).toHaveBeenCalledTimes(1);

// Wait for TTL to expire (300000ms = 5 minutes)
// In test, we can't actually wait 5 minutes, but we verify the TTL is configured
// The LRU cache handles TTL automatically, so we just verify the cache is using TTL
const cache = (service as any).autocompleteCache;
expect(cache.options.ttl).toBe(300000);
});
});
});
16 changes: 11 additions & 5 deletions src/search/search.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Course } from '../courses/entities/course.entity';
import { LRUCache } from 'lru-cache';

export interface SearchFilters {
category?: string | string[];
Expand Down Expand Up @@ -37,15 +38,20 @@ export class SearchService {
private readonly logger = new Logger(SearchService.name);
private readonly AUTOCOMPLETE_LIMIT = 10;
private readonly CACHE_TTL_MS = 300000; // 5 minutes
private autocompleteCache: Map<string, { results: AutocompleteResult[]; timestamp: number }> =
new Map();
private readonly AUTOCOMPLETE_CACHE_MAX_SIZE = 1000;
private autocompleteCache: LRUCache<string, AutocompleteResult[]>;

constructor(
@InjectRepository(Course)
private readonly courseRepository: Repository<Course>,
private readonly elasticsearch: NestElasticsearchService,
@Optional() @Inject(CACHE_MANAGER) private readonly cacheManager?: Cache,
) {}
) {
this.autocompleteCache = new LRUCache<string, AutocompleteResult[]>({
max: this.AUTOCOMPLETE_CACHE_MAX_SIZE,
ttl: this.CACHE_TTL_MS,
});
}

async search(
query: string,
Expand Down Expand Up @@ -102,7 +108,7 @@ export class SearchService {
if (!query || query.length < 2) return [];

const cached = this.autocompleteCache.get(query);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) return cached.results;
if (cached) return cached;

try {
const courses = await this.courseRepository
Expand All @@ -119,7 +125,7 @@ export class SearchService {
metadata: { courseId: course.id },
}));

this.autocompleteCache.set(query, { results, timestamp: Date.now() });
this.autocompleteCache.set(query, results);
return results;
} catch (err) {
this.logger.error(`Autocomplete failed: ${(err as Error).message}`);
Expand Down
Loading