init: AI培训与智能巡检系统
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user