tyt-api-nest/test/e2e/specs/uploads.e2e-spec.ts
EL 0b5640a977 调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。
新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。
发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。
完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。
植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。
患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。
增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
2026-03-20 06:03:09 +08:00

231 lines
6.8 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 { 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'));
});
});
}