tyt-api-nest/test/e2e/specs/tasks.e2e-spec.ts
EL 2bfe8ac8c8 新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理
新增 B 端上传接口与列表接口,统一文件上传和分页查询能力
上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离
图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储
增加上传目录与公开访问能力,统一输出可直接预览的访问地址
前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问
前端新增通用上传组件,支持在页面内复用并返回上传结果
管理后台新增影像库菜单与路由,并补充页面级角色权限控制
患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填
新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景
补充上传模块文档与安装依赖说明,完善工程内使用说明
2026-03-20 04:35:43 +08:00

457 lines
15 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 { 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 }),
});
});
});
});