tyt-api-nest/test/e2e/specs/patients.e2e-spec.ts
EL 73082225f6 "1. 新增系统字典与全局植入目录相关表结构及迁移
2. 扩展患者手术与材料模型,更新种子数据
3. 新增字典模块,增强设备植入目录管理能力
4. 重构患者后台服务与表单链路,统一权限与参数校验
5. 管理台新增字典页面并改造患者/设备页面与路由权限
6. 补充字典及相关领域 e2e 测试并更新文档"
2026-03-19 20:42:17 +08:00

407 lines
14 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 request from 'supertest';
import {
DeviceStatus,
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';
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;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
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/lifecycle', () => {
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
idCard: '110101199001010011',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002001');
expect(response.body.data.idCard).toBe('110101199001010011');
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
});
it('失败:参数缺失返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectErrorEnvelope(response, 400, 'idCard 必须是字符串');
});
it('失败:不存在患者返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800009999',
idCard: '110101199009090099',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
});
});
describe('患者手术录入', () => {
it('成功DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
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: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 20,
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
implantNotes: '首术植入',
labelImageUrl:
'https://seed.example.com/tests/first-surgery.jpg',
},
],
},
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.shuntSurgeryCount).toBe(1);
expect(response.body.data.surgeries).toHaveLength(1);
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 adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
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: '首次分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 18,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 100,
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;
await ctx.prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: ctx.fixtures.users.doctorAId,
engineerId: ctx.fixtures.users.engineerAId,
hospitalId: ctx.fixtures.hospitalAId,
items: {
create: [
{
deviceId: oldDeviceId,
oldPressure: 100,
targetPressure: 120,
},
],
},
},
});
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: '二次翻修术',
surgeonName: 'Seed Doctor A',
preOpPressure: 16,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
abandonedDeviceIds: [oldDeviceId],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
implantNotes: '二次手术新设备-1',
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
},
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '胸腔',
initialPressure: 140,
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 adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
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: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
currentPressure: 120,
},
],
},
});
expectErrorEnvelope(response, 400, '请求参数不合法');
});
});
});