新增 B 端上传接口与列表接口,统一文件上传和分页查询能力 上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离 图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储 增加上传目录与公开访问能力,统一输出可直接预览的访问地址 前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问 前端新增通用上传组件,支持在页面内复用并返回上传结果 管理后台新增影像库菜单与路由,并补充页面级角色权限控制 患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填 新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景 补充上传模块文档与安装依赖说明,完善工程内使用说明
457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import request from 'supertest';
|
||
import { 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,
|
||
} from '../helpers/e2e-http.helper.js';
|
||
|
||
describe('BTasksController (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
});
|
||
|
||
async function publishAssignedTask(
|
||
deviceId: number,
|
||
targetPressure: string,
|
||
actorToken = ctx.tokens[Role.DOCTOR],
|
||
engineerId = ctx.fixtures.users.engineerAId,
|
||
) {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${actorToken}`)
|
||
.send({
|
||
engineerId,
|
||
items: [
|
||
{
|
||
deviceId,
|
||
targetPressure,
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
return response.body.data as {
|
||
id: number;
|
||
status: TaskStatus;
|
||
engineerId: number;
|
||
hospitalId: number;
|
||
};
|
||
}
|
||
|
||
describe('GET /b/tasks/engineers', () => {
|
||
it('成功:DOCTOR 可查看本院可选工程师', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks/engineers')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||
|
||
expectSuccessEnvelope(response, 200);
|
||
expect(Array.isArray(response.body.data)).toBe(true);
|
||
expect(response.body.data).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
id: ctx.fixtures.users.engineerAId,
|
||
}),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('成功:SYSTEM_ADMIN 可按医院筛选工程师', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks/engineers')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.query({ hospitalId: ctx.fixtures.hospitalBId });
|
||
|
||
expectSuccessEnvelope(response, 200);
|
||
expect(response.body.data).toEqual([
|
||
expect.objectContaining({
|
||
id: ctx.fixtures.users.engineerBId,
|
||
hospitalId: ctx.fixtures.hospitalBId,
|
||
}),
|
||
]);
|
||
});
|
||
|
||
it('失败:hospitalId 非法返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks/engineers')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.query({ hospitalId: 0 });
|
||
|
||
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
|
||
});
|
||
});
|
||
|
||
describe('GET /b/tasks', () => {
|
||
it('成功:SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.query({ page: 1, pageSize: 20 });
|
||
|
||
expectSuccessEnvelope(response, 200);
|
||
expect(Array.isArray(response.body.data.list)).toBe(true);
|
||
expect(response.body.data.total).toBeGreaterThan(0);
|
||
expect(
|
||
response.body.data.list.every(
|
||
(item: {
|
||
creator?: { id?: number; name?: string };
|
||
engineer?: { id?: number; name?: string };
|
||
}) =>
|
||
Number.isInteger(item.creator?.id) &&
|
||
Boolean(item.creator?.name) &&
|
||
Number.isInteger(item.engineer?.id) &&
|
||
Boolean(item.engineer?.name),
|
||
),
|
||
).toBe(true);
|
||
|
||
const hospitalIds = Array.from(
|
||
new Set(
|
||
response.body.data.list
|
||
.map((item: { hospital?: { id?: number } }) => item.hospital?.id)
|
||
.filter(Boolean),
|
||
),
|
||
);
|
||
expect(hospitalIds).toEqual(
|
||
expect.arrayContaining([
|
||
ctx.fixtures.hospitalAId,
|
||
ctx.fixtures.hospitalBId,
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('成功:DOCTOR 仅可查看本院调压记录', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.query({ page: 1, pageSize: 20 });
|
||
|
||
expectSuccessEnvelope(response, 200);
|
||
expect(Array.isArray(response.body.data.list)).toBe(true);
|
||
expect(response.body.data.total).toBeGreaterThan(0);
|
||
expect(
|
||
response.body.data.list.every(
|
||
(item: { hospital?: { id?: number } }) =>
|
||
item.hospital?.id === ctx.fixtures.hospitalAId,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('失败:hospitalId 非法返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/tasks')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.query({ hospitalId: 0 });
|
||
|
||
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
|
||
});
|
||
});
|
||
|
||
describe('POST /b/tasks/publish', () => {
|
||
it('成功:DOCTOR 发布任务时必须直接指定接收工程师', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
engineerId: ctx.fixtures.users.engineerAId,
|
||
items: [
|
||
{
|
||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
||
expect(response.body.data.engineerId).toBe(
|
||
ctx.fixtures.users.engineerAId,
|
||
);
|
||
});
|
||
|
||
it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA1Id,
|
||
'1.5',
|
||
ctx.tokens[Role.SYSTEM_ADMIN],
|
||
);
|
||
|
||
expect(task.status).toBe(TaskStatus.ACCEPTED);
|
||
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
|
||
});
|
||
|
||
it('失败:未指定接收工程师返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, 'engineerId 必须是整数');
|
||
});
|
||
|
||
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
engineerId: ctx.fixtures.users.engineerAId,
|
||
items: [
|
||
{
|
||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||
targetPressure: '2',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, '压力值不在该植入物配置的挡位范围内');
|
||
});
|
||
|
||
it('失败:发布跨院设备任务返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
engineerId: ctx.fixtures.users.engineerAId,
|
||
items: [
|
||
{
|
||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||
});
|
||
|
||
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/tasks/publish role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 400,
|
||
[Role.HOSPITAL_ADMIN]: 400,
|
||
[Role.DIRECTOR]: 400,
|
||
[Role.LEADER]: 400,
|
||
[Role.DOCTOR]: 400,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (_role, token) =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({}),
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer()).post('/b/tasks/publish').send({}),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('POST /b/tasks/accept', () => {
|
||
it('失败:ENGINEER 接收接口已停用,返回 403', async () => {
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA2Id,
|
||
'1.5',
|
||
);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectErrorEnvelope(
|
||
response,
|
||
403,
|
||
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
|
||
);
|
||
});
|
||
|
||
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/tasks/accept role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 403,
|
||
[Role.HOSPITAL_ADMIN]: 403,
|
||
[Role.DIRECTOR]: 403,
|
||
[Role.LEADER]: 403,
|
||
[Role.DOCTOR]: 403,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (_role, token) =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({ taskId: 99999999 }),
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.send({ taskId: 99999999 }),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('POST /b/tasks/complete', () => {
|
||
it('成功:ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
|
||
const targetPressure = '1.5';
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA1Id,
|
||
targetPressure,
|
||
);
|
||
|
||
const completeResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectSuccessEnvelope(completeResponse, 201);
|
||
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
|
||
|
||
const device = await ctx.prisma.device.findUnique({
|
||
where: { id: ctx.fixtures.devices.deviceA1Id },
|
||
select: { currentPressure: true },
|
||
});
|
||
expect(device?.currentPressure).toBe(targetPressure);
|
||
});
|
||
|
||
it('失败:完成不存在任务返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: 99999999 });
|
||
|
||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||
});
|
||
|
||
it('状态机失败:重复完成返回 409', async () => {
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA2Id,
|
||
'1',
|
||
);
|
||
|
||
const firstComplete = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
expectSuccessEnvelope(firstComplete, 201);
|
||
|
||
const secondComplete = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
|
||
});
|
||
|
||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/tasks/complete role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 403,
|
||
[Role.HOSPITAL_ADMIN]: 403,
|
||
[Role.DIRECTOR]: 403,
|
||
[Role.LEADER]: 403,
|
||
[Role.DOCTOR]: 403,
|
||
[Role.ENGINEER]: 404,
|
||
},
|
||
sendAsRole: async (_role, token) =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({ taskId: 99999999 }),
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.send({ taskId: 99999999 }),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('POST /b/tasks/cancel', () => {
|
||
it('成功:DOCTOR 可取消自己创建的已指派任务', async () => {
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA3Id,
|
||
'1.5',
|
||
);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
|
||
});
|
||
|
||
it('失败:取消不存在任务返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({ taskId: 99999999 });
|
||
|
||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||
});
|
||
|
||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||
const task = await publishAssignedTask(
|
||
ctx.fixtures.devices.deviceA2Id,
|
||
'1.5',
|
||
);
|
||
|
||
const completeResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
expectSuccessEnvelope(completeResponse, 201);
|
||
|
||
const cancelResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消');
|
||
});
|
||
|
||
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/tasks/cancel role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 404,
|
||
[Role.HOSPITAL_ADMIN]: 404,
|
||
[Role.DIRECTOR]: 404,
|
||
[Role.LEADER]: 404,
|
||
[Role.DOCTOR]: 404,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (_role, token) =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.send({ taskId: 99999999 }),
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.send({ taskId: 99999999 }),
|
||
});
|
||
});
|
||
});
|
||
});
|