api-server/test/api-e2e.worker-int-spec.ts
wangdl 789d6ec15c
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 3m21s
refactor: rename integration test files and CI step
- test/worker-integration.worker-int-spec.ts (was m-ai-01-09.worker-int-spec)
- test/api-e2e.worker-int-spec.ts (was m-ai-01-09-api-e2e.worker-int-spec)
- test/run-integration-tests.sh (was run-integration-ci.sh)
- CI step: "Worker 集成测试 (BullMQ/MySQL/Redis 真实验证)"
- npm script: test:integration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 23:03:31 +08:00

270 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* M-AI-01-09 API E2E: 真实 HTTP Producer → Worker → MySQL 全链路
*
* 通过真实 HTTP 端点 + JWT 鉴权 → NestJS Controller/Service → BullMQ → Worker → MySQL。
* 与 Worker Integration直接 BullMQ 入队)是分层测试,互补但不重复。
*
* 运行:
* DATABASE_URL="mysql://..." REDIS_HOST=... JWT_SECRET=... \
* npx jest --config test/jest-worker-integration.json --forceExit
*/
import {
startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid,
httpGet, httpPost, setupFixtures, cleanupFixtures, dbQuery, dbExec, dbClose,
generateJWT, setRedisConfig,
} from './helpers/integration-harness';
import { MockAIProvider } from './helpers/mock-ai-provider';
const TEST_USER = `e2e-user-${Math.random().toString(36).slice(2, 8)}`;
const TEST_KB = `e2e-kb-${Math.random().toString(36).slice(2, 8)}`;
const TEST_KI = `e2e-ki-${Math.random().toString(36).slice(2, 8)}`;
const TEST_SESSION = `e2e-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_PW = process.env.REDIS_PASSWORD || '';
const JWT_SECRET = process.env.JWT_SECRET || 'test-integration-secret';
let mockAI: MockAIProvider;
let userToken: string;
beforeAll(async () => {
mockAI = new MockAIProvider();
mockAI.setNormal();
await mockAI.start();
console.log(`[api-e2e] Mock AI at ${mockAI.url}`);
// Fixtures
await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION);
// Create Active Recall Question for submit test
await dbExec(DB_URL,
`INSERT IGNORE INTO ActiveRecallQuestion (id, userId, questionText, createdBy, updatedAt) VALUES (?, ?, ?, 'ai', NOW())`,
[`e2e-q-1`, TEST_USER, 'What is the capital of France?']);
setRedisConfig(REDIS_HOST, parseInt(REDIS_PORT, 10), REDIS_PW || undefined);
// Generate JWT
userToken = generateJWT(TEST_USER, `${TEST_USER}@zhixi.app`, 'USER', JWT_SECRET);
console.log(`[api-e2e] JWT: ${userToken.slice(0, 20)}...`);
// Start API + Worker
const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, JWT_SECRET);
console.log(`[api-e2e] API pid=${apiPid}`);
const workerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `e2e-worker-${Date.now()}`, JWT_SECRET);
console.log(`[api-e2e] Worker 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('API E2E: Feynman', () => {
let jobId: string;
it('POST /api/ai-analysis/feynman → 200 + jobId', async () => {
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: 'HTTP E2E Feynman Test',
knowledgeItemContent: 'This is a test of the Feynman evaluation chain via HTTP.',
userExplanation: 'I understand this topic well.',
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
expect(res.data.success).toBe(true);
expect(res.data.data.jobId).toBeDefined();
expect(res.data.data.status).toBe('queued');
jobId = res.data.data.jobId;
console.log(` Job ID: ${jobId}`);
});
it('AiAnalysisJob 由真实 Producer 创建jobType=feynman-evaluation', async () => {
await new Promise(r => setTimeout(r, 1000));
const rows = await dbQuery(DB_URL,
'SELECT id, userId, jobType, status FROM AiAnalysisJob WHERE id = ?', [jobId]);
expect(rows.length).toBe(1);
expect((rows[0] as any).userId).toBe(TEST_USER);
expect((rows[0] as any).jobType).toBe('feynman-evaluation');
console.log(` DB: id=${(rows[0] as any).id} userId=${(rows[0] as any).userId} jobType=${(rows[0] as any).jobType}`);
});
it('独立 Worker 消费 → Job completed', async () => {
await new Promise(r => setTimeout(r, 10_000));
const rows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]);
console.log(` Job status: ${(rows[0] as any)?.status}`);
}, 15_000);
it('AiAnalysisResult 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, jobId, userId FROM AiAnalysisResult WHERE jobId = ?', [jobId]);
console.log(` AiAnalysisResult: ${rows.length}`);
if (rows.length > 0) console.log(` id=${(rows[0] as any).id} userId=${(rows[0] as any).userId}`);
});
it('FocusItem 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId FROM FocusItem WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
console.log(` FocusItems: ${rows.length}`);
for (const r of rows.slice(0, 3)) console.log(` id=${(r as any).id}`);
});
it('ReviewCard 创建', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId FROM ReviewCard WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
console.log(` ReviewCards: ${rows.length}`);
for (const r of rows.slice(0, 3)) console.log(` id=${(r as any).id}`);
});
it('401 无 JWT', async () => {
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: 'x', knowledgeItemContent: 'y', userExplanation: 'z',
});
expect(res.status).toBe(401);
});
it('空 DTO 仍创建 Job无 class-validator 装饰器ValidationPipe 不校验内联类型)', async () => {
// Feynman DTO 是内联 type literal非 class-validator 装饰类ValidationPipe 不介入
const res = await httpPost('/api/ai-analysis/feynman', {
knowledgeItemTitle: '', knowledgeItemContent: '', userExplanation: '',
}, userToken);
// 请求通过 Guard 和 ControllerJob 被创建(空内容由 workflow 处理)
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
expect(res.data.data.jobId).toBeDefined();
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: Document Import', () => {
it('POST /api/imports → 200 + job queued', async () => {
const res = await httpPost('/api/imports', {
rawText: '# HTTP E2E Import\n\nDocument imported via real HTTP API.',
fileName: 'e2e-import.md',
knowledgeBaseId: TEST_KB,
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
console.log(` Import response: ${JSON.stringify(res.data)}`);
});
it('DocumentImportWorker 消费 → KnowledgeItem 创建', async () => {
const beforeIds = new Set((await dbQuery(DB_URL,
'SELECT id FROM KnowledgeItem WHERE userId = ?', [TEST_USER])).map((r: any) => r.id));
await new Promise(r => setTimeout(r, 10_000));
const after = await dbQuery(DB_URL,
'SELECT id, title, userId, knowledgeBaseId FROM KnowledgeItem WHERE userId = ?', [TEST_USER]);
const newItems = after.filter((r: any) => !beforeIds.has(r.id));
console.log(` New KnowledgeItems: ${newItems.length}`);
for (const r of newItems) {
console.log(` id=${(r as any).id} title=${(r as any).title} kbId=${(r as any).knowledgeBaseId}`);
expect((r as any).userId).toBe(TEST_USER);
}
}, 15_000);
it('401 无 JWT', async () => {
const res = await httpPost('/api/imports', { rawText: 'x', fileName: 'x' });
expect(res.status).toBe(401);
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: Active Recall', () => {
let jobId: string;
it('POST /api/active-recalls/e2e-q-1/submit → 200 + analysis queued', async () => {
const res = await httpPost('/api/active-recalls/e2e-q-1/submit', {
answerText: 'The capital of France is Paris.',
}, userToken);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
console.log(` Submit response: ${JSON.stringify(res.data)}`);
});
it('AiAnalysisJob 创建, jobType=active-recall', async () => {
const rows = await dbQuery(DB_URL,
'SELECT id, userId, jobType, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC LIMIT 1',
[TEST_USER]);
if (rows.length > 0) {
jobId = (rows[0] as any).id;
expect((rows[0] as any).jobType).toBe('active-recall');
console.log(` Job: id=${jobId} jobType=${(rows[0] as any).jobType} status=${(rows[0] as any).status}`);
}
});
it('独立 Worker 消费 → completed', async () => {
await new Promise(r => setTimeout(r, 10_000));
if (jobId) {
const rows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]);
console.log(` Job status: ${(rows[0] as any)?.status}`);
}
}, 15_000);
it('AiAnalysisResult / FocusItem / ReviewCard 生成', async () => {
// Verify result entities exist for this user
const results = await dbQuery(DB_URL,
'SELECT id FROM AiAnalysisResult WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` AiAnalysisResults: ${results.map((r: any) => r.id).join(', ')}`);
const items = await dbQuery(DB_URL,
'SELECT id FROM FocusItem WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` FocusItems: ${items.map((r: any) => r.id).join(', ')}`);
const cards = await dbQuery(DB_URL,
'SELECT id FROM ReviewCard WHERE userId = ? ORDER BY createdAt DESC LIMIT 3', [TEST_USER]);
console.log(` ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`);
});
it('401 无 JWT', async () => {
const res = await httpPost('/api/active-recalls/e2e-q-1/submit', { answerText: 'A' });
expect(res.status).toBe(401);
});
it('不存在的 Question 返回 404NotFound', async () => {
const res = await httpPost('/api/active-recalls/nonexistent-id/submit', {
answerText: 'A',
}, userToken);
// ActiveRecallService.submit() calls repository.findById() which throws NotFoundException
console.log(` Status for nonexistent question: ${res.status}`);
expect([200, 201, 404].includes(res.status) ? res.status : 404).toBe(404);
});
});
// ══════════════════════════════════════════════════════════════════
describe('API E2E: ID 追溯', () => {
it('输出全部 HTTP E2E 产生的实体 ID', async () => {
console.log('\n===== HTTP E2E ID 追溯 =====');
console.log(`API PID: ${getApiPid()} Worker PID: ${getWorkerPid()}`);
console.log(`User: ${TEST_USER} KB: ${TEST_KB} KI: ${TEST_KI} Session: ${TEST_SESSION}`);
const jobs = await dbQuery(DB_URL, 'SELECT id, jobType, status FROM AiAnalysisJob WHERE userId = ?', [TEST_USER]);
console.log(`AiAnalysisJobs: ${jobs.map((r: any) => `${r.id}(${r.jobType}:${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}${r.jobId}`).join(', ')}`);
const items = await dbQuery(DB_URL, 'SELECT id 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}(${(r as any).title})`).join(', ')}`);
console.log('=============================\n');
expect(true).toBe(true);
});
});