const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const assetsRoutes = require('../../../src/routes/assets'); function buildAssetRecord({ id = 'asset-1', assetNo = 'ZC-001', serialNo = 'SN-001', assigneeEmployeeId = 'employee-1', name = '测试笔记本', } = {}) { const now = new Date('2026-03-11T00:00:00.000Z'); return { id, asset_no: assetNo, name, category_id: 'cat-1', legal_entity_id: 'legal-1', cost_center_id: 'cc-1', purchase_request_id: null, serial_no: serialNo, location: '深圳办公室', status: 'in_use', assignee_employee_id: assigneeEmployeeId, activated_at: now, retired_at: null, created_at: now, updated_at: now, category: null, legal_entity: { id: 'legal-1', code: 'HY', name: '海韵科技', }, cost_center: { id: 'cc-1', code: 'CC001', name: '研发中心', legal_entity_id: 'legal-1', }, purchase_request: null, assignee: { id: assigneeEmployeeId, employee_no: 'E001', name: '张三', department: null, }, }; } function buildPrisma({ items = [] } = {}) { return { assetCard: { findMany: vi.fn().mockResolvedValue(items), }, }; } async function buildApp({ employee, prisma }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', {}); app.decorate('authenticate', async (request) => { if (!request.headers.authorization) { throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401, }); } request.employee = employee; }); app.decorate('hasPermission', (actor, permissionCode) => { return (actor?.permissionCodes ?? []).includes('*') || (actor?.permissionCodes ?? []).includes(permissionCode); }); app.decorate('requirePermission', (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403, details: { permission: permissionCode }, }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: 'SYS_001', message: '服务器内部错误', details: {} }, }); }); await app.register(assetsRoutes, { prefix: '/api' }); return app; } describe('assets search route', () => { it('prefers exact assetNo matching before serial or fuzzy lookup', async () => { const prisma = buildPrisma({ items: [buildAssetRecord()], }); const app = await buildApp({ employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, prisma, }); const response = await app.inject({ method: 'GET', url: '/api/assets?q=ZC-001', headers: { authorization: 'Bearer token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0]).toEqual( expect.objectContaining({ assetNo: 'ZC-001', serialNo: 'SN-001', }), ); expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(1); expect(prisma.assetCard.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ asset_no: 'ZC-001', }), }), ); await app.close(); }); it('falls back to serialNo exact lookup when assetNo misses', async () => { const prisma = { assetCard: { findMany: vi .fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([buildAssetRecord({ id: 'asset-2', assetNo: 'ZC-003', serialNo: 'SN-003' })]), }, }; const app = await buildApp({ employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, prisma, }); const response = await app.inject({ method: 'GET', url: '/api/assets?q=SN-003', headers: { authorization: 'Bearer token' }, }); expect(response.statusCode).toBe(200); expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: expect.objectContaining({ asset_no: 'SN-003', }), }), ); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: expect.objectContaining({ serial_no: 'SN-003', }), }), ); await app.close(); }); it('falls back to fuzzy name search only after both exact lookups miss', async () => { const prisma = { assetCard: { findMany: vi .fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([buildAssetRecord({ id: 'asset-3', assetNo: 'ZC-004', serialNo: 'SN-004', name: '研发测试笔记本' })]), }, }; const app = await buildApp({ employee: { id: 'manager-1', permissionCodes: ['asset.view_all'] }, prisma, }); const response = await app.inject({ method: 'GET', url: '/api/assets?q=测试笔记本', headers: { authorization: 'Bearer token' }, }); expect(response.statusCode).toBe(200); expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(3); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: expect.objectContaining({ asset_no: '测试笔记本', }), }), ); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: expect.objectContaining({ serial_no: '测试笔记本', }), }), ); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 3, expect.objectContaining({ where: expect.objectContaining({ name: { contains: '测试笔记本', mode: 'insensitive' }, }), }), ); await app.close(); }); it('forces own-scope lookup even when caller passes mine=0', async () => { const prisma = { assetCard: { findMany: vi .fn() .mockResolvedValueOnce([]) .mockResolvedValueOnce([buildAssetRecord({ assigneeEmployeeId: 'employee-1', assetNo: 'ZC-002', serialNo: 'SN-002' })]), }, }; const app = await buildApp({ employee: { id: 'employee-1', permissionCodes: ['asset.view_own'] }, prisma, }); const response = await app.inject({ method: 'GET', url: '/api/assets?q=SN-002&mine=0', headers: { authorization: 'Bearer token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.mine).toBe(true); expect(prisma.assetCard.findMany).toHaveBeenCalledTimes(2); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: expect.objectContaining({ assignee_employee_id: 'employee-1', asset_no: 'SN-002', }), }), ); expect(prisma.assetCard.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: expect.objectContaining({ assignee_employee_id: 'employee-1', serial_no: 'SN-002', }), }), ); await app.close(); }); });