Files
2026-06-16 00:55:20 +08:00

525 lines
19 KiB
TypeScript
Raw Permalink 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.
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}`);
});
});