import sharp from 'sharp'; import request from 'supertest'; import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js'; import { closeE2EContext, createE2EContext, type E2EContext, } from '../helpers/e2e-context.helper.js'; import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; import { expectErrorEnvelope, expectSuccessEnvelope, uniquePhone, uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; import { loginByMiniApp } from '../helpers/e2e-miniapp-auth.helper.js'; function uniqueIdCard() { const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}` .replace(/\D/g, '') .slice(-4); return `11010119990101${suffix.padStart(4, '0')}`; } describe('BTasksController (e2e)', () => { let ctx: E2EContext; let samplePngBuffer: Buffer; let doctorBToken = ''; beforeAll(async () => { ctx = await createE2EContext(); samplePngBuffer = await sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 42, g: 78, b: 126 }, }, }) .png() .toBuffer(); doctorBToken = await loginByUser( ctx.fixtures.users.doctorBId, Role.DOCTOR, ctx.fixtures.hospitalBId, ); }); afterAll(async () => { await closeE2EContext(ctx); }); async function publishPendingTask( deviceId: number, targetPressure: string, actorToken = ctx.tokens[Role.DOCTOR], ) { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${actorToken}`) .send({ items: [ { deviceId, targetPressure, }, ], }); expectSuccessEnvelope(response, 201); return response.body.data as { id: number; status: TaskStatus; engineerId: number | null; hospitalId: number; }; } async function acceptPendingTask( taskId: number, actorToken = ctx.tokens[Role.ENGINEER], ) { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${actorToken}`) .send({ taskId }); expectSuccessEnvelope(response, 201); return response.body.data as { id: number; status: TaskStatus; engineerId: number; }; } async function loginByUser(userId: number, role: Role, hospitalId: number) { const user = await ctx.prisma.user.findUnique({ where: { id: userId }, select: { phone: true, openId: true }, }); expect(user?.phone).toBeTruthy(); expect(user?.openId).toBeTruthy(); return loginByMiniApp(ctx.app.getHttpServer(), { phone: user!.phone, openId: user!.openId!, role, hospitalId, userId, }); } async function createAdjustableDevices(options?: { actorToken?: string; doctorId?: number; count?: number; patientName?: string; projectName?: string; }) { const { actorToken = ctx.tokens[Role.DOCTOR], doctorId = ctx.fixtures.users.doctorAId, count = 1, patientName = '调压测试患者', projectName = '调压测试项目', } = options ?? {}; const response = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${actorToken}`) .send({ name: `${patientName}-${uniqueSeedValue('name')}`, inpatientNo: uniqueSeedValue('zyh'), projectName, phone: uniquePhone(), idCard: uniqueIdCard(), doctorId, initialSurgery: { surgeryDate: '2026-03-20T08:00:00.000Z', surgeryName: '调压测试手术', preOpPressure: 18, primaryDisease: '交通性脑积水', hydrocephalusTypes: ['交通性'], devices: Array.from({ length: count }, (_, index) => ({ implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: [index % 2 === 0 ? '额角' : '枕角'], valvePlacementSites: [index % 2 === 0 ? '耳后' : '胸前'], distalShuntDirection: '腹腔', initialPressure: index % 2 === 0 ? '1' : '1.5', implantNotes: uniqueSeedValue(`task-device-${index + 1}`), })), }, }); expectSuccessEnvelope(response, 201); return response.body.data.devices as Array<{ id: number }>; } async function uploadEngineerProof(actorToken = ctx.tokens[Role.ENGINEER]) { const response = await request(ctx.app.getHttpServer()) .post('/b/uploads') .set('Authorization', `Bearer ${actorToken}`) .attach('file', samplePngBuffer, { filename: 'task-proof.png', contentType: 'image/png', }); expectSuccessEnvelope(response, 201); return response.body.data as { id: number; type: 'IMAGE' | 'VIDEO'; url: string; originalName?: string; fileName?: string; }; } function buildCompletionPayload( taskId: number, asset: Awaited>, ) { return { taskId, completionMaterials: [ { assetId: asset.id, type: asset.type, url: asset.url, name: asset.originalName || asset.fileName || '调压完成照片', }, ], }; } describe('GET /b/tasks/engineers', () => { it('成功:DOCTOR 可查看本院可选工程师', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/tasks/engineers') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); expectSuccessEnvelope(response, 200); expect(Array.isArray(response.body.data)).toBe(true); expect(response.body.data).toEqual( expect.arrayContaining([ expect.objectContaining({ id: ctx.fixtures.users.engineerAId, }), ]), ); }); it('成功:SYSTEM_ADMIN 可按医院筛选工程师', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/tasks/engineers') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .query({ hospitalId: ctx.fixtures.hospitalBId }); expectSuccessEnvelope(response, 200); expect(response.body.data).toEqual([ expect.objectContaining({ id: ctx.fixtures.users.engineerBId, hospitalId: ctx.fixtures.hospitalBId, }), ]); }); it('失败:hospitalId 非法返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/tasks/engineers') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .query({ hospitalId: 0 }); expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0'); }); }); describe('GET /b/tasks', () => { it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => { const [deviceA] = await createAdjustableDevices(); const [deviceB] = await createAdjustableDevices({ actorToken: doctorBToken, doctorId: ctx.fixtures.users.doctorBId, patientName: 'B院调压患者', }); await publishPendingTask( deviceA.id, '1.5', ctx.tokens[Role.SYSTEM_ADMIN], ); await publishPendingTask( deviceB.id, '1.5', ctx.tokens[Role.SYSTEM_ADMIN], ); const response = await request(ctx.app.getHttpServer()) .get('/b/tasks') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .query({ page: 1, pageSize: 20 }); expectSuccessEnvelope(response, 200); expect(Array.isArray(response.body.data.list)).toBe(true); expect(response.body.data.total).toBeGreaterThan(0); expect( response.body.data.list.every( (item: { creator?: { id?: number; name?: string }; engineer?: { id?: number; name?: string } | null; }) => Number.isInteger(item.creator?.id) && Boolean(item.creator?.name), ), ).toBe(true); const hospitalIds = Array.from( new Set( response.body.data.list .map((item: { hospital?: { id?: number } }) => item.hospital?.id) .filter(Boolean), ), ); expect(hospitalIds).toEqual( expect.arrayContaining([ ctx.fixtures.hospitalAId, ctx.fixtures.hospitalBId, ]), ); }); it('成功:DOCTOR 仅可查看本院调压记录', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/tasks') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .query({ page: 1, pageSize: 20 }); expectSuccessEnvelope(response, 200); expect(Array.isArray(response.body.data.list)).toBe(true); expect(response.body.data.total).toBeGreaterThan(0); expect( response.body.data.list.every( (item: { hospital?: { id?: number } }) => item.hospital?.id === ctx.fixtures.hospitalAId, ), ).toBe(true); }); it('失败:hospitalId 非法返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/tasks') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .query({ hospitalId: 0 }); expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0'); }); }); describe('POST /b/tasks/publish', () => { it('成功:DOCTOR 发布任务后进入待接收状态', async () => { const [device] = await createAdjustableDevices(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: device.id, targetPressure: '1.5', }, ], }); expectSuccessEnvelope(response, 201); expect(response.body.data.status).toBe(TaskStatus.PENDING); expect(response.body.data.engineerId).toBeNull(); }); it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask( device.id, '1.5', ctx.tokens[Role.SYSTEM_ADMIN], ); expect(task.status).toBe(TaskStatus.PENDING); expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId); }); it('失败:可调压设备使用非法挡位返回 400', async () => { const [device] = await createAdjustableDevices(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: device.id, targetPressure: '2', }, ], }); expectErrorEnvelope(response, 400, '压力值不在该植入物配置的挡位范围内'); }); it('失败:发布跨院设备任务返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: ctx.fixtures.devices.deviceB1Id, targetPressure: '1.5', }, ], }); expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在'); }); it('失败:已有待处理任务的设备不可重复发布', async () => { const [device] = await createAdjustableDevices(); await publishPendingTask(device.id, '1.5'); const duplicateResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: device.id, targetPressure: '1', }, ], }); expectErrorEnvelope(duplicateResponse, 409, '该设备已有待处理调压任务'); }); it('成功:同一患者另一台设备无任务时仍可发布', async () => { const createdDevices = await createAdjustableDevices({ count: 2 }); expect(createdDevices).toHaveLength(2); await publishPendingTask(createdDevices[0].id, '1.5'); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: createdDevices[1].id, targetPressure: '1', }, ], }); expectSuccessEnvelope(response, 201); expect(response.body.data.status).toBe(TaskStatus.PENDING); }); it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/publish role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 400, [Role.HOSPITAL_ADMIN]: 400, [Role.DIRECTOR]: 400, [Role.LEADER]: 400, [Role.DOCTOR]: 400, [Role.ENGINEER]: 403, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${token}`) .send({}), sendWithoutToken: async () => request(ctx.app.getHttpServer()).post('/b/tasks/publish').send({}), }); }); }); describe('POST /b/tasks/accept', () => { it('成功:ENGINEER 可接收本院待处理任务', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); const acceptedTask = await acceptPendingTask(task.id); expect(acceptedTask.status).toBe(TaskStatus.ACCEPTED); expect(acceptedTask.engineerId).toBe(ctx.fixtures.users.engineerAId); }); it('失败:跨院工程师不能接收任务', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); const engineerB = await ctx.prisma.user.findUnique({ where: { id: ctx.fixtures.users.engineerBId }, select: { phone: true }, }); expect(engineerB?.phone).toBeTruthy(); const loginResponse = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send({ loginCode: `mock-login:${encodeURIComponent('seed-engineer-b-openid')}`, phoneCode: `mock-phone:${engineerB?.phone}`, }); expectSuccessEnvelope(loginResponse, 201); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${loginResponse.body.data.accessToken}`) .send({ taskId: task.id }); expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); }); it('状态机失败:已被接收的任务不可再次接收', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); await acceptPendingTask(task.id); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id }); expectErrorEnvelope(response, 409, '仅待接收任务可执行接收'); }); it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/accept role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 403, [Role.HOSPITAL_ADMIN]: 403, [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 404, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${token}`) .send({ taskId: 99999999 }), sendWithoutToken: async () => request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .send({ taskId: 99999999 }), }); }); }); describe('POST /b/tasks/complete', () => { it('成功:ENGINEER 可完成自己已接收的任务并同步设备压力', async () => { const targetPressure = '1.5'; const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, targetPressure); await acceptPendingTask(task.id); const proof = await uploadEngineerProof(); const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(completeResponse, 201); expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED); expect(completeResponse.body.data.completionMaterials).toEqual([ expect.objectContaining({ assetId: proof.id, type: 'IMAGE', url: proof.url, }), ]); const updatedDevice = await ctx.prisma.device.findUnique({ where: { id: device.id }, select: { currentPressure: true }, }); expect(updatedDevice?.currentPressure).toBe(targetPressure); }); it('失败:完成不存在任务返回 404', async () => { const proof = await uploadEngineerProof(); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send(buildCompletionPayload(99999999, proof)); expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); }); it('失败:未上传完成凭证返回 400', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); await acceptPendingTask(task.id); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id, completionMaterials: [], }); expectErrorEnvelope(response, 400, 'completionMaterials 至少上传 1 项'); }); it('状态机失败:重复完成返回 409', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1'); await acceptPendingTask(task.id); const proof = await uploadEngineerProof(); const firstComplete = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(firstComplete, 201); const secondComplete = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send(buildCompletionPayload(task.id, proof)); expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成'); }); it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => { const proof = await uploadEngineerProof(); await assertRoleMatrix({ name: 'POST /b/tasks/complete role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 403, [Role.HOSPITAL_ADMIN]: 403, [Role.DIRECTOR]: 403, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 404, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${token}`) .send(buildCompletionPayload(99999999, proof)), sendWithoutToken: async () => request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .send(buildCompletionPayload(99999999, proof)), }); }); }); describe('POST /b/tasks/cancel', () => { it('成功:DOCTOR 可取消自己创建的待接收任务', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ taskId: task.id }); expectSuccessEnvelope(response, 201); expect(response.body.data.status).toBe(TaskStatus.CANCELLED); }); it('成功:ENGINEER 可取消接收并退回待接收状态', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); await acceptPendingTask(task.id); const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id, reason: '患者临时取消' }); expectSuccessEnvelope(response, 201); expect(response.body.data.status).toBe(TaskStatus.PENDING); expect(response.body.data.engineerId).toBeNull(); const secondAcceptResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: task.id }); expectSuccessEnvelope(secondAcceptResponse, 201); expect(secondAcceptResponse.body.data.status).toBe(TaskStatus.ACCEPTED); }); it('失败:取消不存在任务返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ taskId: 99999999 }); expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院'); }); it('状态机失败:已完成任务不可取消返回 409', async () => { const [device] = await createAdjustableDevices(); const task = await publishPendingTask(device.id, '1.5'); await acceptPendingTask(task.id); const proof = await uploadEngineerProof(); const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send(buildCompletionPayload(task.id, proof)); expectSuccessEnvelope(completeResponse, 201); const cancelResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ taskId: task.id }); expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消'); }); it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/cancel role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, [Role.DIRECTOR]: 404, [Role.LEADER]: 404, [Role.DOCTOR]: 404, [Role.ENGINEER]: 404, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .set('Authorization', `Bearer ${token}`) .send({ taskId: 99999999 }), sendWithoutToken: async () => request(ctx.app.getHttpServer()) .post('/b/tasks/cancel') .send({ taskId: 99999999 }), }); }); }); });