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('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 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]: 403, }, 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((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')); }); }); }