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