tyt-api-nest/test/e2e/specs/patients.e2e-spec.ts
EL 7c4ba1e1a0 feat(auth): 支持同一微信 openId 绑定多个院内账号
feat(patients): 增强 B 端患者列表返回原发病/压力/手术日期字段
2026-03-24 16:51:37 +08:00

460 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '请求参数不合法');
});
});
});