tyt-api-nest/test/e2e/specs/patients.e2e-spec.ts
EL ab17204739 C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle
生命周期事件移除事件内重复 patient 字段,减少冗余
B端患者生命周期接口同步采用 patient + lifecycle 结构
新增并接入生命周期 Swagger 响应模型,补齐接口文档
更新 auth/patients/frontend 集成文档说明
增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
2026-04-02 04:07:40 +08:00

507 lines
18 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 loginPatientByPhone(phone: string, openId?: string) {
const response = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/c/phone-login')
.send(
buildMiniAppMockPayload(
phone,
openId ?? uniqueSeedValue('patient-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 patientToken = await loginPatientByPhone(
'13800002002',
'seed-family-a2-openid',
);
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/my-lifecycle')
.set('Authorization', `Bearer ${patientToken}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002002');
expect(response.body.data).toHaveProperty('patient');
expect(response.body.data).not.toHaveProperty('patientCount');
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
const firstEvent = response.body.data.lifecycle[0] as
| Record<string, unknown>
| undefined;
if (firstEvent) {
expect(firstEvent).not.toHaveProperty('patient');
}
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/c/patients/my-lifecycle',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('成功:已存在 C 端账号再次登录后仍可查询', async () => {
const patientToken = await loginPatientByPhone(
'13800002002',
'seed-family-a2-openid',
);
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/my-lifecycle')
.set('Authorization', `Bearer ${patientToken}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002002');
});
});
describe('GET /b/patients/:id/lifecycle', () => {
it('成功:返回顶层 patient 与 lifecycle 结构', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/patients/${ctx.fixtures.patients.patientA1Id}/lifecycle`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('patient');
expect(response.body.data.patient.id).toBe(
ctx.fixtures.patients.patientA1Id,
);
expect(response.body.data).not.toHaveProperty('patientId');
expect(response.body.data).not.toHaveProperty('patientCount');
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
const firstEvent = response.body.data.lifecycle[0] as
| Record<string, unknown>
| undefined;
if (firstEvent) {
expect(firstEvent).not.toHaveProperty('patient');
}
});
it('失败ENGINEER 无权限访问返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/patients/${ctx.fixtures.patients.patientA1Id}/lifecycle`)
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
});
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, '请求参数不合法');
});
});
});