214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { ServiceUnavailableException } from '@nestjs/common';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
|
|
import { getQueueToken } from '@nestjs/bullmq';
|
|
import { RagService } from '../rag.service';
|
|
import { QdrantService } from '../qdrant.service';
|
|
import { EmbeddingService } from '../embedding.service';
|
|
import { LocalLlmService } from '../local-llm.service';
|
|
import { IngestionService } from '../ingestion.service';
|
|
import { DocumentChunk } from '../entities/document-chunk.entity';
|
|
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
|
|
|
|
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
|
|
|
const mockQdrant = {
|
|
isReady: jest.fn(),
|
|
hybridSearch: jest.fn(),
|
|
deleteByDocumentId: jest.fn(),
|
|
};
|
|
|
|
const mockEmbedding = {
|
|
embed: jest.fn(),
|
|
};
|
|
|
|
const mockLocalLlm = {
|
|
generate: jest.fn(),
|
|
sanitizeInput: jest.fn((t: string) => t),
|
|
};
|
|
|
|
const mockIngestion = { enqueue: jest.fn() };
|
|
|
|
const mockChunkRepo = {
|
|
count: jest.fn(),
|
|
delete: jest.fn(),
|
|
manager: {
|
|
query: jest.fn(),
|
|
},
|
|
};
|
|
|
|
const mockRedis = {
|
|
get: jest.fn(),
|
|
setex: jest.fn(),
|
|
};
|
|
|
|
const mockVectorDeletionQueue = {
|
|
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
|
|
};
|
|
|
|
describe('RagService', () => {
|
|
let service: RagService;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
RagService,
|
|
{ provide: QdrantService, useValue: mockQdrant },
|
|
{ provide: EmbeddingService, useValue: mockEmbedding },
|
|
{ provide: LocalLlmService, useValue: mockLocalLlm },
|
|
{ provide: IngestionService, useValue: mockIngestion },
|
|
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
|
|
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
|
{
|
|
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
|
|
useValue: mockVectorDeletionQueue,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<RagService>(RagService);
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('query()', () => {
|
|
const dto = {
|
|
question: 'เอกสารเกี่ยวกับอะไร?',
|
|
projectPublicId: 'proj-uuid-1234',
|
|
};
|
|
const memberPerms: string[] = [];
|
|
const adminPerms = ['system.manage_all'];
|
|
|
|
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
mockQdrant.hybridSearch.mockResolvedValue([
|
|
{
|
|
chunkId: 'chunk-1',
|
|
publicId: 'att-1',
|
|
docType: 'CORR',
|
|
docNumber: 'REF-001',
|
|
revision: null,
|
|
projectCode: 'PRJ-001',
|
|
contentPreview: 'เนื้อหาเอกสาร',
|
|
score: 0.92,
|
|
},
|
|
]);
|
|
mockLocalLlm.generate.mockResolvedValue({
|
|
answer: 'คำตอบ',
|
|
usedFallbackModel: false,
|
|
});
|
|
|
|
const result = await service.query(dto, memberPerms);
|
|
|
|
expect(result.answer).toBe('คำตอบ');
|
|
expect(result.citations).toHaveLength(1);
|
|
expect(result.usedFallbackModel).toBe(false);
|
|
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should return cached result without calling Qdrant on cache hit', async () => {
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
const cached = JSON.stringify({
|
|
answer: 'cached answer',
|
|
citations: [],
|
|
confidence: 0.9,
|
|
usedFallbackModel: false,
|
|
});
|
|
mockRedis.get.mockResolvedValue(cached);
|
|
|
|
const result = await service.query(dto, memberPerms);
|
|
|
|
expect(result.answer).toBe('cached answer');
|
|
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
|
|
expect(mockEmbedding.embed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
mockLocalLlm.generate.mockResolvedValue({
|
|
answer: 'ลับมาก',
|
|
usedFallbackModel: false,
|
|
});
|
|
|
|
const result = await service.query(dto, adminPerms);
|
|
|
|
expect(mockRedis.get).not.toHaveBeenCalled();
|
|
expect(mockRedis.setex).not.toHaveBeenCalled();
|
|
expect(mockLocalLlm.generate).toHaveBeenCalledWith(expect.any(String));
|
|
expect(result.usedFallbackModel).toBe(false);
|
|
});
|
|
|
|
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
|
|
mockQdrant.isReady.mockReturnValue(false);
|
|
|
|
await expect(service.query(dto, memberPerms)).rejects.toThrow(
|
|
ServiceUnavailableException
|
|
);
|
|
});
|
|
|
|
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
mockLocalLlm.generate.mockResolvedValue({
|
|
answer: 'A',
|
|
usedFallbackModel: false,
|
|
});
|
|
|
|
await service.query(
|
|
{ question: 'Q?', projectPublicId: 'proj-A' },
|
|
memberPerms
|
|
);
|
|
await service.query(
|
|
{ question: 'Q?', projectPublicId: 'proj-B' },
|
|
memberPerms
|
|
);
|
|
|
|
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
|
|
expect(calls[0][0]).not.toBe(calls[1][0]);
|
|
});
|
|
|
|
it('classification ceiling derived from role, not from request body', async () => {
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
mockLocalLlm.generate.mockResolvedValue({
|
|
anwer: 'ok',
|
|
usedFallbackModel: false,
|
|
});
|
|
|
|
await service.query(dto, memberPerms);
|
|
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
|
expect.any(Array),
|
|
dto.projectPublicId,
|
|
'INTERNAL',
|
|
20
|
|
);
|
|
|
|
jest.clearAllMocks();
|
|
mockQdrant.isReady.mockReturnValue(true);
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
|
|
mockQdrant.hybridSearch.mockResolvedValue([]);
|
|
mockLocalLlm.generate.mockResolvedValue({
|
|
answer: 'ok',
|
|
usedFallbackModel: false,
|
|
});
|
|
|
|
await service.query(dto, adminPerms);
|
|
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
|
|
expect.any(Array),
|
|
dto.projectPublicId,
|
|
'CONFIDENTIAL',
|
|
20
|
|
);
|
|
});
|
|
});
|
|
});
|