- Attach rejects handler before advancing timers (vitest 2.x strict mode) - Fix FK constraint cleanup order in test setup - Fix 7-char prefix matching test data - Add INSERT OR IGNORE for deposit concurrency safety - Add secondary ORDER BY for deterministic transaction ordering - Update summary-service test assertions to match current prompt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
/**
|
|
* Unit Tests for summary-service.ts
|
|
*
|
|
* 테스트 범위:
|
|
* 1. addToBuffer - 메시지 버퍼 추가
|
|
* 2. getBufferedMessages - 버퍼 메시지 조회
|
|
* 3. getLatestSummary - 최신 요약 조회
|
|
* 4. getAllSummaries - 모든 요약 조회 (최대 3개)
|
|
* 5. getConversationContext - 전체 컨텍스트 조회
|
|
* 6. processAndSummarize - 요약 실행 및 저장
|
|
* 7. generateAIResponse - AI 응답 생성
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import {
|
|
addToBuffer,
|
|
getBufferedMessages,
|
|
getLatestSummary,
|
|
getAllSummaries,
|
|
getConversationContext,
|
|
processAndSummarize,
|
|
generateAIResponse,
|
|
} from '../src/summary-service';
|
|
import {
|
|
createTestUser,
|
|
createSummary,
|
|
createMessageBuffer,
|
|
getTestDB,
|
|
} from './setup';
|
|
import { Env } from '../src/types';
|
|
|
|
// Mock OpenAI service
|
|
// Store captured arguments for inspection in tests
|
|
let capturedSystemPrompt: string | undefined;
|
|
let capturedRecentContext: Array<{ role: string; content: string }> | undefined;
|
|
|
|
vi.mock('../src/openai-service', () => ({
|
|
generateProfileWithOpenAI: vi.fn(async () => '테스트 프로필'),
|
|
generateOpenAIResponse: vi.fn(async (env, userMessage, systemPrompt, recentContext) => {
|
|
// Capture arguments for test inspection
|
|
capturedSystemPrompt = systemPrompt as string;
|
|
capturedRecentContext = recentContext as Array<{ role: string; content: string }>;
|
|
return 'AI 응답 테스트';
|
|
}),
|
|
}));
|
|
|
|
describe('summary-service', () => {
|
|
let testUserId: number;
|
|
const testChatId = 'test_chat_123';
|
|
let testEnv: Env;
|
|
|
|
beforeEach(async () => {
|
|
// 각 테스트마다 새로운 사용자 생성
|
|
testUserId = await createTestUser('123456789', 'testuser');
|
|
|
|
// Mock Env 객체 생성
|
|
testEnv = {
|
|
DB: getTestDB(),
|
|
OPENAI_API_KEY: 'test-key',
|
|
SUMMARY_THRESHOLD: '20',
|
|
MAX_SUMMARIES_PER_USER: '3',
|
|
AI: {
|
|
run: vi.fn(async () => ({ response: 'Workers AI 응답' })),
|
|
},
|
|
} as unknown as Env;
|
|
});
|
|
|
|
describe('addToBuffer', () => {
|
|
it('should add user message to buffer and return count', async () => {
|
|
const count = await addToBuffer(
|
|
testEnv.DB,
|
|
testUserId,
|
|
testChatId,
|
|
'user',
|
|
'안녕하세요'
|
|
);
|
|
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it('should add multiple messages and return correct count', async () => {
|
|
await addToBuffer(testEnv.DB, testUserId, testChatId, 'user', '메시지 1');
|
|
await addToBuffer(testEnv.DB, testUserId, testChatId, 'bot', '응답 1');
|
|
const count = await addToBuffer(testEnv.DB, testUserId, testChatId, 'user', '메시지 2');
|
|
|
|
expect(count).toBe(3);
|
|
});
|
|
|
|
it('should distinguish between user and bot roles', async () => {
|
|
await addToBuffer(testEnv.DB, testUserId, testChatId, 'user', '사용자 메시지');
|
|
await addToBuffer(testEnv.DB, testUserId, testChatId, 'bot', '봇 메시지');
|
|
|
|
const messages = await getBufferedMessages(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(messages).toHaveLength(2);
|
|
expect(messages[0].role).toBe('user');
|
|
expect(messages[1].role).toBe('bot');
|
|
});
|
|
});
|
|
|
|
describe('getBufferedMessages', () => {
|
|
it('should return empty array for new user', async () => {
|
|
const messages = await getBufferedMessages(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(messages).toEqual([]);
|
|
});
|
|
|
|
it('should return messages in chronological order', async () => {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', '첫 번째');
|
|
await createMessageBuffer(testUserId, testChatId, 'bot', '두 번째');
|
|
await createMessageBuffer(testUserId, testChatId, 'user', '세 번째');
|
|
|
|
const messages = await getBufferedMessages(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(messages).toHaveLength(3);
|
|
expect(messages[0].message).toBe('첫 번째');
|
|
expect(messages[1].message).toBe('두 번째');
|
|
expect(messages[2].message).toBe('세 번째');
|
|
});
|
|
|
|
it('should include all message fields', async () => {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', '테스트 메시지');
|
|
|
|
const messages = await getBufferedMessages(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(messages[0]).toHaveProperty('id');
|
|
expect(messages[0]).toHaveProperty('role');
|
|
expect(messages[0]).toHaveProperty('message');
|
|
expect(messages[0]).toHaveProperty('created_at');
|
|
});
|
|
});
|
|
|
|
describe('getLatestSummary', () => {
|
|
it('should return null for user without summaries', async () => {
|
|
const summary = await getLatestSummary(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summary).toBeNull();
|
|
});
|
|
|
|
it('should return the most recent summary', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '첫 번째 요약', 20);
|
|
await createSummary(testUserId, testChatId, 2, '두 번째 요약', 40);
|
|
await createSummary(testUserId, testChatId, 3, '최신 요약', 60);
|
|
|
|
const summary = await getLatestSummary(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summary).not.toBeNull();
|
|
expect(summary?.generation).toBe(3);
|
|
expect(summary?.summary).toBe('최신 요약');
|
|
expect(summary?.message_count).toBe(60);
|
|
});
|
|
|
|
it('should include all summary fields', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '테스트 요약', 20);
|
|
|
|
const summary = await getLatestSummary(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summary).toHaveProperty('id');
|
|
expect(summary).toHaveProperty('generation');
|
|
expect(summary).toHaveProperty('summary');
|
|
expect(summary).toHaveProperty('message_count');
|
|
expect(summary).toHaveProperty('created_at');
|
|
});
|
|
});
|
|
|
|
describe('getAllSummaries', () => {
|
|
it('should return empty array for user without summaries', async () => {
|
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summaries).toEqual([]);
|
|
});
|
|
|
|
it('should return summaries in descending order (most recent first)', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '오래된 요약', 20);
|
|
await createSummary(testUserId, testChatId, 2, '중간 요약', 40);
|
|
await createSummary(testUserId, testChatId, 3, '최신 요약', 60);
|
|
|
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summaries).toHaveLength(3);
|
|
expect(summaries[0].generation).toBe(3);
|
|
expect(summaries[1].generation).toBe(2);
|
|
expect(summaries[2].generation).toBe(1);
|
|
});
|
|
|
|
it('should limit to maximum 3 summaries', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '요약 1', 20);
|
|
await createSummary(testUserId, testChatId, 2, '요약 2', 40);
|
|
await createSummary(testUserId, testChatId, 3, '요약 3', 60);
|
|
await createSummary(testUserId, testChatId, 4, '요약 4', 80);
|
|
|
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(summaries).toHaveLength(3);
|
|
expect(summaries[0].generation).toBe(4);
|
|
expect(summaries[1].generation).toBe(3);
|
|
expect(summaries[2].generation).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getConversationContext', () => {
|
|
it('should return context for new user without summaries', async () => {
|
|
const context = await getConversationContext(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(context.previousSummary).toBeNull();
|
|
expect(context.summaries).toEqual([]);
|
|
expect(context.recentMessages).toEqual([]);
|
|
expect(context.totalMessages).toBe(0);
|
|
});
|
|
|
|
it('should return context with summary and messages', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '사용자 프로필', 20);
|
|
await createMessageBuffer(testUserId, testChatId, 'user', '메시지 1');
|
|
await createMessageBuffer(testUserId, testChatId, 'bot', '응답 1');
|
|
|
|
const context = await getConversationContext(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(context.previousSummary).not.toBeNull();
|
|
expect(context.previousSummary?.generation).toBe(1);
|
|
expect(context.summaries).toHaveLength(1);
|
|
expect(context.recentMessages).toHaveLength(2);
|
|
expect(context.totalMessages).toBe(22); // 20 (from summary) + 2 (buffer)
|
|
});
|
|
|
|
it('should return previousSummary as most recent from summaries array', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '오래된 요약', 20);
|
|
await createSummary(testUserId, testChatId, 2, '최신 요약', 40);
|
|
|
|
const context = await getConversationContext(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(context.previousSummary).not.toBeNull();
|
|
expect(context.previousSummary?.generation).toBe(2);
|
|
expect(context.summaries[0].generation).toBe(2);
|
|
});
|
|
|
|
it('should include all required fields in context', async () => {
|
|
const context = await getConversationContext(testEnv.DB, testUserId, testChatId);
|
|
|
|
expect(context).toHaveProperty('previousSummary');
|
|
expect(context).toHaveProperty('summaries');
|
|
expect(context).toHaveProperty('recentMessages');
|
|
expect(context).toHaveProperty('totalMessages');
|
|
});
|
|
});
|
|
|
|
describe('processAndSummarize', () => {
|
|
it('should not summarize when below threshold', async () => {
|
|
// Add 10 messages (below threshold of 20)
|
|
for (let i = 0; i < 10; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
const result = await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
expect(result.summarized).toBe(false);
|
|
expect(result.summary).toBeUndefined();
|
|
});
|
|
|
|
it('should summarize when threshold is reached', async () => {
|
|
// Add 20 messages (threshold)
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
const result = await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
expect(result.summarized).toBe(true);
|
|
expect(result.summary).toBeDefined();
|
|
});
|
|
|
|
it('should clear buffer after summarization', async () => {
|
|
// Add 20 messages
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
const messages = await getBufferedMessages(testEnv.DB, testUserId, testChatId);
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it('should create new summary with correct generation', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '첫 번째 요약', 20);
|
|
|
|
// Add 20 more messages
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
const summary = await getLatestSummary(testEnv.DB, testUserId, testChatId);
|
|
expect(summary?.generation).toBe(2);
|
|
expect(summary?.message_count).toBe(40); // 20 + 20
|
|
});
|
|
|
|
it('should maintain maximum 3 summaries', async () => {
|
|
// Create 3 existing summaries
|
|
await createSummary(testUserId, testChatId, 1, '요약 1', 20);
|
|
await createSummary(testUserId, testChatId, 2, '요약 2', 40);
|
|
await createSummary(testUserId, testChatId, 3, '요약 3', 60);
|
|
|
|
// Add 20 messages to trigger 4th summary
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
|
expect(summaries).toHaveLength(3);
|
|
expect(summaries[0].generation).toBe(4); // Most recent
|
|
expect(summaries[2].generation).toBe(2); // Oldest kept (generation 1 deleted)
|
|
});
|
|
|
|
it('should use OpenAI when API key is available', async () => {
|
|
const { generateProfileWithOpenAI } = await import('../src/openai-service');
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
await processAndSummarize(testEnv, testUserId, testChatId);
|
|
|
|
expect(generateProfileWithOpenAI).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fall back to Workers AI when OpenAI key is not available', async () => {
|
|
const envNoOpenAI = {
|
|
...testEnv,
|
|
OPENAI_API_KEY: undefined,
|
|
} as unknown as Env;
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
await createMessageBuffer(testUserId, testChatId, 'user', `메시지 ${i}`);
|
|
}
|
|
|
|
await processAndSummarize(envNoOpenAI, testUserId, testChatId);
|
|
|
|
expect(envNoOpenAI.AI.run).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('generateAIResponse', () => {
|
|
it('should generate response for user without profile', async () => {
|
|
const response = await generateAIResponse(
|
|
testEnv,
|
|
testUserId,
|
|
testChatId,
|
|
'안녕하세요'
|
|
);
|
|
|
|
expect(response).toBeDefined();
|
|
expect(typeof response).toBe('string');
|
|
});
|
|
|
|
it('should include profile in system prompt when available', async () => {
|
|
await createSummary(testUserId, testChatId, 1, '사용자는 개발자입니다', 20);
|
|
|
|
// Verify summary was created correctly
|
|
const summaries = await getAllSummaries(testEnv.DB, testUserId, testChatId);
|
|
expect(summaries).toHaveLength(1);
|
|
|
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
|
|
|
// Reset captured values
|
|
capturedSystemPrompt = undefined;
|
|
|
|
await generateAIResponse(
|
|
testEnv,
|
|
testUserId,
|
|
testChatId,
|
|
'최근에 뭐 했어?'
|
|
);
|
|
|
|
expect(generateOpenAIResponse).toHaveBeenCalled();
|
|
|
|
// Use captured system prompt from mock
|
|
expect(capturedSystemPrompt).toBeDefined();
|
|
|
|
// When summaries exist, system prompt should include profile content
|
|
// Format: "## 사용자 프로필 (N개 버전 통합)" followed by versioned profile
|
|
if (summaries.length > 0 && capturedSystemPrompt) {
|
|
expect(capturedSystemPrompt).toContain('사용자는 개발자입니다');
|
|
}
|
|
});
|
|
|
|
it('should include recent messages in context', async () => {
|
|
// Note: We need to use the same userId and chatId as testUserId and testChatId
|
|
// because generateAIResponse uses those for context lookup
|
|
await createMessageBuffer(testUserId, testChatId, 'user', '이전 메시지');
|
|
await createMessageBuffer(testUserId, testChatId, 'bot', '이전 응답');
|
|
|
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
|
|
|
// Reset captured values
|
|
capturedRecentContext = undefined;
|
|
|
|
await generateAIResponse(
|
|
testEnv,
|
|
testUserId,
|
|
testChatId,
|
|
'새 메시지'
|
|
);
|
|
|
|
expect(generateOpenAIResponse).toHaveBeenCalled();
|
|
|
|
// Use captured recent context from mock
|
|
expect(capturedRecentContext).toBeDefined();
|
|
|
|
// recentContext uses getSmartContext() when telegramUserId is provided.
|
|
// Without telegramUserId parameter, it returns empty [], then falls back
|
|
// to getConversationContext().recentMessages
|
|
// Since we created buffer messages above, we should have at least 2 messages
|
|
if (capturedRecentContext) {
|
|
expect(capturedRecentContext.length).toBeGreaterThanOrEqual(2);
|
|
expect(capturedRecentContext[0].role).toBe('user');
|
|
expect(capturedRecentContext[0].content).toBe('이전 메시지');
|
|
}
|
|
});
|
|
|
|
it('should use OpenAI when API key is available', async () => {
|
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
|
|
|
await generateAIResponse(
|
|
testEnv,
|
|
testUserId,
|
|
testChatId,
|
|
'테스트 메시지'
|
|
);
|
|
|
|
expect(generateOpenAIResponse).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should fall back to Workers AI when OpenAI key is not available', async () => {
|
|
const envNoOpenAI = {
|
|
...testEnv,
|
|
OPENAI_API_KEY: undefined,
|
|
} as unknown as Env;
|
|
|
|
await generateAIResponse(
|
|
envNoOpenAI,
|
|
testUserId,
|
|
testChatId,
|
|
'테스트 메시지'
|
|
);
|
|
|
|
expect(envNoOpenAI.AI.run).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should include tool usage instructions in system prompt', async () => {
|
|
const { generateOpenAIResponse } = await import('../src/openai-service');
|
|
|
|
await generateAIResponse(
|
|
testEnv,
|
|
testUserId,
|
|
testChatId,
|
|
'날씨 알려줘'
|
|
);
|
|
|
|
const callArgs = vi.mocked(generateOpenAIResponse).mock.calls[0];
|
|
const systemPrompt = callArgs[2] as string;
|
|
|
|
expect(systemPrompt).toContain('날씨');
|
|
expect(systemPrompt).toContain('도구');
|
|
});
|
|
});
|
|
});
|