import sharp from 'sharp'; import request from 'supertest'; import { DeviceStatus, Role } 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 { buildMiniAppMockPayload } 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('Patients Controllers (e2e)', () => { let ctx: E2EContext; let samplePngBuffer: Buffer; beforeAll(async () => { ctx = await createE2EContext(); samplePngBuffer = await sharp({ create: { width: 24, height: 24, channels: 3, background: { r: 20, g: 60, b: 120 }, }, }) .png() .toBuffer(); }); afterAll(async () => { await closeE2EContext(ctx); }); async function uploadEngineerProof() { const response = await request(ctx.app.getHttpServer()) .post('/b/uploads') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .attach('file', samplePngBuffer, { filename: 'patient-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; }; } async function loginFamilyByPhone(phone: string, openId?: string) { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') .send(buildMiniAppMockPayload(phone, openId ?? uniqueSeedValue('family-openid'))); expectSuccessEnvelope(response, 201); return response.body.data.accessToken as string; } describe('GET /b/patients', () => { it('成功:按角色返回正确可见性范围', async () => { const systemAdminResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .query({ hospitalId: ctx.fixtures.hospitalAId }) .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`); expectSuccessEnvelope(systemAdminResponse, 200); const systemPatientIds = ( systemAdminResponse.body.data as Array<{ id: number }> ).map((item) => item.id); expect(systemPatientIds).toEqual( expect.arrayContaining([ ctx.fixtures.patients.patientA1Id, ctx.fixtures.patients.patientA2Id, ctx.fixtures.patients.patientA3Id, ]), ); const patientA1 = ( systemAdminResponse.body.data as Array<{ id: number; primaryDisease: string | null; hydrocephalusTypes: string[]; surgeryDate: string | null; currentPressure: string | null; initialPressure: string | null; }> ).find((item) => item.id === ctx.fixtures.patients.patientA1Id); expect(patientA1).toBeDefined(); expect(patientA1?.primaryDisease).toBeTruthy(); expect(Array.isArray(patientA1?.hydrocephalusTypes)).toBe(true); expect(patientA1?.surgeryDate).toBeTruthy(); expect(patientA1?.currentPressure).toBeTruthy(); expect(patientA1?.initialPressure).toBeTruthy(); const hospitalAdminResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); expectSuccessEnvelope(hospitalAdminResponse, 200); const hospitalPatientIds = ( hospitalAdminResponse.body.data as Array<{ id: number }> ).map((item) => item.id); expect(hospitalPatientIds).toEqual( expect.arrayContaining([ ctx.fixtures.patients.patientA1Id, ctx.fixtures.patients.patientA2Id, ctx.fixtures.patients.patientA3Id, ]), ); const directorResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`); expectSuccessEnvelope(directorResponse, 200); const directorPatientIds = ( directorResponse.body.data as Array<{ id: number }> ).map((item) => item.id); expect(directorPatientIds).toEqual( expect.arrayContaining([ ctx.fixtures.patients.patientA1Id, ctx.fixtures.patients.patientA2Id, ]), ); expect(directorPatientIds).not.toContain( ctx.fixtures.patients.patientA3Id, ); const leaderResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`); expectSuccessEnvelope(leaderResponse, 200); const leaderPatientIds = ( leaderResponse.body.data as Array<{ id: number }> ).map((item) => item.id); expect(leaderPatientIds).toEqual( expect.arrayContaining([ ctx.fixtures.patients.patientA1Id, ctx.fixtures.patients.patientA2Id, ]), ); expect(leaderPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id); const doctorResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); expectSuccessEnvelope(doctorResponse, 200); const doctorPatientIds = ( doctorResponse.body.data as Array<{ id: number }> ).map((item) => item.id); expect(doctorPatientIds).toContain(ctx.fixtures.patients.patientA1Id); expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA2Id); expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id); const engineerResponse = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`); expectErrorEnvelope(engineerResponse, 403, '无权限执行当前操作'); }); it('失败:SYSTEM_ADMIN 不传 hospitalId 返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`); expectErrorEnvelope( response, 400, '系统管理员查询必须显式传入 hospitalId', ); }); it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,ENGINEER 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/patients role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, [Role.DOCTOR]: 200, [Role.ENGINEER]: 403, }, sendAsRole: async (role, token) => { const req = request(ctx.app.getHttpServer()) .get('/b/patients') .set('Authorization', `Bearer ${token}`); if (role === Role.SYSTEM_ADMIN) { req.query({ hospitalId: ctx.fixtures.hospitalAId }); } return req; }, sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/patients'), }); }); }); describe('GET /c/patients/my-lifecycle', () => { it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => { const familyToken = await loginFamilyByPhone( '13800002001', 'seed-family-a1-openid', ); const response = await request(ctx.app.getHttpServer()) .get('/c/patients/my-lifecycle') .set('Authorization', `Bearer ${familyToken}`); expectSuccessEnvelope(response, 200); expect(response.body.data.phone).toBe('13800002001'); expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2); expect(Array.isArray(response.body.data.lifecycle)).toBe(true); }); it('失败:未登录返回 401', async () => { const response = await request(ctx.app.getHttpServer()) .get('/c/patients/my-lifecycle'); expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); it('成功:已存在家属账号再次登录后仍可查询', async () => { const familyToken = await loginFamilyByPhone('13800002002', 'seed-family-a2-openid'); const response = await request(ctx.app.getHttpServer()) .get('/c/patients/my-lifecycle') .set('Authorization', `Bearer ${familyToken}`); expectSuccessEnvelope(response, 200); expect(response.body.data.phone).toBe('13800002002'); }); }); describe('患者手术录入', () => { it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ name: '首术患者', inpatientNo: uniqueSeedValue('zyh'), projectName: '脑积水手术项目', phone: uniquePhone(), idCard: uniqueIdCard(), doctorId: ctx.fixtures.users.doctorAId, initialSurgery: { surgeryDate: '2026-03-19T08:00:00.000Z', surgeryName: '脑室腹腔分流术', preOpPressure: 20, primaryDisease: '梗阻性脑积水', hydrocephalusTypes: ['交通性'], devices: [ { implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', initialPressure: '1', implantNotes: '首术植入', labelImageUrl: 'https://seed.example.com/tests/first-surgery.jpg', }, ], }, }); expectSuccessEnvelope(response, 201); expect(response.body.data.creatorId).toBe(ctx.fixtures.users.doctorAId); expect(response.body.data.creator).toEqual( expect.objectContaining({ id: ctx.fixtures.users.doctorAId, }), ); expect(response.body.data.createdAt).toBeTruthy(); expect(response.body.data.shuntSurgeryCount).toBe(1); expect(response.body.data.surgeries).toHaveLength(1); expect(response.body.data.surgeries[0].surgeonId).toBe( ctx.fixtures.users.doctorAId, ); expect(response.body.data.surgeries[0].devices).toHaveLength(1); expect(response.body.data.surgeries[0].devices[0].implantModel).toBe( 'SEED-ADJUSTABLE-VALVE', ); }); it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => { const createPatientResponse = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ name: '二次手术患者', inpatientNo: uniqueSeedValue('zyh'), projectName: '二次手术项目', phone: uniquePhone(), idCard: uniqueIdCard(), doctorId: ctx.fixtures.users.doctorAId, initialSurgery: { surgeryDate: '2026-03-01T08:00:00.000Z', surgeryName: '首次分流术', preOpPressure: 18, primaryDisease: '出血后脑积水', hydrocephalusTypes: ['高压性'], devices: [ { implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', initialPressure: '1', implantNotes: '首术设备', labelImageUrl: 'https://seed.example.com/tests/initial-device.jpg', }, ], }, }); expectSuccessEnvelope(createPatientResponse, 201); const patient = createPatientResponse.body.data as { id: number; devices: Array<{ id: number }>; }; const oldDeviceId = patient.devices[0].id; const publishResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ items: [ { deviceId: oldDeviceId, targetPressure: '1.5', }, ], }); expectSuccessEnvelope(publishResponse, 201); const acceptResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/accept') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: publishResponse.body.data.id }); expectSuccessEnvelope(acceptResponse, 201); const proof = await uploadEngineerProof(); const completeResponse = await request(ctx.app.getHttpServer()) .post('/b/tasks/complete') .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`) .send({ taskId: publishResponse.body.data.id, completionMaterials: [ { assetId: proof.id, type: proof.type, url: proof.url, name: proof.originalName || proof.fileName || '调压完成照片', }, ], }); expectSuccessEnvelope(completeResponse, 201); const surgeryResponse = await request(ctx.app.getHttpServer()) .post(`/b/patients/${patient.id}/surgeries`) .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ surgeryDate: '2026-03-18T08:00:00.000Z', surgeryName: '二次翻修术', preOpPressure: 16, primaryDisease: '分流功能障碍', hydrocephalusTypes: ['交通性', '高压性'], abandonedDeviceIds: [oldDeviceId], devices: [ { implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['枕角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', initialPressure: '1', implantNotes: '二次手术新设备-1', labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg', }, { implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['胸前'], distalShuntDirection: '胸腔', initialPressure: '1.5', implantNotes: '二次手术新设备-2', labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg', }, ], }); expectSuccessEnvelope(surgeryResponse, 201); expect(surgeryResponse.body.data.devices).toHaveLength(2); expect(surgeryResponse.body.data.shuntSurgeryCount).toBe(2); const oldDevice = await ctx.prisma.device.findUnique({ where: { id: oldDeviceId }, include: { taskItems: true }, }); expect(oldDevice?.isAbandoned).toBe(true); expect(oldDevice?.status).toBe(DeviceStatus.INACTIVE); expect(oldDevice?.taskItems).toHaveLength(1); }); it('失败:手术录入设备不允许手工传 currentPressure', async () => { const response = await request(ctx.app.getHttpServer()) .post('/b/patients') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`) .send({ name: '非法当前压力患者', inpatientNo: uniqueSeedValue('zyh'), projectName: '非法字段校验', phone: uniquePhone(), idCard: uniqueIdCard(), doctorId: ctx.fixtures.users.doctorAId, initialSurgery: { surgeryDate: '2026-03-19T08:00:00.000Z', surgeryName: '脑室腹腔分流术', primaryDisease: '梗阻性脑积水', hydrocephalusTypes: ['交通性'], devices: [ { implantCatalogId: ctx.fixtures.catalogs.adjustableValveId, shuntMode: 'VPS', proximalPunctureAreas: ['额角'], valvePlacementSites: ['耳后'], distalShuntDirection: '腹腔', initialPressure: '1', currentPressure: '1', }, ], }, }); expectErrorEnvelope(response, 400, '请求参数不合法'); }); }); });