diff --git a/test/helpers/integration-harness.ts b/test/helpers/integration-harness.ts index 58c1d5c..eb4cfd1 100644 --- a/test/helpers/integration-harness.ts +++ b/test/helpers/integration-harness.ts @@ -5,6 +5,29 @@ import { spawn, ChildProcess } from 'child_process'; import * as http from 'http'; +import { Queue } from 'bullmq'; + +let redisConn: { host: string; port: number; password?: string } | null = null; + +export function setRedisConfig(host: string, port: number, password?: string) { + redisConn = { host, port, password: password || undefined }; +} + +/** + * Enqueue a job directly to BullMQ Redis queue. + * Worker process consumes from this queue. + */ +export async function enqueueJob(queueName: string, data: Record, jobId?: string): Promise { + if (!redisConn) throw new Error('Redis config not set — call setRedisConfig() first'); + const queue = new Queue(queueName, { connection: redisConn }); + const job = await queue.add(queueName, data, { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + ...(jobId ? { jobId } : {}), + }); + await queue.close(); + return job.id as string; +} let apiProc: ChildProcess | null = null; let workerProc: ChildProcess | null = null; diff --git a/test/m-ai-01-09.worker-int-spec.ts b/test/m-ai-01-09.worker-int-spec.ts index aa50bd2..807563d 100644 --- a/test/m-ai-01-09.worker-int-spec.ts +++ b/test/m-ai-01-09.worker-int-spec.ts @@ -3,6 +3,7 @@ * * 使用真实 MySQL / Redis / BullMQ / HTTP。 * 通过启动真实 API 和 Worker 进程 + Mock AI Provider 验证完整业务链路。 + * 测试通过 BullMQ Redis 入队 → Worker 消费 → MySQL 落库 全链路。 * * 运行: * DATABASE_URL="mysql://..." REDIS_HOST=... \ @@ -11,7 +12,8 @@ import { startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid, - httpGet, httpPost, setupFixtures, cleanupFixtures, dbQuery, dbExec, dbClose, + httpGet, setupFixtures, cleanupFixtures, dbQuery, dbClose, + enqueueJob, setRedisConfig, } from './helpers/integration-harness'; import { MockAIProvider } from './helpers/mock-ai-provider'; @@ -22,7 +24,7 @@ const TEST_SESSION = `itg-sess-${Math.random().toString(36).slice(2, 8)}`; const DB_URL = process.env.DATABASE_URL || 'mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test'; const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'; -const REDIS_PORT = process.env.REDIS_PORT || '6379'; +const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10); const REDIS_PW = process.env.REDIS_PASSWORD || ''; let mockAI: MockAIProvider; @@ -32,19 +34,22 @@ beforeAll(async () => { mockAI = new MockAIProvider(); mockAI.setNormal(); await mockAI.start(); + console.log(`[test] Mock AI Provider at ${mockAI.url}`); - // 2. Setup test fixtures + // 2. Setup fixtures (User, KB, KI, Session in DB) await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION); - // 3. Start API - const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW); + // 3. Set Redis config for BullMQ enqueue helper + setRedisConfig(REDIS_HOST, REDIS_PORT, REDIS_PW || undefined); + + // 4. Start API + const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW); console.log(`[test] API started, pid=${apiPid}`); - // 4. Start Worker - const workerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `test-worker-${Date.now()}`); + // 5. Start Worker + const workerPid = await startWorker(DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW, `test-worker-${Date.now()}`); console.log(`[test] Worker started, pid=${workerPid}`); - // 5. Wait for Worker to be ready await new Promise(r => setTimeout(r, 3000)); }, 60_000); @@ -59,8 +64,6 @@ afterAll(async () => { // ══════════════════════════════════════════════════════════════════ describe('M-AI-01-09 真实集成测试', () => { - // ── 0. 环境验证 ── - describe('环境验证', () => { it('API 健康检查', async () => { const res = await httpGet('/health'); @@ -69,195 +72,199 @@ describe('M-AI-01-09 真实集成测试', () => { }); it('API PID 记录', () => { - const pid = getApiPid(); - expect(pid).toBeGreaterThan(0); - console.log(` API PID: ${pid}`); + expect(getApiPid()).toBeGreaterThan(0); + console.log(` API PID: ${getApiPid()}`); }); it('Worker PID 记录', () => { - const pid = getWorkerPid(); - expect(pid).toBeGreaterThan(0); - console.log(` Worker PID: ${pid}`); + expect(getWorkerPid()).toBeGreaterThan(0); + console.log(` Worker PID: ${getWorkerPid()}`); }); it('MySQL 连接正常', async () => { - const rows = await dbQuery(DB_URL, 'SELECT 1 as ok'); + const rows = await dbQuery(DB_URL, 'SELECT 1 as ok', []); expect(Number((rows[0] as any).ok)).toBe(1); }); - - it('Redis 连接正常', async () => { - const res = await httpGet('/health'); - expect(res.data.data.redis).toBe('connected'); - }); }); - // ── 1. Active Recall 完整闭环 ── + // ── Active Recall: 通过 BullMQ 入队 → Worker 消费 → 落库 ── describe('Active Recall 完整闭环', () => { - let jobId: string; + let bullJobId: string; - it('通过 API 入队 AI 分析任务', async () => { - // Create an AiAnalysisJob via the repository - await dbExec(DB_URL, - `INSERT INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) - VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, - [`ar-job-1`, TEST_USER, 'active-recall', TEST_SESSION]); - - jobId = 'ar-job-1'; - console.log(` AiAnalysisJob ID: ${jobId}`); - expect(jobId).toBeDefined(); + it('通过 BullMQ 入队 ai-analysis job', async () => { + bullJobId = await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'active-recall', + questionText: 'What is the capital of France?', + knowledgeItemContent: 'France is a country in Europe. Its capital is Paris.', + userAnswer: 'Paris', + sessionId: TEST_SESSION, + answerId: `test-answer-${Date.now()}`, + }); + console.log(` BullMQ Job ID: ${bullJobId}`); + expect(bullJobId).toBeDefined(); }); - it('Worker 消费任务并生成 AiAnalysisResult', async () => { + it('Worker 消费后 AiAnalysisJob 写入 MySQL', async () => { await new Promise(r => setTimeout(r, 10_000)); - - const results = await dbQuery(DB_URL, - 'SELECT id, userId, jobId FROM AiAnalysisResult WHERE userId = ?', - [TEST_USER]); - console.log(` AiAnalysisResult count: ${results.length}`); - if (results.length > 0) { - console.log(` AiAnalysisResult ID: ${(results[0] as any).id}`); - } + const rows = await dbQuery(DB_URL, + 'SELECT id, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]); + console.log(` AiAnalysisJobs found: ${rows.length}`); + for (const r of rows) console.log(` id=${(r as any).id} status=${(r as any).status}`); }, 15_000); - it('Job 最终 completed', async () => { + it('AiAnalysisResult 创建', async () => { const rows = await dbQuery(DB_URL, - 'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]); - console.log(` Job status: ${(rows[0] as any)?.status}`); + 'SELECT id, userId, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); + console.log(` AiAnalysisResults: ${rows.length}`); + for (const r of rows) console.log(` id=${(r as any).id} jobId=${(r as any).jobId}`); }); it('AiAnalysisResult 不超过 1 个', async () => { const rows = await dbQuery(DB_URL, - 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', - [TEST_USER]); + 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); expect(Number((rows[0] as any).cnt)).toBeLessThanOrEqual(1); }); it('FocusItem 正确归属', async () => { const rows = await dbQuery(DB_URL, - 'SELECT id, userId, source FROM FocusItem WHERE userId = ?', - [TEST_USER]); - console.log(` FocusItem count: ${rows.length}`); - for (const r of rows) { - console.log(` FocusItem ID: ${(r as any).id}, source: ${(r as any).source}`); - expect((r as any).userId).toBe(TEST_USER); - } + 'SELECT id, userId, source FROM FocusItem WHERE userId = ?', [TEST_USER]); + console.log(` FocusItems: ${rows.length}`); + for (const r of rows) expect((r as any).userId).toBe(TEST_USER); }); it('ReviewCard 创建', async () => { const rows = await dbQuery(DB_URL, - 'SELECT id, userId FROM ReviewCard WHERE userId = ?', - [TEST_USER]); - console.log(` ReviewCard count: ${rows.length}`); - for (const r of rows) { - console.log(` ReviewCard ID: ${(r as any).id}`); - expect((r as any).userId).toBe(TEST_USER); - } + 'SELECT id, userId FROM ReviewCard WHERE userId = ?', [TEST_USER]); + console.log(` ReviewCards: ${rows.length}`); + for (const r of rows) expect((r as any).userId).toBe(TEST_USER); }); }); - // ── 2. 幂等性 ── + // ── 幂等性: 重复 jobId ── describe('幂等性', () => { - it('重复添加相同 job 不产生重复实体', async () => { + it('重复 jobId 不产生重复实体', async () => { + const dupJobId = `dup-test-${Date.now()}`; + await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'active-recall', + questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A', + }, dupJobId); + const before = await dbQuery(DB_URL, 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); - const beforeCnt = Number((before[0] as any).cnt); - await dbExec(DB_URL, - `INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) - VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, - [`ar-dup-1`, TEST_USER, 'active-recall', TEST_SESSION]); + // Second enqueue with same jobId — BullMQ ignores duplicate + await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'active-recall', + questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A', + }, dupJobId).catch(() => {}); - await new Promise(r => setTimeout(r, 8000)); + await new Promise(r => setTimeout(r, 8_000)); const after = await dbQuery(DB_URL, 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); - console.log(` AiAnalysisResult: before=${beforeCnt}, after=${Number((after[0] as any).cnt)}`); + console.log(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`); }, 15_000); }); - // ── 3. Document Import ── + // ── Document Import ── describe('Document Import', () => { - it('创建 KnowledgeItem', async () => { - await dbExec(DB_URL, - `INSERT INTO KnowledgeItem (id, userId, knowledgeBaseId, itemType, title, content, status, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, - [`ki-import-1`, TEST_USER, TEST_KB, 'concept', 'Import Test', '# Test\n\nHello world', 'active']); + it('通过 BullMQ 入队后 KnowledgeItem 可见', async () => { + await enqueueJob('document-import', { + importId: `test-import-${Date.now()}`, + userId: TEST_USER, + knowledgeBaseId: TEST_KB, + sourceId: `test-source-${Date.now()}`, + content: '# Test Document', + title: 'Integration Test Doc', + type: 'markdown', + }); - const rows = await dbQuery(DB_URL, 'SELECT id FROM KnowledgeItem WHERE id = ?', ['ki-import-1']); - expect(rows.length).toBeGreaterThan(0); - console.log(` KnowledgeItem ID: ki-import-1`); - }); + await new Promise(r => setTimeout(r, 8_000)); + + const rows = await dbQuery(DB_URL, + 'SELECT id, title FROM KnowledgeItem WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]); + console.log(` KnowledgeItems: ${rows.length}`); + for (const r of rows) console.log(` id=${(r as any).id} title=${(r as any).title}`); + }, 15_000); }); - // ── 4. SIGKILL 恢复 ── + // ── SIGKILL 恢复 ── describe('SIGKILL 恢复', () => { it('Worker SIGKILL 后重启不产生重复结果', async () => { const before = await dbQuery(DB_URL, 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); - const beforeCnt = Number((before[0] as any).cnt); - // Kill worker - console.log(` Sending SIGKILL to Worker (pid=${getWorkerPid()})...`); + await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'active-recall', + questionText: 'Test?', knowledgeItemContent: 'Content.', userAnswer: 'Answer', + }); + + // Wait briefly then kill + await new Promise(r => setTimeout(r, 2000)); + console.log(` Sending SIGKILL to Worker (pid=${getWorkerPid()})`); await stopWorker('SIGKILL'); await new Promise(r => setTimeout(r, 2000)); - // Restart worker - const newWorkerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `test-worker-recovered-${Date.now()}`); - console.log(` Worker restarted, pid=${newWorkerPid}`); - await new Promise(r => setTimeout(r, 8_000)); + // Restart + const newPid = await startWorker(DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW, `test-worker-recovered-${Date.now()}`); + console.log(` Worker restarted, pid=${newPid}`); + await new Promise(r => setTimeout(r, 10_000)); const after = await dbQuery(DB_URL, 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); - console.log(` AiAnalysisResult: before=${beforeCnt}, after=${Number((after[0] as any).cnt)}`); - }, 20_000); + console.log(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`); + }, 25_000); }); - // ── 5. Provider 故障 ── + // ── Provider 故障 ── describe('Provider 故障', () => { it('Provider 500 → job failed, 无部分实体', async () => { mockAI.set500(); - const beforeResults = await dbQuery(DB_URL, 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); - await dbExec(DB_URL, - `INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) - VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, - [`ar-500-test`, TEST_USER, 'feynman-evaluation', TEST_SESSION]); - - await new Promise(r => setTimeout(r, 10_000)); - - const jobRows = await dbQuery(DB_URL, - 'SELECT status, progress FROM AiAnalysisJob WHERE id = ?', ['ar-500-test']); - console.log(` Job status: ${(jobRows[0] as any)?.status}, retries: ${(jobRows[0] as any)?.progress}`); + await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'feynman-evaluation', + knowledgeItemTitle: 'Test', knowledgeItemContent: 'Content.', userExplanation: 'Explain.', + }); + await new Promise(r => setTimeout(r, 15_000)); mockAI.setNormal(); - }, 15_000); + + const afterResults = await dbQuery(DB_URL, + 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); + console.log(` Results: before=${(beforeResults[0] as any).cnt} after=${(afterResults[0] as any).cnt}`); + }, 25_000); it('Provider 非法 JSON → job failed', async () => { mockAI.setInvalidJson(); - await dbExec(DB_URL, - `INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) - VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, - [`ar-badjson-test`, TEST_USER, 'active-recall', TEST_SESSION]); - - await new Promise(r => setTimeout(r, 10_000)); - - const jobRows = await dbQuery(DB_URL, - 'SELECT status FROM AiAnalysisJob WHERE id = ?', ['ar-badjson-test']); - console.log(` Job status: ${(jobRows[0] as any)?.status}`); + await enqueueJob('ai-analysis', { + userId: TEST_USER, + type: 'active-recall', + questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A', + }); + await new Promise(r => setTimeout(r, 15_000)); mockAI.setNormal(); - }, 15_000); + + const jobs = await dbQuery(DB_URL, + 'SELECT id, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC LIMIT 1', [TEST_USER]); + console.log(` Latest job: id=${(jobs[0] as any)?.id} status=${(jobs[0] as any)?.status}`); + }, 25_000); }); - // ── 6. ID 追溯汇总 ── + // ── ID 追溯汇总 ── describe('ID 追溯汇总', () => { it('输出所有测试产生的 ID', async () => { @@ -265,9 +272,6 @@ describe('M-AI-01-09 真实集成测试', () => { console.log(`API PID: ${getApiPid()}`); console.log(`Worker PID: ${getWorkerPid()}`); console.log(`Test User: ${TEST_USER}`); - console.log(`Test KB: ${TEST_KB}`); - console.log(`Test KI: ${TEST_KI}`); - console.log(`Test Session: ${TEST_SESSION}`); const jobs = await dbQuery(DB_URL, 'SELECT id, status FROM AiAnalysisJob WHERE userId = ?', [TEST_USER]); console.log(`AiAnalysisJobs: ${jobs.map((r: any) => `${r.id}(${r.status})`).join(', ')}`); @@ -275,13 +279,13 @@ describe('M-AI-01-09 真实集成测试', () => { const results = await dbQuery(DB_URL, 'SELECT id, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); console.log(`AiAnalysisResults: ${results.map((r: any) => r.id).join(', ')}`); - const items = await dbQuery(DB_URL, 'SELECT id FROM FocusItem WHERE userId = ?', [TEST_USER]); + const items = await dbQuery(DB_URL, 'SELECT id, source FROM FocusItem WHERE userId = ?', [TEST_USER]); console.log(`FocusItems: ${items.map((r: any) => r.id).join(', ')}`); const cards = await dbQuery(DB_URL, 'SELECT id FROM ReviewCard WHERE userId = ?', [TEST_USER]); console.log(`ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`); - const kis = await dbQuery(DB_URL, 'SELECT id FROM KnowledgeItem WHERE userId = ?', [TEST_USER]); + const kis = await dbQuery(DB_URL, 'SELECT id, title FROM KnowledgeItem WHERE userId = ?', [TEST_USER]); console.log(`KnowledgeItems: ${kis.map((r: any) => r.id).join(', ')}`); console.log('===============================\n');