2026-05-24 14:04:47 +08:00
|
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
|
|
|
import request from 'supertest';
|
|
|
|
|
import { AppModule } from '../src/app.module';
|
|
|
|
|
|
|
|
|
|
describe('M3 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 || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-01: Learning Engine
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-01 Learning Engine', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('POST /api/learning-sessions → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer()).post('/api/learning-sessions').expect(401);
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('GET /api/ai-analysis/:id → 404 for non-existent (verified endpoint exists)', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/ai-analysis/nonexistent').expect(401);
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('GET /api/focus-items → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/focus-items').expect(401);
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('GET /api/activity/summary → 200 (public)', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer()).get('/api/activity/summary').expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
2026-05-24 14:11:58 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-02: Review Engine
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-02 Review Engine', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
2026-05-24 14:04:47 +08:00
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('GET /api/reviews/due → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/reviews/due').expect(401);
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-24 14:11:58 +08:00
|
|
|
it('ReviewService registered (app starts cleanly)', async () => {
|
|
|
|
|
// AppModule loaded successfully with ReviewService + OnEvent subscriber
|
|
|
|
|
const res = await request(app.getHttpServer()).get('/api').expect(200);
|
|
|
|
|
expect(res.body.success).toBe(true);
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|
|
|
|
|
});
|
2026-05-24 14:16:14 +08:00
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-03: Growth & Retention
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-03 Growth & Retention', () => {
|
|
|
|
|
it('GET /api/activity/streak → 401 without token', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/activity/streak').expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/activity/recommendations → endpoint exists', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/activity/recommendations').expect(401);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GrowthService registered (app starts cleanly)', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer()).get('/api').expect(200);
|
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: M3-04/05/06 — Workspace Experience, Notification, Cache Module
M3-04: RecentItem/Favorite/SearchHistory models, Tag CRUD, global search, workspace dashboard
M3-05: NotificationPreference/PushToken/Template models, preferences, push tokens, admin templates
M3-06: CacheService with wrap() penetration protection, key naming conventions, admin cache management
E2E: 27 new tests for M3-04/05/06 (35/36 passing overall)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 16:01:34 +08:00
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-04: Workspace Experience
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-04 Workspace Experience', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/recent → lists recent items', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/recent')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /api/workspace/recent → records a recent item', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/workspace/recent')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ targetType: 'knowledge_base', targetId: 'kb-1', title: 'Test KB' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/favorites → lists favorites', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/favorites')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /api/workspace/favorites → adds a favorite', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/workspace/favorites')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ targetType: 'knowledge_item', targetId: 'item-1', title: 'Test Item' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/tags → lists tags', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/tags')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /api/workspace/tags → creates a tag', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/workspace/tags')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ name: 'test-tag', color: '#ff0000' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/search → searches', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/search?q=test')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/search-history → lists search history', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/search-history')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/workspace/dashboard → returns dashboard data', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/workspace/dashboard')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('401 without token for all workspace endpoints', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/workspace/recent').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).get('/api/workspace/favorites').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).get('/api/workspace/tags').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).get('/api/workspace/search?q=test').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).get('/api/workspace/dashboard').expect(401);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-05: Notification Module
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-05 Notification Module', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /api/notifications → lists notifications with unread count', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/notifications')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
expect(res.body.data).toHaveProperty('items');
|
|
|
|
|
expect(res.body.data).toHaveProperty('unreadCount');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /api/notifications/read-all → marks all read', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/notifications/read-all')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/notifications/preferences → returns preferences', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/notifications/preferences')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('PATCH /api/notifications/preferences → updates preferences', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.patch('/api/notifications/preferences')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ pushEnabled: false, quietStartHour: 22 })
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /api/notifications/push-tokens → registers push token', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/api/notifications/push-tokens')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ token: 'test-push-token-abc', platform: 'ios', deviceId: 'device-1' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /api/notifications/push-tokens → lists push tokens', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/api/notifications/push-tokens')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('DELETE /api/notifications/push-tokens/:token → removes push token', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.delete('/api/notifications/push-tokens/test-push-token-abc')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Admin endpoints
|
|
|
|
|
it('GET /admin-api/notifications/templates → lists templates', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/notifications/templates')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/notifications/templates → creates template', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/notifications/templates')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ name: '复习提醒', type: 'review_reminder', title: '复习时间到了', content: '你有{count}张卡片待复习' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/notifications/send-log → returns send logs', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/notifications/send-log')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('401 without token for notification endpoints', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/api/notifications').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).get('/api/notifications/preferences').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).post('/api/notifications/push-tokens').expect(401);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
// M3-06: Cache Module
|
|
|
|
|
// ══════════════════════════════════════════════
|
|
|
|
|
describe('M3-06 Cache Module', () => {
|
|
|
|
|
let token: string;
|
|
|
|
|
beforeAll(async () => { token = await loginAdmin(); });
|
|
|
|
|
|
|
|
|
|
it('GET /admin-api/cache/stats → returns cache stats', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.get('/admin-api/cache/stats')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(200);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
expect(res.body.data).toHaveProperty('hits');
|
|
|
|
|
expect(res.body.data).toHaveProperty('misses');
|
|
|
|
|
expect(res.body.data).toHaveProperty('hitRate');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/cache/flush-key → flushes specific key', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/cache/flush-key')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.send({ key: 'test:key' })
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/cache/flush/config → flushes module cache', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/cache/flush/config')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/cache/reset-stats → resets stats', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/cache/reset-stats')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('POST /admin-api/cache/flush-all → flushes all cache', async () => {
|
|
|
|
|
const res = await request(app.getHttpServer())
|
|
|
|
|
.post('/admin-api/cache/flush-all')
|
|
|
|
|
.set('Authorization', `Bearer ${token}`)
|
|
|
|
|
.expect(201);
|
|
|
|
|
expect(res.body).toHaveProperty('success');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('Cache module endpoints require auth', async () => {
|
|
|
|
|
await request(app.getHttpServer()).get('/admin-api/cache/stats').expect(401);
|
|
|
|
|
await request(app.getHttpServer()).post('/admin-api/cache/flush-key').expect(401);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-24 14:04:47 +08:00
|
|
|
});
|