Files
i/server/test/routes.vehicles.test.js
wsh5485 65b0bb04f8 feat: import CarLog v2.8 code + dev plan
把 CarLog v2.8 全套源码 + 配置导入到 i 仓库作为 baseline:
- server/src/ (13 个路由 + middleware + services + config)
- server/migrations/ (0001~0018 共 18 个迁移 + mysql)
- server/test/ (12 文件 101 测试)
- client/src/ (20 个 view + components + stores + api + composables)
- client/public/ + client/scripts/
- 全部配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, lighthouserc.json, .pa11yci.json, package.json, carlog-init.sql)
- .husky/pre-commit (git hooks)
- docs/install/ (宝塔部署文档)

不含:
- node_modules/ (本地 npm install)
- .env (敏感, 走 .env.example)
- *.zip / *.log / *.sqlite / .DS_Store

新增文档 docs/DEV-PLAN.md:
- Phase 1: 平台基座 (019 migration + 3 个 platform 路由 + 3 个 view)
- Phase 2: CarLog 子系统化 (后端 routes/ → subsystems/carlog/ + 前端 views/ → views/subsystems/carlog/ + 元数据驱动菜单)
- Phase 3: 验证 (测试 + E2E + DB 完整性)
- 交付清单 + commit 模板 + 给 Mavis review 的材料

后续 Trae 实施, 提交后我 code review + 跑测试。
2026-06-20 22:30:19 +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);
});
});