feat(P21PWA1-1,P21PWA1-2,P21PWA1-3): 资产搜索扩展/扫码适配器/IndexedDB离线队列与去重重放
这个提交包含在:
@@ -0,0 +1,181 @@
|
||||
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('adds q matcher for assetNo serialNo and name fallback', 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).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
OR: [
|
||||
{ asset_no: 'ZC-001' },
|
||||
{ serial_no: 'ZC-001' },
|
||||
{ name: { contains: 'ZC-001', mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('forces own-scope lookup even when caller passes mine=0', async () => {
|
||||
const prisma = buildPrisma({
|
||||
items: [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).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
assignee_employee_id: 'employee-1',
|
||||
OR: [
|
||||
{ asset_no: 'SN-002' },
|
||||
{ serial_no: 'SN-002' },
|
||||
{ name: { contains: 'SN-002', mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户