tyt-api-nest/test/e2e/specs/patients.e2e-spec.ts
EL 0b5640a977 调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。
新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。
发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。
完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。
植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。
患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。
增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
2026-03-20 06:03:09 +08:00

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