Files
CarLog/server/test/routes.vehicles.test.js
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
2026-06-20 21:11:54 +08:00

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);
});
});