import { expect, test, type Page } from "@playwright/test"; const PNG_1X1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg=="; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } async function installOnboardingFixtures(page: Page, avatarUrl: string) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "e2e-image-resilience", email: "avatar.fixture@hs.ac.kr", role: "learner", display_name: "온보딩 사용자", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: null, onboarding_completed_at: null, nickname: "온보딩 사용자", self_introduction: "", avatar_url: avatarUrl, }), ), ); await page.route("**/api/users/me", (route) => route.fulfill( jsonRoute({ user_id: "e2e-image-resilience", email: "avatar.fixture@hs.ac.kr", role: "learner", display_name: "온보딩 사용자", legal_name: "", affiliation: "한신대학교", department: "", grade_level: "", phone: "", contact_address: "", nickname: "온보딩 사용자", self_introduction: "", avatar_url: avatarUrl, cohort_ids: [], }), ), ); await page.route("**/api/users/legal-docs", (route) => route.fulfill( jsonRoute({ terms: { kind: "terms", status: "draft", title: "서비스 이용약관", version: "image-resilience-fixture", body: "테스트 약관", }, privacy: { kind: "privacy", status: "draft", title: "개인정보 처리방침", version: "image-resilience-fixture", body: "테스트 개인정보 처리방침", }, source_note: "이미지 회복성 전용 fixture", }), ), ); } test.describe("사용자 이미지 회복성", () => { test("대문자 외부 HTTPS URL도 안전하게 정규화해 실제 이미지로 전환한다", async ({ page, }) => { const externalAvatar = "HTTPS://images.example.test/profile/avatar.png"; await installOnboardingFixtures(page, externalAvatar); await page.route("https://images.example.test/profile/avatar.png", (route) => route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from(PNG_1X1_BASE64, "base64"), }), ); await page.goto("/onboarding"); const preview = page.locator(".ob-avatar__preview"); const image = preview.locator("img"); await expect(image).toBeVisible(); await expect(image).toHaveAttribute( "src", "https://images.example.test/profile/avatar.png", ); await expect(preview.locator("[data-image-fallback]")).toHaveCount(0); }); test("온보딩 아바타 파일이 404여도 깨진 아이콘 대신 닉네임 이니셜을 표시한다", async ({ page, }) => { const staleAvatar = "/uploads/profile-avatars/stale-onboarding-avatar.jpg"; await installOnboardingFixtures(page, staleAvatar); await page.route("**/api/uploads/profile-avatars/stale-onboarding-avatar.jpg", (route) => route.fulfill(jsonRoute({ detail: "Not Found" }, 404)), ); await page.goto("/onboarding"); await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible(); const preview = page.locator(".ob-avatar__preview"); await expect(preview.locator("[data-image-fallback='failed']")).toHaveText("온"); await expect(preview.locator("img")).toHaveCount(0); }); test("지연된 이미지 응답 중에도 브라우저의 깨진 이미지 박스를 노출하지 않는다", async ({ page, }) => { const delayedAvatar = "/uploads/profile-avatars/delayed-onboarding-avatar.jpg"; let releaseImage: (() => void) | undefined; const imageGate = new Promise((resolve) => { releaseImage = resolve; }); await installOnboardingFixtures(page, delayedAvatar); await page.route("**/api/uploads/profile-avatars/delayed-onboarding-avatar.jpg", async (route) => { await imageGate; await route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from(PNG_1X1_BASE64, "base64"), }); }); await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); const preview = page.locator(".ob-avatar__preview"); await expect(preview.locator("[data-image-fallback='loading']")).toHaveText("온"); const pendingImage = preview.locator("img"); await expect(pendingImage).toHaveAttribute("data-image-state", "loading"); await expect(pendingImage).toBeHidden(); await expect(pendingImage).toHaveCSS("display", "none"); expect(await pendingImage.evaluate((image) => image.getBoundingClientRect().toJSON())).toMatchObject({ width: 0, height: 0, }); releaseImage?.(); await expect(pendingImage).toBeVisible(); await expect(pendingImage).toHaveAttribute("data-image-state", "ready"); await expect(preview.locator("[data-image-fallback]")).toHaveCount(0); }); });