diff --git a/components/admin/media-uploader.test.ts b/components/admin/media-uploader.test.ts new file mode 100644 index 0000000..c8d7dc9 --- /dev/null +++ b/components/admin/media-uploader.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +vi.mock("sonner", () => ({ toast: mocks })); +import { MediaUploader } from "./media-uploader"; + +let container: HTMLDivElement; +let root: Root; +const fetchUpload = vi.fn(); +const onReady = vi.fn(); +const onUploadingChange = vi.fn(); + +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("fetch", fetchUpload); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render(createElement(MediaUploader, { onReady, onUploadingChange }))); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +async function selectImage() { + const input = container.querySelector("input")!; + Object.defineProperty(input, "files", { configurable: true, value: [new File(["image"], "test.png", { type: "image/png" })] }); + await act(async () => input.dispatchEvent(new Event("change", { bubbles: true }))); +} + +describe("media upload responses", () => { + it("reports HTTP 503 when the network replaces the API error with HTML", async () => { + fetchUpload.mockResolvedValueOnce(new Response("503 Service Unavailable", { + status: 503, headers: { "Content-Type": "text/html" }, + })); + await selectImage(); + expect(mocks.error).toHaveBeenCalledWith(expect.stringContaining("HTTP 503")); + expect(onReady).not.toHaveBeenCalled(); + expect(onUploadingChange.mock.calls).toEqual([[true], [false]]); + expect(container.querySelector("input")?.disabled).toBe(false); + }); + + it("preserves JSON API error details", async () => { + fetchUpload.mockResolvedValueOnce(Response.json({ error: "uploads-busy" }, { status: 429 })); + await selectImage(); + expect(mocks.error).toHaveBeenCalledWith("uploads-busy"); + expect(onReady).not.toHaveBeenCalled(); + expect(container.querySelector("input")?.disabled).toBe(false); + }); + + it("passes a successful upload to the editor", async () => { + const media = { id: "media", url: "https://cdn.test/image.png", fileName: "test.png", width: 1, height: 1 }; + fetchUpload.mockResolvedValueOnce(Response.json(media, { status: 201 })); + await selectImage(); + expect(fetchUpload).toHaveBeenCalledWith("/api/admin/media", expect.objectContaining({ method: "POST", body: expect.any(FormData) })); + expect(onReady).toHaveBeenCalledExactlyOnceWith(media); + expect(mocks.success).toHaveBeenCalledTimes(1); + expect(onUploadingChange.mock.calls).toEqual([[true], [false]]); + }); +}); diff --git a/components/admin/media-uploader.tsx b/components/admin/media-uploader.tsx index e82f31f..be31f54 100644 --- a/components/admin/media-uploader.tsx +++ b/components/admin/media-uploader.tsx @@ -49,8 +49,11 @@ export function MediaUploader({ method: "POST", body, }); - const result = await response.json() as UploadedMedia & { error?: string }; - if (!response.ok) throw new Error(result.error || "upload"); + if (!response.ok) { + const error = await response.json().catch(() => null) as { error?: string } | null; + throw new Error(error?.error || `อัปโหลดไม่สำเร็จ (HTTP ${response.status}) กรุณาลองอีกครั้ง`); + } + const result = await response.json() as UploadedMedia; onReady(result); toast.success("อัปโหลดรูปภาพแล้ว"); } catch (cause) {