新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。 发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。 完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。 植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。 患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。 增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
import { spawn } from 'node:child_process';
|
||
import { mkdtemp, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import ffmpegPath from 'ffmpeg-static';
|
||
import sharp from 'sharp';
|
||
import request from 'supertest';
|
||
import { 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,
|
||
} from '../helpers/e2e-http.helper.js';
|
||
|
||
describe('BUploadsController (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
let tempDir = '';
|
||
let sampleVideoPath = '';
|
||
let samplePngBuffer: Buffer;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
tempDir = await mkdtemp(join(tmpdir(), 'upload-assets-e2e-'));
|
||
sampleVideoPath = join(tempDir, 'ct-video.mov');
|
||
await createSampleVideo(sampleVideoPath);
|
||
samplePngBuffer = await sharp({
|
||
create: {
|
||
width: 32,
|
||
height: 32,
|
||
channels: 3,
|
||
background: { r: 24, g: 46, b: 82 },
|
||
},
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('成功:HOSPITAL_ADMIN 可上传图片', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'ct-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.type).toBe('IMAGE');
|
||
expect(response.body.data.mimeType).toBe('image/webp');
|
||
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-image\.webp$/);
|
||
expect(response.body.data.storagePath).toMatch(
|
||
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||
);
|
||
expect(response.body.data.url).toMatch(
|
||
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||
);
|
||
expect(response.body.data.hospital.id).toBe(ctx.fixtures.hospitalAId);
|
||
});
|
||
|
||
it('成功:HOSPITAL_ADMIN 可上传视频并转为 mp4', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', sampleVideoPath, {
|
||
filename: 'ct-video.mov',
|
||
contentType: 'video/quicktime',
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.type).toBe('VIDEO');
|
||
expect(response.body.data.mimeType).toBe('video/mp4');
|
||
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-video\.mp4$/);
|
||
expect(response.body.data.storagePath).toMatch(
|
||
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||
);
|
||
expect(response.body.data.url).toMatch(
|
||
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||
);
|
||
});
|
||
|
||
it('失败:SYSTEM_ADMIN 上传文件时未指定医院返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'ct-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
expectErrorEnvelope(
|
||
response,
|
||
400,
|
||
'系统管理员上传文件时必须显式指定 hospitalId',
|
||
);
|
||
});
|
||
|
||
it('失败:DIRECTOR 查询影像库返回 403', async () => {
|
||
await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'seed-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||
|
||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||
});
|
||
|
||
it('角色矩阵:上传允许系统/医院/主任/组长/医生/工程师,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/uploads role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 400,
|
||
[Role.HOSPITAL_ADMIN]: 201,
|
||
[Role.DIRECTOR]: 201,
|
||
[Role.LEADER]: 201,
|
||
[Role.DOCTOR]: 201,
|
||
[Role.ENGINEER]: 201,
|
||
},
|
||
sendAsRole: async (role, token) => {
|
||
const req = request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: `${role.toLowerCase()}.png`,
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
if (role === Role.SYSTEM_ADMIN) {
|
||
return req;
|
||
}
|
||
return req;
|
||
},
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'anonymous.png',
|
||
contentType: 'image/png',
|
||
}),
|
||
});
|
||
});
|
||
|
||
it('角色矩阵:上传列表仅系统/医院管理员可访问,其他角色 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'GET /b/uploads role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 200,
|
||
[Role.HOSPITAL_ADMIN]: 200,
|
||
[Role.DIRECTOR]: 403,
|
||
[Role.LEADER]: 403,
|
||
[Role.DOCTOR]: 403,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (role, token) => {
|
||
const req = request(ctx.app.getHttpServer())
|
||
.get('/b/uploads')
|
||
.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/uploads'),
|
||
});
|
||
});
|
||
});
|
||
|
||
async function createSampleVideo(outputPath: string) {
|
||
const command = ffmpegPath as unknown as string | null;
|
||
if (!command) {
|
||
throw new Error('ffmpeg-static not available');
|
||
}
|
||
|
||
const args = [
|
||
'-y',
|
||
'-f',
|
||
'lavfi',
|
||
'-i',
|
||
'color=c=black:s=320x240:d=1',
|
||
'-f',
|
||
'lavfi',
|
||
'-i',
|
||
'anullsrc=r=44100:cl=stereo',
|
||
'-shortest',
|
||
'-c:v',
|
||
'libx264',
|
||
'-pix_fmt',
|
||
'yuv420p',
|
||
'-c:a',
|
||
'aac',
|
||
'-movflags',
|
||
'+faststart',
|
||
outputPath,
|
||
];
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const child = spawn(command, args);
|
||
let stderr = '';
|
||
|
||
child.stderr?.on('data', (chunk) => {
|
||
stderr += String(chunk);
|
||
});
|
||
child.on('error', reject);
|
||
child.on('close', (code) => {
|
||
if (code === 0) {
|
||
resolve();
|
||
return;
|
||
}
|
||
reject(new Error(stderr.trim() || 'failed to create sample video'));
|
||
});
|
||
});
|
||
}
|