65b0bb04f8
把 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 + 跑测试。
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);
|
|
});
|
|
});
|