feat: M1-01~03 — AI Gateway deepening, Vector module, Task Queue deepening
M1-01 AI Gateway:
- DB-driven ModelRoute/ProviderConfig/FallbackEvent tables
- ModelRouter rewrite with loadFromDb() hot-reload
- Fallback event recording + AIUsageRecorded event publishing
- Admin AAPI: routes CRUD, provider enable/disable, fallback events log
M1-02 Vector & Retrieval:
- VectorService with Qdrant client (upsert/delete/search/rerank)
- Admin AAPI: collection status, vector count, reindex trigger
M1-03 Task Queue:
- 16 task types with default retry/timeout configs
- Task stats dashboard, worker status panel, batch retry endpoint
M0 audit fixes:
- ApiMetric retention policy (30-day cleanup)
- Content Safety integration in Files module
- Queue registration centralized (domain-events)
- SECRET_MASTER_KEY production validation
E2E tests:
- M0: 28 smoke tests covering all 14 M0 issues
- M1: 16 tests covering M1-01/02/03
- Mock infrastructure: prisma, ioredis, jose, bullmq, qdrant
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:18:07 +08:00
|
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
|
|
|
import request from 'supertest';
|
|
|
|
|
import { AppModule } from '../src/app.module';
|
|
|
|
|
|
|
|
|
|
describe('M1 E2E Tests', () => {
|
|
|
|
|
let app: INestApplication;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
|
|
|
imports: [AppModule],
|
|
|
|
|
}).compile();
|
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
|
|
|
app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] });
|
|
|
|
|
await app.init();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function loginAdmin(): Promise<string> {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/auth/login')
|
|
|
|
|
.send({ email: 'admin@zhixi.app', password: 'admin123' });
|
|
|
|
|
return res.body?.data?.accessToken || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M1-01: AI Gateway 深化
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M1-01 AI Gateway Deepening', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/ai-gateway/status → 200 with routes info', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/ai-gateway/status')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body.data).toHaveProperty('providers');
|
|
|
|
|
expect(res.body.data).toHaveProperty('routes');
|
|
|
|
|
expect(res.body.data).toHaveProperty('activeRoutes');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Model Routes CRUD ──
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/ai-gateway/routes → 200 with route list', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/ai-gateway/routes')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/ai-gateway/routes → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/ai-gateway/routes')
|
|
|
|
|
.expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/ai-gateway/routes → creates route and reloads cache', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/ai-gateway/routes')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({
|
|
|
|
|
tier: 'cheap',
|
|
|
|
|
taskType: 'test-e2e',
|
|
|
|
|
preferredProvider: 'deepseek',
|
|
|
|
|
preferredModel: 'deepseek-v4-flash',
|
|
|
|
|
fallbackProvider: 'deepseek',
|
|
|
|
|
fallbackModel: 'deepseek-v4-flash',
|
|
|
|
|
maxRetries: 1,
|
|
|
|
|
})
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
if (res.body?.data?.id) {
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.delete(`/admin-api/ai-gateway/routes/${res.body.data.id}`)
|
|
|
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('PUT /admin-api/ai-gateway/routes/:id → updates route', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const create = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/ai-gateway/routes')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({
|
|
|
|
|
tier: 'strong',
|
|
|
|
|
taskType: 'test-update',
|
|
|
|
|
preferredProvider: 'deepseek',
|
|
|
|
|
preferredModel: 'deepseek-v4-pro',
|
|
|
|
|
fallbackProvider: 'minimax',
|
|
|
|
|
fallbackModel: 'minimax-m2.7',
|
|
|
|
|
maxRetries: 2,
|
|
|
|
|
});
|
|
|
|
|
const id = create.body?.data?.id;
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.put(`/admin-api/ai-gateway/routes/${id}`)
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ maxRetries: 4 })
|
|
|
|
|
.expect(200);
|
|
|
|
|
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.delete(`/admin-api/ai-gateway/routes/${id}`)
|
|
|
|
|
.set('Authorization', `Bearer ${token}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Provider management ──
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/ai-gateway/providers → 200 with provider list', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/ai-gateway/providers')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('PUT /admin-api/ai-gateway/providers/:name → enables/disables provider', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.put('/admin-api/ai-gateway/providers/deepseek')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ enabled: true })
|
|
|
|
|
.expect(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Fallback events ──
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/ai-gateway/fallback-events → 200 with events list', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/ai-gateway/fallback-events')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M1-02: Vector & Retrieval Module
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M1-02 Vector & Retrieval', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/vector/collection → 200 with collection info', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/vector/collection')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body.data).toHaveProperty('name');
|
|
|
|
|
expect(res.body.data).toHaveProperty('pointsCount');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/vector/collection → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/vector/collection')
|
|
|
|
|
.expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/vector/count → 200 with vector count', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/vector/count')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body.data).toHaveProperty('collection');
|
|
|
|
|
expect(res.body.data).toHaveProperty('count');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/vector/reindex → 200 (reserved)', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/vector/reindex')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
expect(res.body.data).toHaveProperty('message');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M1-03: Task Queue 深化
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M1-03 Task Queue Deepening', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/events/stats → 200 with task type configs', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/events/stats')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body.data).toHaveProperty('taskStats');
|
|
|
|
|
expect(res.body.data).toHaveProperty('totalTaskTypes');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/events/stats → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/events/stats')
|
|
|
|
|
.expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/events/workers → 200 with worker status', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/events/workers')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body.data).toHaveProperty('workers');
|
|
|
|
|
expect(res.body.data).toHaveProperty('count');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/events/:queue/jobs/batch-retry → 200', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/events/ai-analysis/jobs/batch-retry')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ count: 10 })
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-24 10:53:19 +08:00
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M1-04: Content Safety 深化
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M1-04 Content Safety Deepening', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('POST /api/reports → 201 submit user report', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/reports')
|
|
|
|
|
.send({ targetType: 'knowledge_item', targetId: 'test123', reason: '包含错误信息', reporterId: 'user1' })
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/content-safety/reports → 200 list reports', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/content-safety/reports')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/content-safety/reports → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/content-safety/reports')
|
|
|
|
|
.expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/content-safety/violations → 200 list violations', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/content-safety/violations')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/content-safety/violations/:id/penalty → apply penalty', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/content-safety/violations/test-id/penalty')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ penalty: 'warning' })
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/content-safety/reports/:id/handle → handle report', async () => {
|
|
|
|
|
if (!token) return;
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/content-safety/reports/test-id/handle')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ action: 'dismissed', note: '已处理' })
|
|
|
|
|
.expect([200, 201]);
|
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: M1-01~03 — AI Gateway deepening, Vector module, Task Queue deepening
M1-01 AI Gateway:
- DB-driven ModelRoute/ProviderConfig/FallbackEvent tables
- ModelRouter rewrite with loadFromDb() hot-reload
- Fallback event recording + AIUsageRecorded event publishing
- Admin AAPI: routes CRUD, provider enable/disable, fallback events log
M1-02 Vector & Retrieval:
- VectorService with Qdrant client (upsert/delete/search/rerank)
- Admin AAPI: collection status, vector count, reindex trigger
M1-03 Task Queue:
- 16 task types with default retry/timeout configs
- Task stats dashboard, worker status panel, batch retry endpoint
M0 audit fixes:
- ApiMetric retention policy (30-day cleanup)
- Content Safety integration in Files module
- Queue registration centralized (domain-events)
- SECRET_MASTER_KEY production validation
E2E tests:
- M0: 28 smoke tests covering all 14 M0 issues
- M1: 16 tests covering M1-01/02/03
- Mock infrastructure: prisma, ioredis, jose, bullmq, qdrant
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:18:07 +08:00
|
|
|
});
|