From 23303d4cb41b6ded1a45b418991ec335cba61e39 Mon Sep 17 00:00:00 2001 From: MD JUBER QURAISHI Date: Wed, 29 Apr 2026 05:39:45 +0530 Subject: [PATCH] feat(search): add Elasticsearch integration layer and index management --- .../elasticsearch.service.spec.ts | 98 ++++++++++++++++ .../elasticsearch/elasticsearch.service.ts | 110 ++++++++++++++++++ src/search/search.module.ts | 2 + src/search/search.service.ts | 1 + 4 files changed, 211 insertions(+) create mode 100644 src/search/elasticsearch/elasticsearch.service.spec.ts create mode 100644 src/search/elasticsearch/elasticsearch.service.ts diff --git a/src/search/elasticsearch/elasticsearch.service.spec.ts b/src/search/elasticsearch/elasticsearch.service.spec.ts new file mode 100644 index 0000000..f5a099c --- /dev/null +++ b/src/search/elasticsearch/elasticsearch.service.spec.ts @@ -0,0 +1,98 @@ +import { ElasticsearchService } from './elasticsearch.service'; + +describe('ElasticsearchService', () => { + let service: ElasticsearchService; + + const mockElasticsearchClient = { + indices: { + exists: jest.fn(), + create: jest.fn(), + }, + bulk: jest.fn(), + delete: jest.fn(), + cluster: { + health: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + service = new ElasticsearchService(mockElasticsearchClient as any); + }); + + describe('service initialization', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create missing indices on module init', async () => { + mockElasticsearchClient.indices.exists + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + await service.onModuleInit(); + + expect(mockElasticsearchClient.indices.create).toHaveBeenCalledTimes(2); + }); + + it('should skip index creation when indices already exist', async () => { + mockElasticsearchClient.indices.exists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + await service.onModuleInit(); + + expect(mockElasticsearchClient.indices.create).not.toHaveBeenCalled(); + }); + }); + + describe('bulk indexing', () => { + it('should bulk index course documents', async () => { + mockElasticsearchClient.bulk.mockResolvedValue({ + errors: false, + }); + + const docs = [ + { + id: 'course-1', + body: { + title: 'NestJS Masterclass', + category: 'backend', + }, + }, + ]; + + await service.bulkIndexCourses(docs); + + expect(mockElasticsearchClient.bulk).toHaveBeenCalled(); + }); + }); + + describe('document deletion', () => { + it('should delete course by id', async () => { + mockElasticsearchClient.delete.mockResolvedValue({ + result: 'deleted', + }); + + await service.deleteCourse('course-1'); + + expect(mockElasticsearchClient.delete).toHaveBeenCalledWith({ + index: 'courses', + id: 'course-1', + }); + }); + }); + + describe('health check', () => { + it('should return cluster health', async () => { + mockElasticsearchClient.cluster.health.mockResolvedValue({ + status: 'green', + }); + + const result = await service.healthCheck(); + + expect(result.status).toBe('green'); + }); + }); +}); diff --git a/src/search/elasticsearch/elasticsearch.service.ts b/src/search/elasticsearch/elasticsearch.service.ts new file mode 100644 index 0000000..1fd9735 --- /dev/null +++ b/src/search/elasticsearch/elasticsearch.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ElasticsearchService as NestElasticsearchService } from '@nestjs/elasticsearch'; + +@Injectable() +export class ElasticsearchService implements OnModuleInit { + private readonly logger = new Logger(ElasticsearchService.name); + + private readonly indices = { + courses: 'courses', + analytics: 'search_analytics', + }; + + constructor(private readonly elasticsearch: NestElasticsearchService) {} + + async onModuleInit(): Promise { + await this.ensureCoursesIndex(); + await this.ensureAnalyticsIndex(); + } + + private async ensureCoursesIndex(): Promise { + const exists = await this.elasticsearch.indices.exists({ + index: this.indices.courses, + }); + + if (!exists) { + await this.elasticsearch.indices.create({ + index: this.indices.courses, + mappings: { + properties: { + title: { + type: 'text', + fields: { + keyword: { type: 'keyword' }, + search: { type: 'search_as_you_type' }, + }, + }, + description: { type: 'text' }, + content: { type: 'text' }, + tags: { type: 'keyword' }, + category: { type: 'keyword' }, + level: { type: 'keyword' }, + language: { type: 'keyword' }, + price: { type: 'float' }, + rating: { type: 'float' }, + views: { type: 'integer' }, + enrollments: { type: 'integer' }, + duration: { type: 'integer' }, + instructorId: { type: 'keyword' }, + instructorName: { type: 'text' }, + status: { type: 'keyword' }, + createdAt: { type: 'date' }, + updatedAt: { type: 'date' }, + }, + }, + }); + + this.logger.log('Created Elasticsearch courses index'); + } + } + + private async ensureAnalyticsIndex(): Promise { + const exists = await this.elasticsearch.indices.exists({ + index: this.indices.analytics, + }); + + if (!exists) { + await this.elasticsearch.indices.create({ + index: this.indices.analytics, + mappings: { + properties: { + query: { type: 'keyword' }, + resultsCount: { type: 'integer' }, + sort: { type: 'keyword' }, + timestamp: { type: 'date' }, + }, + }, + }); + + this.logger.log('Created Elasticsearch analytics index'); + } + } + + async bulkIndexCourses(documents: Array<{ id: string; body: Record }>) { + const operations = documents.flatMap((doc) => [ + { + index: { + _index: this.indices.courses, + _id: doc.id, + }, + }, + doc.body, + ]); + + return this.elasticsearch.bulk({ + refresh: true, + operations, + }); + } + + async deleteCourse(id: string) { + return this.elasticsearch.delete({ + index: this.indices.courses, + id, + }); + } + + async healthCheck() { + return this.elasticsearch.cluster.health(); + } +} diff --git a/src/search/search.module.ts b/src/search/search.module.ts index 7f0a660..9bc01d1 100644 --- a/src/search/search.module.ts +++ b/src/search/search.module.ts @@ -8,6 +8,7 @@ import { AutoCompleteService } from './autocomplete/autocomplete.service'; import { SearchFiltersService } from './filters/search-filters.service'; import { SearchIndexOptimizerService } from './indexing/search-index-optimizer.service'; import { createElasticsearchConfig } from '../config/elasticsearch.config'; +import { ElasticsearchService } from './elasticsearch/elasticsearch.service'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { createElasticsearchConfig } from '../config/elasticsearch.config'; AutoCompleteService, SearchFiltersService, SearchIndexOptimizerService, + ElasticsearchService, ], exports: [ SearchService, diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 3c43c8d..8202e71 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -6,6 +6,7 @@ import { CachingService } from '../caching/caching.service'; import { CACHE_TTL, CACHE_PREFIXES } from '../caching/caching.constants'; import { SEARCH_CONSTANTS } from './search.constants'; + export const COURSES_INDEX = 'courses'; export const SEARCH_ANALYTICS_INDEX = 'search_analytics'; const SEARCH_SOURCE_FIELDS = [