All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 47s
Worker consumes from BullMQ Redis, not MySQL polling. Replace all dbExec(INSERT INTO AiAnalysisJob) with enqueueJob() which uses real BullMQ Queue.add() to the test Redis. Add setRedisConfig() + enqueueJob() to integration harness. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
/**
|
|
* M-AI-01-09: 真实 API/Worker 集成测试
|
|
*
|
|
* 使用真实 MySQL / Redis / BullMQ / HTTP。
|
|
* 通过启动真实 API 和 Worker 进程 + Mock AI Provider 验证完整业务链路。
|
|
* 测试通过 BullMQ Redis 入队 → Worker 消费 → MySQL 落库 全链路。
|
|
*
|
|
* 运行:
|
|
* DATABASE_URL="mysql://..." REDIS_HOST=... \
|
|
* npx jest --config test/jest-worker-integration.json --forceExit
|
|
*/
|
|
|
|
import {
|
|
startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid,
|
|
httpGet, setupFixtures, cleanupFixtures, dbQuery, dbClose,
|
|
enqueueJob, setRedisConfig,
|
|
} from './helpers/integration-harness';
|
|
import { MockAIProvider } from './helpers/mock-ai-provider';
|
|
|
|
const TEST_USER = `itg-user-${Math.random().toString(36).slice(2, 8)}`;
|
|
const TEST_KB = `itg-kb-${Math.random().toString(36).slice(2, 8)}`;
|
|
const TEST_KI = `itg-ki-${Math.random().toString(36).slice(2, 8)}`;
|
|
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 = parseInt(process.env.REDIS_PORT || '6379', 10);
|
|
const REDIS_PW = process.env.REDIS_PASSWORD || '';
|
|
|
|
let mockAI: MockAIProvider;
|
|
|
|
beforeAll(async () => {
|
|
// 1. Start Mock AI Provider
|
|
mockAI = new MockAIProvider();
|
|
mockAI.setNormal();
|
|
await mockAI.start();
|
|
console.log(`[test] Mock AI Provider at ${mockAI.url}`);
|
|
|
|
// 2. Setup fixtures (User, KB, KI, Session in DB)
|
|
await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION);
|
|
|
|
// 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}`);
|
|
|
|
// 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}`);
|
|
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await stopWorker();
|
|
await stopAPI();
|
|
await cleanupFixtures(DB_URL, TEST_USER);
|
|
await mockAI.stop();
|
|
await dbClose();
|
|
}, 30_000);
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
describe('M-AI-01-09 真实集成测试', () => {
|
|
|
|
describe('环境验证', () => {
|
|
it('API 健康检查', async () => {
|
|
const res = await httpGet('/health');
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.data.status).toBe('ok');
|
|
});
|
|
|
|
it('API PID 记录', () => {
|
|
expect(getApiPid()).toBeGreaterThan(0);
|
|
console.log(` API PID: ${getApiPid()}`);
|
|
});
|
|
|
|
it('Worker PID 记录', () => {
|
|
expect(getWorkerPid()).toBeGreaterThan(0);
|
|
console.log(` Worker PID: ${getWorkerPid()}`);
|
|
});
|
|
|
|
it('MySQL 连接正常', async () => {
|
|
const rows = await dbQuery(DB_URL, 'SELECT 1 as ok', []);
|
|
expect(Number((rows[0] as any).ok)).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── Active Recall: 通过 BullMQ 入队 → Worker 消费 → 落库 ──
|
|
|
|
describe('Active Recall 完整闭环', () => {
|
|
let bullJobId: string;
|
|
|
|
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 消费后 AiAnalysisJob 写入 MySQL', async () => {
|
|
await new Promise(r => setTimeout(r, 10_000));
|
|
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('AiAnalysisResult 创建', async () => {
|
|
const rows = await dbQuery(DB_URL,
|
|
'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]);
|
|
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(` 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(` ReviewCards: ${rows.length}`);
|
|
for (const r of rows) expect((r as any).userId).toBe(TEST_USER);
|
|
});
|
|
});
|
|
|
|
// ── 幂等性: 重复 jobId ──
|
|
|
|
describe('幂等性', () => {
|
|
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]);
|
|
|
|
// 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, 8_000));
|
|
|
|
const after = await dbQuery(DB_URL,
|
|
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
|
|
console.log(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`);
|
|
}, 15_000);
|
|
});
|
|
|
|
// ── Document Import ──
|
|
|
|
describe('Document Import', () => {
|
|
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',
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// ── SIGKILL 恢复 ──
|
|
|
|
describe('SIGKILL 恢复', () => {
|
|
it('Worker SIGKILL 后重启不产生重复结果', async () => {
|
|
const before = await dbQuery(DB_URL,
|
|
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
|
|
|
|
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
|
|
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(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`);
|
|
}, 25_000);
|
|
});
|
|
|
|
// ── 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 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();
|
|
|
|
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 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();
|
|
|
|
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);
|
|
});
|
|
|
|
// ── ID 追溯汇总 ──
|
|
|
|
describe('ID 追溯汇总', () => {
|
|
it('输出所有测试产生的 ID', async () => {
|
|
console.log('\n===== M-AI-01-09 ID 追溯 =====');
|
|
console.log(`API PID: ${getApiPid()}`);
|
|
console.log(`Worker PID: ${getWorkerPid()}`);
|
|
console.log(`Test User: ${TEST_USER}`);
|
|
|
|
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(', ')}`);
|
|
|
|
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, 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, title FROM KnowledgeItem WHERE userId = ?', [TEST_USER]);
|
|
console.log(`KnowledgeItems: ${kis.map((r: any) => r.id).join(', ')}`);
|
|
console.log('===============================\n');
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
});
|