fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删 - AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3 - 化学品 / Grocy 实例对接 + 库存镜像同步 - 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护 - 月度报表:Excel 6 sheet + PDF - PWA:manifest / SW / 离线缓存 / iOS 引导 - 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿 - 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就 - 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等 - 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications) - 部署:宝塔面板文档 + PM2 + Nginx
312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
// server/test/routes.vehicles.test.js
|
|
// mock 掉 db(),跑 vehicles 路由的纯逻辑测试
|
|
// 使用 vi.hoisted 解决 vi.mock factory 不能引用 file-scope 变量的问题
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const _tables = { vehicles: [], wash_records: [], operation_logs: [] };
|
|
const _seq = { vehicles: 1, wash_records: 1, operation_logs: 1 };
|
|
let stub = null;
|
|
|
|
const makeStub = () => ({
|
|
all: vi.fn(async (sql, params = []) => {
|
|
if (/FROM vehicles v/.test(sql) && /wash_records/.test(sql)) {
|
|
const whereActive = /v\.is_active = 1/.test(sql);
|
|
return _tables.vehicles
|
|
.filter((v) => v.is_deleted === 0)
|
|
.filter((v) => !whereActive || v.is_active === 1)
|
|
.map((v) => {
|
|
const washes = _tables.wash_records.filter(
|
|
(w) => w.vehicle_id === v.id && w.is_deleted === 0
|
|
);
|
|
return {
|
|
...v,
|
|
wash_count: washes.length,
|
|
total_cost: washes.reduce((s, w) => s + (w.cost || 0), 0),
|
|
last_wash_date: washes.length
|
|
? washes.map((w) => w.wash_date).sort().pop()
|
|
: null,
|
|
};
|
|
});
|
|
}
|
|
if (/COUNT\(\*\) c FROM vehicles/.test(sql) && /is_deleted = 0/.test(sql)) {
|
|
if (/is_active = 1/.test(sql)) {
|
|
return [{ c: _tables.vehicles.filter((v) => v.is_deleted === 0 && v.is_active === 1).length }];
|
|
}
|
|
return [{ c: _tables.vehicles.filter((v) => v.is_deleted === 0).length }];
|
|
}
|
|
if (/COUNT\(DISTINCT vehicle_id\) c FROM wash_records/.test(sql)) {
|
|
const ids = new Set(
|
|
_tables.wash_records
|
|
.filter((w) => w.vehicle_id != null && w.is_deleted === 0)
|
|
.map((w) => w.vehicle_id)
|
|
);
|
|
return [{ c: ids.size }];
|
|
}
|
|
if (/SELECT id FROM vehicles WHERE plate = \?/.test(sql)) {
|
|
const [plate] = params;
|
|
const found = _tables.vehicles.find((v) => v.plate === plate && v.is_deleted === 0);
|
|
return found ? [{ id: found.id }] : [];
|
|
}
|
|
if (/FROM vehicles v[\s\S]+WHERE v\.id = \? AND v\.is_deleted = 0/.test(sql)) {
|
|
const [id] = params;
|
|
const v = _tables.vehicles.find((x) => x.id === Number(id) && x.is_deleted === 0);
|
|
if (!v) return [];
|
|
const washes = _tables.wash_records.filter(
|
|
(w) => w.vehicle_id === v.id && w.is_deleted === 0
|
|
);
|
|
return [
|
|
{
|
|
...v,
|
|
wash_count: washes.length,
|
|
total_cost: washes.reduce((s, w) => s + (w.cost || 0), 0),
|
|
last_wash_date: washes.length
|
|
? washes.map((w) => w.wash_date).sort().pop()
|
|
: null,
|
|
},
|
|
];
|
|
}
|
|
if (/SELECT \* FROM vehicles WHERE id = \? AND is_deleted = 0/.test(sql)) {
|
|
const [id] = params;
|
|
const v = _tables.vehicles.find((x) => x.id === Number(id) && x.is_deleted === 0);
|
|
return v ? [v] : [];
|
|
}
|
|
return [];
|
|
}),
|
|
get: vi.fn(async (sql, params = []) => {
|
|
const rows = await stub.all(sql, params);
|
|
return rows[0] || null;
|
|
}),
|
|
run: vi.fn(async (sql, params = []) => {
|
|
if (/INSERT INTO vehicles/.test(sql)) {
|
|
const id = _seq.vehicles++;
|
|
const [name, plate, type, color, notes, is_active, sort_order, powertrain] = params;
|
|
_tables.vehicles.push({
|
|
id,
|
|
name,
|
|
plate: plate || null,
|
|
type: type || 'car',
|
|
color: color || null,
|
|
notes: notes || null,
|
|
is_active: is_active ? 1 : 0,
|
|
sort_order: sort_order || 0,
|
|
powertrain: powertrain || 'ice',
|
|
is_deleted: 0,
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
return { lastInsertRowid: id };
|
|
}
|
|
if (/UPDATE vehicles SET is_deleted = 1, updated_at = NOW\(\) WHERE id = \?/.test(sql)) {
|
|
const [id] = params;
|
|
const v = _tables.vehicles.find((x) => x.id === Number(id));
|
|
if (v) v.is_deleted = 1;
|
|
return { changes: v ? 1 : 0 };
|
|
}
|
|
if (/INSERT INTO operation_logs/.test(sql)) {
|
|
_seq.operation_logs++;
|
|
return { lastInsertRowid: _seq.operation_logs };
|
|
}
|
|
return { changes: 0 };
|
|
}),
|
|
});
|
|
|
|
const reset = () => {
|
|
_tables.vehicles = [];
|
|
_tables.wash_records = [];
|
|
_tables.operation_logs = [];
|
|
_seq.vehicles = 1;
|
|
_seq.wash_records = 1;
|
|
_seq.operation_logs = 1;
|
|
};
|
|
|
|
return { makeStub, reset, setStub: (s) => (stub = s), getStub: () => stub, _tables, _seq };
|
|
});
|
|
|
|
vi.mock('../src/db.js', () => ({ db: () => mocks.getStub() }));
|
|
vi.mock('../src/services/operationLog.js', () => ({ logOperation: vi.fn(async () => {}) }));
|
|
|
|
const vehiclesRouter = (await import('../src/routes/vehicles.js')).default;
|
|
|
|
function buildApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api', vehiclesRouter);
|
|
return app;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mocks.reset();
|
|
mocks.setStub(mocks.makeStub());
|
|
});
|
|
|
|
describe('routes/vehicles — 列表', () => {
|
|
it('空列表 → []', async () => {
|
|
const r = await request(buildApp()).get('/api/vehicles');
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toEqual([]);
|
|
});
|
|
|
|
it('过滤软删的车辆', async () => {
|
|
mocks._tables.vehicles.push(
|
|
{ id: 1, name: '车A', plate: '粤A111', type: 'car', is_active: 1, sort_order: 0, powertrain: 'ice', is_deleted: 0 },
|
|
{ id: 2, name: '车B', plate: '粤A222', type: 'suv', is_active: 1, sort_order: 0, powertrain: 'ice', is_deleted: 1 }
|
|
);
|
|
const r = await request(buildApp()).get('/api/vehicles');
|
|
expect(r.status).toBe(200);
|
|
expect(r.body).toHaveLength(1);
|
|
expect(r.body[0].name).toBe('车A');
|
|
});
|
|
|
|
it('返回字段包含 powertrain_label', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: '车A', type: 'ev', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ev',
|
|
});
|
|
const r = await request(buildApp()).get('/api/vehicles');
|
|
expect(r.body[0].powertrain_label).toBe('纯电');
|
|
});
|
|
|
|
it('?active=1 只返回 is_active=1', async () => {
|
|
mocks._tables.vehicles.push(
|
|
{ id: 1, name: '启用', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
|
|
{ id: 2, name: '停用', type: 'car', is_active: 0, is_deleted: 0, sort_order: 0, powertrain: 'ice' }
|
|
);
|
|
const r = await request(buildApp()).get('/api/vehicles?active=1');
|
|
expect(r.body).toHaveLength(1);
|
|
expect(r.body[0].name).toBe('启用');
|
|
});
|
|
|
|
it('wash_count / total_cost 来自 join', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: '车A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
mocks._tables.wash_records.push(
|
|
{ id: 10, vehicle_id: 1, cost: 30, wash_date: '2025-01-01', is_deleted: 0 },
|
|
{ id: 11, vehicle_id: 1, cost: 25.5, wash_date: '2025-02-01', is_deleted: 0 }
|
|
);
|
|
const r = await request(buildApp()).get('/api/vehicles');
|
|
expect(r.body[0].wash_count).toBe(2);
|
|
expect(r.body[0].total_cost).toBe(55.5);
|
|
expect(r.body[0].last_wash_date).toBe('2025-02-01');
|
|
});
|
|
});
|
|
|
|
describe('routes/vehicles — 详情', () => {
|
|
it('存在 → 返回', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: '车A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
const r = await request(buildApp()).get('/api/vehicles/1');
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.id).toBe(1);
|
|
});
|
|
|
|
it('不存在 → 404', async () => {
|
|
const r = await request(buildApp()).get('/api/vehicles/999');
|
|
expect(r.status).toBe(404);
|
|
expect(r.body.error.code).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('软删 → 视为不存在', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 1, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
const r = await request(buildApp()).get('/api/vehicles/1');
|
|
expect(r.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('routes/vehicles — 创建', () => {
|
|
it('缺 name → 422', async () => {
|
|
const r = await request(buildApp()).post('/api/vehicles').send({ type: 'car' });
|
|
expect(r.status).toBe(422);
|
|
expect(r.body.error.code).toBe('VALIDATION');
|
|
expect(r.body.error.errors.name).toBeDefined();
|
|
});
|
|
|
|
it('name 超 64 字 → 422', async () => {
|
|
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'x'.repeat(65) });
|
|
expect(r.status).toBe(422);
|
|
});
|
|
|
|
it('type 非法 → 422', async () => {
|
|
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'X', type: 'rocket' });
|
|
expect(r.status).toBe(422);
|
|
});
|
|
|
|
it('powertrain 非法 → 422', async () => {
|
|
const r = await request(buildApp())
|
|
.post('/api/vehicles')
|
|
.send({ name: 'X', powertrain: 'fusion' });
|
|
expect(r.status).toBe(422);
|
|
});
|
|
|
|
it('车牌重复 → 409', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: 'A', plate: '粤A111', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
const r = await request(buildApp())
|
|
.post('/api/vehicles')
|
|
.send({ name: 'B', plate: '粤A111' });
|
|
expect(r.status).toBe(409);
|
|
expect(r.body.error.code).toBe('CONFLICT');
|
|
});
|
|
|
|
it('合法 → 200 + id', async () => {
|
|
const r = await request(buildApp())
|
|
.post('/api/vehicles')
|
|
.send({ name: '我的车', plate: '粤E99999', type: 'suv', powertrain: 'hev' });
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.id).toBeDefined();
|
|
expect(mocks._tables.vehicles).toHaveLength(1);
|
|
expect(mocks._tables.vehicles[0].powertrain).toBe('hev');
|
|
});
|
|
|
|
it('默认 powertrain = ice', async () => {
|
|
const r = await request(buildApp()).post('/api/vehicles').send({ name: 'X' });
|
|
expect(r.status).toBe(200);
|
|
expect(mocks._tables.vehicles[0].powertrain).toBe('ice');
|
|
});
|
|
});
|
|
|
|
describe('routes/vehicles — 软删', () => {
|
|
it('DELETE → is_deleted=1', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
const r = await request(buildApp()).delete('/api/vehicles/1');
|
|
expect(r.status).toBe(200);
|
|
expect(mocks._tables.vehicles[0].is_deleted).toBe(1);
|
|
});
|
|
|
|
it('软删后列表查不到', async () => {
|
|
mocks._tables.vehicles.push({
|
|
id: 1, name: 'X', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice',
|
|
});
|
|
await request(buildApp()).delete('/api/vehicles/1');
|
|
const r = await request(buildApp()).get('/api/vehicles');
|
|
expect(r.body).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('routes/vehicles — stats', () => {
|
|
it('总览统计', async () => {
|
|
mocks._tables.vehicles.push(
|
|
{ id: 1, name: 'A', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
|
|
{ id: 2, name: 'B', type: 'car', is_active: 1, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
|
|
{ id: 3, name: 'C', type: 'car', is_active: 0, is_deleted: 0, sort_order: 0, powertrain: 'ice' },
|
|
{ id: 4, name: 'D', type: 'car', is_active: 1, is_deleted: 1, sort_order: 0, powertrain: 'ice' }
|
|
);
|
|
mocks._tables.wash_records.push(
|
|
{ id: 10, vehicle_id: 1, is_deleted: 0 },
|
|
{ id: 11, vehicle_id: 1, is_deleted: 0 }
|
|
);
|
|
const r = await request(buildApp()).get('/api/vehicles/stats');
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.total).toBe(3);
|
|
expect(r.body.active).toBe(2);
|
|
expect(r.body.with_washes).toBe(1);
|
|
});
|
|
});
|