From 9038d86308754c1d2bec4a85e39ec72eb1d30e4a Mon Sep 17 00:00:00 2001 From: hyejj19 Date: Thu, 26 Mar 2026 10:30:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]=20=EB=B0=B0=EB=84=88=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=ED=83=80=EC=9E=85=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?(IMAGE/VIDEO)=20=EB=B0=8F=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배너 API에 추가된 mediaType 필드를 프론트엔드 전 계층에 반영하고, 동영상(MP4) 업로드 및 미리보기 기능을 구현한다. - types: MediaType 타입, presigned URL contentType 파라미터 추가 - schema: 폼 스키마에 mediaType 필드 추가 (기본값: IMAGE) - service: S3 presigned URL 요청 시 contentType 전달 - form: 파일 업로드 시 file.type 기반 mediaType 자동 판별 - ImageUpload: 이미지/동영상 accept, 거부 콜백, 미리보기 분기 - BannerImageCard: 허용 외 파일 에러 토스트, mediaType 자동 설정 - BannerPreviewCard: form.mediaType 기반 비디오 미리보기 - fix: blob URL에서 useEffect가 isVideo를 덮어쓰는 버그 수정 - test: 유틸/컴포넌트/서비스/훅/E2E 테스트 51개 추가 (총 129개) Refs: bottle-note/workspace#205 Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/fixtures/test-video.mp4 | Bin 0 -> 36 bytes e2e/pages/banner-detail.page.ts | 20 +- e2e/specs/banners.spec.ts | 30 ++ src/components/common/ImageUpload.tsx | 84 ++++- .../common/__tests__/ImageUpload.test.tsx | 331 ++++++++++++++++++ src/hooks/__tests__/useBanners.test.ts | 73 ++++ src/pages/banners/BannerDetail.tsx | 8 + .../__tests__/BannerImageCard.test.tsx | 143 ++++++++ .../banners/__tests__/banner.schema.test.ts | 55 +++ src/pages/banners/banner.schema.ts | 6 +- .../banners/components/BannerImageCard.tsx | 40 ++- .../banners/components/BannerPreviewCard.tsx | 21 +- src/pages/banners/useBannerDetailForm.ts | 2 + src/services/__tests__/s3.service.test.ts | 155 ++++++++ src/services/s3.service.ts | 3 +- src/test/mocks/data.ts | 3 + src/types/api/banner.api.ts | 20 ++ src/types/api/s3.api.ts | 2 + 18 files changed, 971 insertions(+), 25 deletions(-) create mode 100644 e2e/fixtures/test-video.mp4 create mode 100644 src/components/common/__tests__/ImageUpload.test.tsx create mode 100644 src/pages/banners/__tests__/BannerImageCard.test.tsx create mode 100644 src/pages/banners/__tests__/banner.schema.test.ts create mode 100644 src/services/__tests__/s3.service.test.ts diff --git a/e2e/fixtures/test-video.mp4 b/e2e/fixtures/test-video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..eec7adcaefa750b4b9a83d5c93ffb2cb9853f135 GIT binary patch literal 36 ncmZQzV30{GsVvAW&d+6FU}6B#Kx~v-U}DI?z`&84pI-(5uK5Yt literal 0 HcmV?d00001 diff --git a/e2e/pages/banner-detail.page.ts b/e2e/pages/banner-detail.page.ts index 41c1a58..cdc065d 100644 --- a/e2e/pages/banner-detail.page.ts +++ b/e2e/pages/banner-detail.page.ts @@ -56,10 +56,14 @@ export class BannerDetailPage extends BasePage { readonly endDateInput = () => this.page.locator('input#endDate'); - readonly imageFileInput = () => this.page.locator('input[type="file"][accept="image/*"]'); + readonly mediaFileInput = () => this.page.locator('input[type="file"]'); readonly uploadedImage = () => this.page.locator('img[alt="업로드된 이미지"]'); + readonly uploadedVideo = () => this.page.locator('video'); + + readonly uploadedMedia = () => this.page.locator('img[alt="업로드된 이미지"], video'); + readonly deleteDialog = () => this.page.getByRole('alertdialog'); readonly confirmDeleteButton = () => this.deleteDialog().getByRole('button', { name: '삭제' }); @@ -148,13 +152,19 @@ export class BannerDetailPage extends BasePage { async uploadTestImage() { const testImagePath = path.resolve(__dirname, '../fixtures/test-image.png'); - await this.imageFileInput().setInputFiles(testImagePath); - await this.uploadedImage().waitFor({ state: 'visible', timeout: 10000 }); + await this.mediaFileInput().setInputFiles(testImagePath); + await this.uploadedMedia().waitFor({ state: 'visible', timeout: 10000 }); + } + + async uploadTestVideo() { + const testVideoPath = path.resolve(__dirname, '../fixtures/test-video.mp4'); + await this.mediaFileInput().setInputFiles(testVideoPath); + await this.uploadedVideo().waitFor({ state: 'visible', timeout: 10000 }); } async ensureImage() { - const hasImage = await this.uploadedImage().isVisible().catch(() => false); - if (!hasImage) { + const hasMedia = await this.uploadedMedia().isVisible().catch(() => false); + if (!hasMedia) { await this.uploadTestImage(); } } diff --git a/e2e/specs/banners.spec.ts b/e2e/specs/banners.spec.ts index da3c433..8a3bc97 100644 --- a/e2e/specs/banners.spec.ts +++ b/e2e/specs/banners.spec.ts @@ -232,6 +232,36 @@ test.describe('배너 CRUD 플로우', () => { }); }); +test.describe('배너 동영상 업로드', () => { + test('동영상 파일 업로드 시 비디오 미리보기가 표시된다', async ({ page }) => { + const detailPage = new BannerDetailPage(page); + + await detailPage.gotoNew(); + + // 동영상 업로드 + await detailPage.uploadTestVideo(); + + //