新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。 发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。 完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。 植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。 患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。 增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
717 lines
24 KiB
TypeScript
717 lines
24 KiB
TypeScript
import sharp from 'sharp';
|
||
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,
|
||
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('BTasksController (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
let samplePngBuffer: Buffer;
|
||
let doctorBToken = '';
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
samplePngBuffer = await sharp({
|
||
create: {
|
||
width: 32,
|
||
height: 32,
|
||
channels: 3,
|
||
background: { r: 42, g: 78, b: 126 },
|
||
},
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
doctorBToken = await loginByUser(
|
||
ctx.fixtures.users.doctorBId,
|
||
Role.DOCTOR,
|
||
ctx.fixtures.hospitalBId,
|
||
);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
});
|
||
|
||
async function publishPendingTask(
|
||
deviceId: number,
|
||
targetPressure: string,
|
||
actorToken = ctx.tokens[Role.DOCTOR],
|
||
) {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${actorToken}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId,
|
||
targetPressure,
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
return response.body.data as {
|
||
id: number;
|
||
status: TaskStatus;
|
||
engineerId: number | null;
|
||
hospitalId: number;
|
||
};
|
||
}
|
||
|
||
async function acceptPendingTask(
|
||
taskId: number,
|
||
actorToken = ctx.tokens[Role.ENGINEER],
|
||
) {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${actorToken}`)
|
||
.send({ taskId });
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
return response.body.data as {
|
||
id: number;
|
||
status: TaskStatus;
|
||
engineerId: number;
|
||
};
|
||
}
|
||
|
||
async function loginByUser(userId: number, role: Role, hospitalId: number) {
|
||
const user = await ctx.prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { phone: true },
|
||
});
|
||
expect(user?.phone).toBeTruthy();
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login')
|
||
.send({
|
||
phone: user?.phone,
|
||
password: 'Seed@1234',
|
||
role,
|
||
hospitalId,
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
return response.body.data.accessToken as string;
|
||
}
|
||
|
||
async function createAdjustableDevices(options?: {
|
||
actorToken?: string;
|
||
doctorId?: number;
|
||
count?: number;
|
||
patientName?: string;
|
||
projectName?: string;
|
||
}) {
|
||
const {
|
||
actorToken = ctx.tokens[Role.DOCTOR],
|
||
doctorId = ctx.fixtures.users.doctorAId,
|
||
count = 1,
|
||
patientName = '调压测试患者',
|
||
projectName = '调压测试项目',
|
||
} = options ?? {};
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/patients')
|
||
.set('Authorization', `Bearer ${actorToken}`)
|
||
.send({
|
||
name: `${patientName}-${uniqueSeedValue('name')}`,
|
||
inpatientNo: uniqueSeedValue('zyh'),
|
||
projectName,
|
||
phone: uniquePhone(),
|
||
idCard: uniqueIdCard(),
|
||
doctorId,
|
||
initialSurgery: {
|
||
surgeryDate: '2026-03-20T08:00:00.000Z',
|
||
surgeryName: '调压测试手术',
|
||
preOpPressure: 18,
|
||
primaryDisease: '交通性脑积水',
|
||
hydrocephalusTypes: ['交通性'],
|
||
devices: Array.from({ length: count }, (_, index) => ({
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: [index % 2 === 0 ? '额角' : '枕角'],
|
||
valvePlacementSites: [index % 2 === 0 ? '耳后' : '胸前'],
|
||
distalShuntDirection: '腹腔',
|
||
initialPressure: index % 2 === 0 ? '1' : '1.5',
|
||
implantNotes: uniqueSeedValue(`task-device-${index + 1}`),
|
||
})),
|
||
},
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
return response.body.data.devices as Array<{ id: number }>;
|
||
}
|
||
|
||
async function uploadEngineerProof(actorToken = ctx.tokens[Role.ENGINEER]) {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${actorToken}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: '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;
|
||
};
|
||
}
|
||
|
||
function buildCompletionPayload(
|
||
taskId: number,
|
||
asset: Awaited<ReturnType<typeof uploadEngineerProof>>,
|
||
) {
|
||
return {
|
||
taskId,
|
||
completionMaterials: [
|
||
{
|
||
assetId: asset.id,
|
||
type: asset.type,
|
||
url: asset.url,
|
||
name: asset.originalName || asset.fileName || '调压完成照片',
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
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 [deviceA] = await createAdjustableDevices();
|
||
const [deviceB] = await createAdjustableDevices({
|
||
actorToken: doctorBToken,
|
||
doctorId: ctx.fixtures.users.doctorBId,
|
||
patientName: 'B院调压患者',
|
||
});
|
||
|
||
await publishPendingTask(
|
||
deviceA.id,
|
||
'1.5',
|
||
ctx.tokens[Role.SYSTEM_ADMIN],
|
||
);
|
||
await publishPendingTask(
|
||
deviceB.id,
|
||
'1.5',
|
||
ctx.tokens[Role.SYSTEM_ADMIN],
|
||
);
|
||
|
||
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 } | null;
|
||
}) =>
|
||
Number.isInteger(item.creator?.id) && Boolean(item.creator?.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 [device] = await createAdjustableDevices();
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId: device.id,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||
expect(response.body.data.engineerId).toBeNull();
|
||
});
|
||
|
||
it('成功:SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(
|
||
device.id,
|
||
'1.5',
|
||
ctx.tokens[Role.SYSTEM_ADMIN],
|
||
);
|
||
|
||
expect(task.status).toBe(TaskStatus.PENDING);
|
||
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
|
||
});
|
||
|
||
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId: device.id,
|
||
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({
|
||
items: [
|
||
{
|
||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||
});
|
||
|
||
it('失败:已有待处理任务的设备不可重复发布', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
await publishPendingTask(device.id, '1.5');
|
||
|
||
const duplicateResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId: device.id,
|
||
targetPressure: '1',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectErrorEnvelope(duplicateResponse, 409, '该设备已有待处理调压任务');
|
||
});
|
||
|
||
it('成功:同一患者另一台设备无任务时仍可发布', async () => {
|
||
const createdDevices = await createAdjustableDevices({ count: 2 });
|
||
expect(createdDevices).toHaveLength(2);
|
||
|
||
await publishPendingTask(createdDevices[0].id, '1.5');
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
items: [
|
||
{
|
||
deviceId: createdDevices[1].id,
|
||
targetPressure: '1',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||
});
|
||
|
||
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 可接收本院待处理任务', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
|
||
const acceptedTask = await acceptPendingTask(task.id);
|
||
expect(acceptedTask.status).toBe(TaskStatus.ACCEPTED);
|
||
expect(acceptedTask.engineerId).toBe(ctx.fixtures.users.engineerAId);
|
||
});
|
||
|
||
it('失败:跨院工程师不能接收任务', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
const engineerB = await ctx.prisma.user.findUnique({
|
||
where: { id: ctx.fixtures.users.engineerBId },
|
||
select: { phone: true },
|
||
});
|
||
expect(engineerB?.phone).toBeTruthy();
|
||
|
||
const loginResponse = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login')
|
||
.send({
|
||
phone: engineerB?.phone,
|
||
password: 'Seed@1234',
|
||
role: Role.ENGINEER,
|
||
hospitalId: ctx.fixtures.hospitalBId,
|
||
});
|
||
expectSuccessEnvelope(loginResponse, 201);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${loginResponse.body.data.accessToken}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||
});
|
||
|
||
it('状态机失败:已被接收的任务不可再次接收', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
await acceptPendingTask(task.id);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectErrorEnvelope(response, 409, '仅待接收任务可执行接收');
|
||
});
|
||
|
||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 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]: 404,
|
||
},
|
||
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 [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, targetPressure);
|
||
await acceptPendingTask(task.id);
|
||
const proof = await uploadEngineerProof();
|
||
|
||
const completeResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send(buildCompletionPayload(task.id, proof));
|
||
|
||
expectSuccessEnvelope(completeResponse, 201);
|
||
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
|
||
expect(completeResponse.body.data.completionMaterials).toEqual([
|
||
expect.objectContaining({
|
||
assetId: proof.id,
|
||
type: 'IMAGE',
|
||
url: proof.url,
|
||
}),
|
||
]);
|
||
|
||
const updatedDevice = await ctx.prisma.device.findUnique({
|
||
where: { id: device.id },
|
||
select: { currentPressure: true },
|
||
});
|
||
expect(updatedDevice?.currentPressure).toBe(targetPressure);
|
||
});
|
||
|
||
it('失败:完成不存在任务返回 404', async () => {
|
||
const proof = await uploadEngineerProof();
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send(buildCompletionPayload(99999999, proof));
|
||
|
||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||
});
|
||
|
||
it('失败:未上传完成凭证返回 400', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
await acceptPendingTask(task.id);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({
|
||
taskId: task.id,
|
||
completionMaterials: [],
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, 'completionMaterials 至少上传 1 项');
|
||
});
|
||
|
||
it('状态机失败:重复完成返回 409', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1');
|
||
await acceptPendingTask(task.id);
|
||
const proof = await uploadEngineerProof();
|
||
|
||
const firstComplete = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send(buildCompletionPayload(task.id, proof));
|
||
expectSuccessEnvelope(firstComplete, 201);
|
||
|
||
const secondComplete = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send(buildCompletionPayload(task.id, proof));
|
||
|
||
expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成');
|
||
});
|
||
|
||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||
const proof = await uploadEngineerProof();
|
||
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(buildCompletionPayload(99999999, proof)),
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.send(buildCompletionPayload(99999999, proof)),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('POST /b/tasks/cancel', () => {
|
||
it('成功:DOCTOR 可取消自己创建的待接收任务', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '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('成功:ENGINEER 可取消接收并退回待接收状态', async () => {
|
||
const [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
await acceptPendingTask(task.id);
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/cancel')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id, reason: '患者临时取消' });
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||
expect(response.body.data.engineerId).toBeNull();
|
||
|
||
const secondAcceptResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/accept')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: task.id });
|
||
|
||
expectSuccessEnvelope(secondAcceptResponse, 201);
|
||
expect(secondAcceptResponse.body.data.status).toBe(TaskStatus.ACCEPTED);
|
||
});
|
||
|
||
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 [device] = await createAdjustableDevices();
|
||
const task = await publishPendingTask(device.id, '1.5');
|
||
await acceptPendingTask(task.id);
|
||
const proof = await uploadEngineerProof();
|
||
|
||
const completeResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send(buildCompletionPayload(task.id, proof));
|
||
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]: 404,
|
||
},
|
||
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 }),
|
||
});
|
||
});
|
||
});
|
||
});
|