import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import Database from 'better-sqlite3'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import request from 'supertest'; import { AppModule } from '../src/app.module'; import { seedDatabase } from '../src/testing/seed'; /** API 端到端测试 — 对应 T-1.3 / T-1.4 E2E。*/ describe('API (e2e)', () => { let app: INestApplication; beforeAll(async () => { const dir = mkdtempSync(join(tmpdir(), 'train-e2e-')); const dbPath = join(dir, 'test.db'); const seedDb = new Database(dbPath); seedDatabase(seedDb); seedDb.close(); process.env.DB_PATH = dbPath; process.env.APP_DB_PATH = join(dir, 'app.db'); // 可写库(用户等) process.env.ADMIN_EMAIL = 'admin@example.com'; // 引导管理员(审核测试) process.env.UPLOAD_DIR = join(dir, 'uploads'); // 图片上传目录(隔离) const moduleRef = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleRef.createNestApplication(); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); await app.init(); }); afterAll(async () => { await app.close(); }); it('GET /api/health', async () => { const res = await request(app.getHttpServer()).get('/api/health').expect(200); expect(res.body.status).toBe('ok'); expect(res.body.models).toBe(2); expect(res.body.units).toBe(1); }); it('GET /api/categories', async () => { const res = await request(app.getHttpServer()) .get('/api/categories') .expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBe(3); }); it('GET /api/models 列表+分页', async () => { const res = await request(app.getHttpServer()) .get('/api/models?pageSize=1') .expect(200); expect(res.body.total).toBe(2); expect(res.body.items.length).toBe(1); }); it('GET /api/models 筛选+排序', async () => { const res = await request(app.getHttpServer()) .get('/api/models?sort=max_speed_value&order=desc') .expect(200); expect(res.body.items[0].model_code).toBe('CR400AF'); }); it('GET /api/models 非法参数被拒', async () => { await request(app.getHttpServer()) .get('/api/models?pageSize=99999') .expect(400); }); it('GET /api/models/:id 详情', async () => { const list = await request(app.getHttpServer()).get('/api/models?q=HXD'); const id = list.body.items[0].id; const res = await request(app.getHttpServer()) .get(`/api/models/${id}`) .expect(200); expect(res.body.model_code).toBe('HXD1型'); }); it('GET /api/models/:id 不存在 404', async () => { await request(app.getHttpServer()).get('/api/models/99999').expect(404); }); it('GET /api/search', async () => { const res = await request(app.getHttpServer()) .get('/api/search?q=CR400') .expect(200); expect(res.body.results[0].model_code).toBe('CR400AF'); }); it('GET /api/units 列表', async () => { const res = await request(app.getHttpServer()) .get('/api/units') .expect(200); expect(res.body.total).toBe(1); expect(res.body.items[0].car_number).toBe('SYJZ-0001'); }); // ===== T-2.1 账户体系 ===== const cred = { email: 'tester@example.com', password: 'secret123', displayName: '测试员' }; let token = ''; it('POST /api/auth/register 注册', async () => { const res = await request(app.getHttpServer()) .post('/api/auth/register') .send(cred) .expect(201); expect(res.body.token).toBeTruthy(); expect(res.body.user.email).toBe('tester@example.com'); expect(res.body.user.role).toBe('user'); token = res.body.token; }); it('POST /api/auth/register 重复邮箱 409', async () => { await request(app.getHttpServer()) .post('/api/auth/register') .send(cred) .expect(409); }); it('POST /api/auth/register 非法输入 400', async () => { await request(app.getHttpServer()) .post('/api/auth/register') .send({ email: 'bad', password: '1', displayName: '' }) .expect(400); }); it('POST /api/auth/login 正确/错误', async () => { await request(app.getHttpServer()) .post('/api/auth/login') .send({ email: cred.email, password: cred.password }) .expect(201); await request(app.getHttpServer()) .post('/api/auth/login') .send({ email: cred.email, password: 'wrongpass' }) .expect(401); }); it('GET /api/auth/me 需要 token', async () => { await request(app.getHttpServer()).get('/api/auth/me').expect(401); const res = await request(app.getHttpServer()) .get('/api/auth/me') .set('Authorization', `Bearer ${token}`) .expect(200); expect(res.body.email).toBe('tester@example.com'); }); // ===== T-2.2 / T-2.3 编辑·修订(图鉴仅管理员可编辑)===== let adminToken = ''; it('普通用户提交编辑被拒 403(仅管理员可编辑图鉴)', async () => { await request(app.getHttpServer()) .post('/api/models/1/revisions') .set('Authorization', `Bearer ${token}`) .send({ changes: { usage: '干线货运(实测)' } }) .expect(403); }); it('未登录提交编辑 401', async () => { await request(app.getHttpServer()) .post('/api/models/1/revisions') .send({ changes: { usage: 'x' } }) .expect(401); }); it('管理员编辑 → 自动通过 + 覆盖生效 + 计分', async () => { const reg = await request(app.getHttpServer()) .post('/api/auth/register') .send({ email: 'admin@example.com', password: 'secret123', displayName: '管理员' }) .expect(201); adminToken = reg.body.token; expect(reg.body.user.role).toBe('admin'); const res = await request(app.getHttpServer()) .post('/api/models/1/revisions') .set('Authorization', `Bearer ${adminToken}`) .send({ changes: { usage: '干线货运(实测)' }, note: '管理员维护' }) .expect(201); expect(res.body.status).toBe('approved'); const detail = await request(app.getHttpServer()).get('/api/models/1').expect(200); expect(detail.body.usage).toBe('干线货运(实测)'); expect(detail.body.overridden).toContain('usage'); }); it('修订历史可见', async () => { const res = await request(app.getHttpServer()) .get('/api/models/1/revisions') .expect(200); expect(res.body.length).toBeGreaterThanOrEqual(1); }); it('普通用户访问审核队列 403', async () => { await request(app.getHttpServer()) .get('/api/revisions/pending') .set('Authorization', `Bearer ${token}`) .expect(403); }); it('管理员编辑模型2立即生效', async () => { const res = await request(app.getHttpServer()) .post('/api/models/2/revisions') .set('Authorization', `Bearer ${adminToken}`) .send({ changes: { status: '封存' } }) .expect(201); expect(res.body.status).toBe('approved'); const detail = await request(app.getHttpServer()).get('/api/models/2').expect(200); expect(detail.body.status).toBe('封存'); }); it('字段白名单外/非法值被拒 400', async () => { await request(app.getHttpServer()) .post('/api/models/1/revisions') .set('Authorization', `Bearer ${adminToken}`) .send({ changes: { password_hash: 'hack' } }) .expect(400); await request(app.getHttpServer()) .post('/api/models/1/revisions') .set('Authorization', `Bearer ${adminToken}`) .send({ changes: { first_year: '99' } }) .expect(400); }); // ===== T-2.4 荣誉与激励 ===== it('GET /api/auth/me 含等级与徽章', async () => { const res = await request(app.getHttpServer()) .get('/api/auth/me') .set('Authorization', `Bearer ${adminToken}`) .expect(200); expect(res.body.level).toBeDefined(); expect(res.body.level.title).toBeTruthy(); expect(Array.isArray(res.body.badges)).toBe(true); expect(res.body.approvedCount).toBeGreaterThanOrEqual(1); // 管理员有被采纳修订 }); it('GET /api/leaderboard 含贡献者与头衔', async () => { const res = await request(app.getHttpServer()) .get('/api/leaderboard') .expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body[0].rank).toBe(1); expect(res.body[0].title).toBeTruthy(); }); it('认领词条维护 → 出现在维护者列表', async () => { await request(app.getHttpServer()) .post('/api/models/1/maintainers') .set('Authorization', `Bearer ${token}`) .expect(201); const res = await request(app.getHttpServer()) .get('/api/models/1/maintainers') .expect(200); expect(res.body.some((mm: any) => mm.displayName === '测试员')).toBe(true); }); it('未登录认领维护 401', async () => { await request(app.getHttpServer()) .post('/api/models/1/maintainers') .expect(401); }); // ===== T-3.1/3.2 论坛 + 词条讨论 ===== let threadId = 0; it('创建帖子并回复', async () => { const t = await request(app.getHttpServer()) .post('/api/threads') .set('Authorization', `Bearer ${token}`) .send({ board: 'general', title: '大家好啊', body: '第一帖' }) .expect(201); expect(t.body.title).toBe('大家好啊'); threadId = t.body.id; const r = await request(app.getHttpServer()) .post(`/api/threads/${threadId}/replies`) .set('Authorization', `Bearer ${token}`) .send({ body: '沙发' }) .expect(201); expect(r.body.reply_count).toBe(1); expect(r.body.replies[0].body).toBe('沙发'); }); it('帖子列表按板块', async () => { const res = await request(app.getHttpServer()) .get('/api/threads?board=general') .expect(200); expect(res.body.some((t: any) => t.id === threadId)).toBe(true); }); it('词条讨论:绑定 model_id', async () => { await request(app.getHttpServer()) .post('/api/threads') .set('Authorization', `Bearer ${token}`) .send({ board: 'entry', modelId: 1, title: 'HXD1 求资料', body: '有铭牌照吗' }) .expect(201); const res = await request(app.getHttpServer()) .get('/api/threads?modelId=1') .expect(200); expect(res.body.length).toBeGreaterThanOrEqual(1); expect(res.body[0].model_id).toBe(1); }); it('未登录发帖 401,非法输入 400', async () => { await request(app.getHttpServer()) .post('/api/threads') .send({ board: 'general', title: 'x', body: 'y' }) .expect(401); await request(app.getHttpServer()) .post('/api/threads') .set('Authorization', `Bearer ${token}`) .send({ board: 'general', title: 'a', body: '' }) .expect(400); }); // ===== T-3.3 打卡(sightings)===== it('打卡 → 出现在车型打卡列表与近期动态', async () => { const res = await request(app.getHttpServer()) .post('/api/models/1/sightings') .set('Authorization', `Bearer ${token}`) .send({ lat: 39.9, lng: 116.4, station: '北京站', carNumber: 'HXD1-0001', description: '清晨拍到' }) .expect(201); expect(res.body.station).toBe('北京站'); expect(res.body.model_code).toBe('HXD1型'); expect(res.body.user_name).toBe('测试员'); const list = await request(app.getHttpServer()) .get('/api/models/1/sightings') .expect(200); expect(list.body.length).toBeGreaterThanOrEqual(1); const recent = await request(app.getHttpServer()) .get('/api/sightings/recent') .expect(200); expect(recent.body[0].model_code).toBe('HXD1型'); const map = await request(app.getHttpServer()).get('/api/sightings/map').expect(200); expect(map.body.some((p: any) => p.station === '北京站')).toBe(true); }); it('未登录打卡 401,非法坐标 400', async () => { await request(app.getHttpServer()) .post('/api/models/1/sightings') .send({ lat: 39.9, lng: 116.4 }) .expect(401); await request(app.getHttpServer()) .post('/api/models/1/sightings') .set('Authorization', `Bearer ${token}`) .send({ lat: 999, lng: 116.4 }) .expect(400); }); // ===== T-4.2 族谱 / T-4.3 拍车攻略 ===== it('GET /api/models/families 按系列聚合', async () => { const res = await request(app.getHttpServer()) .get('/api/models/families') .expect(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body[0].series).toBeDefined(); }); it('GET /api/sightings/spots 拍车攻略聚合', async () => { const res = await request(app.getHttpServer()) .get('/api/sightings/spots') .expect(200); expect(Array.isArray(res.body)).toBe(true); // 前面打卡过北京站 expect(res.body.some((s: any) => s.station === '北京站')).toBe(true); expect(res.body[0].models[0].model_code).toBeTruthy(); }); // ===== T-5.2 数据大屏 / 开放 API ===== it('GET /api/stats 概览统计', async () => { const res = await request(app.getHttpServer()).get('/api/stats').expect(200); expect(res.body.totals.models).toBe(2); expect(res.body.byCategory.length).toBeGreaterThan(0); expect(Array.isArray(res.body.speedByDecade)).toBe(true); }); it('GET /api/export/models.json 开放数据', async () => { const res = await request(app.getHttpServer()) .get('/api/export/models.json') .expect(200); expect(res.body.length).toBe(2); expect(res.body[0].model_code).toBeTruthy(); }); it('GET /api/export/models.csv 导出 CSV', async () => { const res = await request(app.getHttpServer()) .get('/api/export/models.csv') .expect(200); expect(res.headers['content-type']).toContain('text/csv'); expect(res.text.split('\n')[0]).toContain('model_code'); }); // ===== 共享图库(管理员上传,所有人可看)===== const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64', ); let photoId = 0; it('普通用户上传照片 403', async () => { await request(app.getHttpServer()) .post('/api/models/1/photos') .set('Authorization', `Bearer ${token}`) .attach('file', png, 'demo.png') .expect(403); }); it('管理员上传照片 → 进入公共图库', async () => { const res = await request(app.getHttpServer()) .post('/api/models/1/photos') .set('Authorization', `Bearer ${adminToken}`) .field('caption', '管理员示例照片') .attach('file', png, 'demo.png') .expect(201); expect(res.body.url).toMatch(/^\/uploads\/.+\.png$/); expect(res.body.caption).toBe('管理员示例照片'); photoId = res.body.id; const list = await request(app.getHttpServer()) .get('/api/models/1/photos') .expect(200); expect(list.body.some((p: any) => p.id === photoId)).toBe(true); expect(list.body[0].uploaderName).toBe('管理员'); }); it('非图片被拒 400', async () => { await request(app.getHttpServer()) .post('/api/models/1/photos') .set('Authorization', `Bearer ${adminToken}`) .attach('file', Buffer.from('hello'), 'note.txt') .expect(400); }); it('管理员删除照片', async () => { await request(app.getHttpServer()) .delete(`/api/photos/${photoId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); const list = await request(app.getHttpServer()).get('/api/models/1/photos').expect(200); expect(list.body.some((p: any) => p.id === photoId)).toBe(false); }); it('候选照片确认流程', async () => { const up = await request(app.getHttpServer()) .post('/api/models/3/photos') .set('Authorization', `Bearer ${adminToken}`) .attach('file', png, 'demo.png') .expect(201); const pid = up.body.id; expect(up.body.status).toBe('confirmed'); // 直接置为候选(模拟取图脚本入库的 candidate) const db = new Database(process.env.APP_DB_PATH as string); db.prepare("UPDATE photos SET status='candidate' WHERE id=?").run(pid); db.close(); let list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200); expect(list.body.find((p: any) => p.id === pid).status).toBe('candidate'); await request(app.getHttpServer()) .post(`/api/photos/${pid}/confirm`) .set('Authorization', `Bearer ${adminToken}`) .expect(201); list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200); expect(list.body.find((p: any) => p.id === pid).status).toBe('confirmed'); }); // ===== 封面优先(featured)===== it('设为封面:cover_url 优先 featured,其次最小 id;权限受控', async () => { // 上传两张确认照片到模型 2 const a = await request(app.getHttpServer()) .post('/api/models/2/photos') .set('Authorization', `Bearer ${adminToken}`) .attach('file', png, 'a.png') .expect(201); const b = await request(app.getHttpServer()) .post('/api/models/2/photos') .set('Authorization', `Bearer ${adminToken}`) .attach('file', png, 'b.png') .expect(201); // 默认封面 = 最早一张(a) const findCover = async () => { const res = await request(app.getHttpServer()) .get('/api/models?pageSize=50') .expect(200); return res.body.items.find((m: any) => m.id === 2)?.cover_url as string | null; }; const aFile = a.body.url.replace('/uploads/', ''); const bFile = b.body.url.replace('/uploads/', ''); expect(await findCover()).toBe(`/uploads/${aFile}`); // 普通用户设为封面 403 await request(app.getHttpServer()) .post(`/api/photos/${b.body.id}/feature`) .set('Authorization', `Bearer ${token}`) .expect(403); // 管理员把 b 设为封面 → cover_url 切到 b const feat = await request(app.getHttpServer()) .post(`/api/photos/${b.body.id}/feature`) .set('Authorization', `Bearer ${adminToken}`) .expect(201); expect(feat.body.featured).toBe(true); expect(feat.body.status).toBe('confirmed'); expect(await findCover()).toBe(`/uploads/${bFile}`); // 改 featured 到 a → 旧 featured 清除,cover 回到 a await request(app.getHttpServer()) .post(`/api/photos/${a.body.id}/feature`) .set('Authorization', `Bearer ${adminToken}`) .expect(201); const photos = await request(app.getHttpServer()).get('/api/models/2/photos').expect(200); expect(photos.body.find((p: any) => p.id === a.body.id).featured).toBe(true); expect(photos.body.find((p: any) => p.id === b.body.id).featured).toBe(false); expect(await findCover()).toBe(`/uploads/${aFile}`); }); });