diff --git a/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx index d3b0f343..d5c2fbf1 100644 --- a/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx +++ b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx @@ -40,53 +40,77 @@ describe("HostUniversityTab image uploads", () => { totalElements: 0, totalPages: 0, }); + vi.stubGlobal("URL", { + createObjectURL: vi.fn(() => "blob:mock-url"), + revokeObjectURL: vi.fn(), + }); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); toastError.mockReset(); toastSuccess.mockReset(); }); - it("uploads a selected logo with formatName and writes the returned URL", async () => { - const upload = vi - .spyOn(adminApi, "uploadAdminUniversityLogo") - .mockResolvedValue({ fileUrl: "admin/logo/test.webp" }); + it("shows logo preview immediately after file selection without calling the API", async () => { await openCreateModal(); - fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "university_of_test" } }); const file = new File(["logo"], "logo.png", { type: "image/png" }); fireEvent.change(screen.getByLabelText("로고 이미지 파일"), { target: { files: [file] } }); - await waitFor(() => expect(upload).toHaveBeenCalledWith(file, "university_of_test")); - await waitFor(() => - expect((screen.getByLabelText("로고 이미지 URL *") as HTMLInputElement).value).toBe("admin/logo/test.webp"), - ); - expect(screen.getByRole("img", { name: "로고 미리보기" }).getAttribute("src")).toBe( - "https://cdn.upload.solid-connection.com/admin/logo/test.webp", - ); + await waitFor(() => expect(screen.getByRole("img", { name: "로고 미리보기" })).toBeTruthy()); + expect(toastError).not.toHaveBeenCalled(); }); - it("does not upload when formatName is blank", async () => { - const upload = vi.spyOn(adminApi, "uploadAdminUniversityLogo"); + it("shows background preview immediately after file selection without calling the API", async () => { await openCreateModal(); - const file = new File(["logo"], "logo.png", { type: "image/png" }); - fireEvent.change(screen.getByLabelText("로고 이미지 파일"), { target: { files: [file] } }); + const file = new File(["bg"], "background.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("배경 이미지 파일"), { target: { files: [file] } }); - expect(upload).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledWith("표시명을 먼저 입력해 주세요."); + await waitFor(() => expect(screen.getByRole("img", { name: "배경 미리보기" })).toBeTruthy()); + expect(toastError).not.toHaveBeenCalled(); }); - it("preserves the current background URL when upload fails", async () => { - vi.spyOn(adminApi, "uploadAdminUniversityBackground").mockRejectedValue(new Error("업로드 실패")); + it("shows an error toast and does not submit when files are not selected on create", async () => { + const create = vi.spyOn(adminApi, "createHostUniversity").mockResolvedValue({} as never); await openCreateModal(); - fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "university_of_test" } }); - const urlInput = screen.getByLabelText("배경 이미지 URL *") as HTMLInputElement; - fireEvent.change(urlInput, { target: { value: "existing/background.webp" } }); - const file = new File(["background"], "background.png", { type: "image/png" }); - fireEvent.change(screen.getByLabelText("배경 이미지 파일"), { target: { files: [file] } }); - await waitFor(() => expect(toastError).toHaveBeenCalledWith("업로드 실패")); - expect(urlInput.value).toBe("existing/background.webp"); + fireEvent.change(screen.getByLabelText("한글명 *"), { target: { value: "테스트 대학교" } }); + fireEvent.change(screen.getByLabelText("영문명 *"), { target: { value: "Test University" } }); + fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "Test U" } }); + fireEvent.change(screen.getByLabelText("국가코드 *"), { target: { value: "JP" } }); + fireEvent.change(screen.getByLabelText("권역코드 *"), { target: { value: "ASIA" } }); + + fireEvent.click(screen.getByRole("button", { name: "생성" })); + + await waitFor(() => expect(toastError).toHaveBeenCalledWith("로고 및 배경 이미지를 모두 선택해 주세요.")); + expect(create).not.toHaveBeenCalled(); + }); + + it("calls createHostUniversity with form data and selected files on submit", async () => { + const create = vi.spyOn(adminApi, "createHostUniversity").mockResolvedValue({} as never); + await openCreateModal(); + + fireEvent.change(screen.getByLabelText("한글명 *"), { target: { value: "테스트 대학교" } }); + fireEvent.change(screen.getByLabelText("영문명 *"), { target: { value: "Test University" } }); + fireEvent.change(screen.getByLabelText("표시명 *"), { target: { value: "Test U" } }); + fireEvent.change(screen.getByLabelText("국가코드 *"), { target: { value: "JP" } }); + fireEvent.change(screen.getByLabelText("권역코드 *"), { target: { value: "ASIA" } }); + + const logoFile = new File(["logo"], "logo.png", { type: "image/png" }); + const backgroundFile = new File(["bg"], "bg.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("로고 이미지 파일"), { target: { files: [logoFile] } }); + fireEvent.change(screen.getByLabelText("배경 이미지 파일"), { target: { files: [backgroundFile] } }); + + fireEvent.click(screen.getByRole("button", { name: "생성" })); + + await waitFor(() => + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ koreanName: "테스트 대학교", countryCode: "JP" }), + logoFile, + backgroundFile, + ), + ); }); }); diff --git a/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx index 9a2240fd..b2f6e675 100644 --- a/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx +++ b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.tsx @@ -1,8 +1,8 @@ "use client"; import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ImageIcon, Loader2, Upload } from "lucide-react"; -import { type FormEvent, useId, useRef, useState } from "react"; +import { ImageIcon, Upload } from "lucide-react"; +import { type FormEvent, useEffect, useId, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -19,23 +19,18 @@ import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl"; type ModalState = { open: false } | { open: true; mode: "create" } | { open: true; mode: "edit"; id: number }; -const REQUIRED_FIELDS = [ - "koreanName", - "englishName", - "formatName", - "logoImageUrl", - "backgroundImageUrl", - "countryCode", - "regionCode", -] as const; +interface HostUniversityFormState extends HostUniversityPayload { + logoImageUrl: string; + backgroundImageUrl: string; +} + +const REQUIRED_FIELDS = ["koreanName", "englishName", "formatName", "countryCode", "regionCode"] as const; const OPTIONAL_FIELDS = ["homepageUrl", "englishCourseUrl", "accommodationUrl"] as const; -const FIELD_LABELS: Record = { +const FIELD_LABELS: Record = { koreanName: "한글명", englishName: "영문명", formatName: "표시명", - logoImageUrl: "로고 이미지 URL", - backgroundImageUrl: "배경 이미지 URL", countryCode: "국가코드", regionCode: "권역코드", homepageUrl: "홈페이지 URL", @@ -44,7 +39,7 @@ const FIELD_LABELS: Record = { detailsForLocal: "상세 설명", }; -const EMPTY_FORM: HostUniversityPayload = { +const EMPTY_FORM: HostUniversityFormState = { koreanName: "", englishName: "", formatName: "", @@ -58,7 +53,7 @@ const EMPTY_FORM: HostUniversityPayload = { detailsForLocal: "", }; -function detailToForm(detail: HostUniversityDetailResponse): HostUniversityPayload { +function detailToForm(detail: HostUniversityDetailResponse): HostUniversityFormState { return { koreanName: detail.koreanName, englishName: detail.englishName, @@ -74,9 +69,13 @@ function detailToForm(detail: HostUniversityDetailResponse): HostUniversityPaylo }; } -function toPayload(form: HostUniversityPayload): HostUniversityPayload { +function toPayload(form: HostUniversityFormState): HostUniversityPayload { return { - ...form, + koreanName: form.koreanName, + englishName: form.englishName, + formatName: form.formatName, + countryCode: form.countryCode, + regionCode: form.regionCode, homepageUrl: form.homepageUrl || undefined, englishCourseUrl: form.englishCourseUrl || undefined, accommodationUrl: form.accommodationUrl || undefined, @@ -94,11 +93,37 @@ export function HostUniversityTab() { const [searchParams, setSearchParams] = useState({ keyword: "", countryCode: "", regionCode: "", page: 0 }); const [modal, setModal] = useState({ open: false }); - const [form, setForm] = useState(EMPTY_FORM); + const [form, setForm] = useState(EMPTY_FORM); + const [logoFile, setLogoFile] = useState(null); + const [backgroundFile, setBackgroundFile] = useState(null); + const [logoPreviewUrl, setLogoPreviewUrl] = useState(""); + const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState(""); const pendingEditIdRef = useRef(null); + useEffect(() => { + if (!logoFile) { + setLogoPreviewUrl(""); + return; + } + const url = URL.createObjectURL(logoFile); + setLogoPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [logoFile]); + + useEffect(() => { + if (!backgroundFile) { + setBackgroundPreviewUrl(""); + return; + } + const url = URL.createObjectURL(backgroundFile); + setBackgroundPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [backgroundFile]); + const closeModal = () => { pendingEditIdRef.current = null; + setLogoFile(null); + setBackgroundFile(null); setModal({ open: false }); }; @@ -111,7 +136,8 @@ export function HostUniversityTab() { const invalidate = () => queryClient.invalidateQueries({ queryKey: ["admin", "host-universities"] }); const createMutation = useMutation({ - mutationFn: (data: HostUniversityPayload) => adminApi.createHostUniversity(data), + mutationFn: ({ data, logo, background }: { data: HostUniversityPayload; logo: File; background: File }) => + adminApi.createHostUniversity(data, logo, background), onSuccess: async () => { await invalidate(); closeModal(); @@ -124,7 +150,17 @@ export function HostUniversityTab() { }); const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: HostUniversityPayload }) => adminApi.updateHostUniversity(id, data), + mutationFn: ({ + id, + data, + logo, + background, + }: { + id: number; + data: HostUniversityPayload; + logo?: File | null; + background?: File | null; + }) => adminApi.updateHostUniversity(id, data, logo, background), onSuccess: async () => { await invalidate(); closeModal(); @@ -148,32 +184,6 @@ export function HostUniversityTab() { }, }); - const logoUploadMutation = useMutation({ - mutationFn: ({ file, englishName }: { file: File; englishName: string }) => - adminApi.uploadAdminUniversityLogo(file, englishName), - onSuccess: ({ fileUrl }) => { - setForm((prev) => ({ ...prev, logoImageUrl: fileUrl })); - toast.success("로고 이미지를 업로드했습니다."); - }, - onError: (e: unknown) => { - const msg = e instanceof Error ? e.message : "로고 이미지 업로드에 실패했습니다."; - toast.error(msg); - }, - }); - - const backgroundUploadMutation = useMutation({ - mutationFn: ({ file, englishName }: { file: File; englishName: string }) => - adminApi.uploadAdminUniversityBackground(file, englishName), - onSuccess: ({ fileUrl }) => { - setForm((prev) => ({ ...prev, backgroundImageUrl: fileUrl })); - toast.success("배경 이미지를 업로드했습니다."); - }, - onError: (e: unknown) => { - const msg = e instanceof Error ? e.message : "배경 이미지 업로드에 실패했습니다."; - toast.error(msg); - }, - }); - const handleSearch = (e: FormEvent) => { e.preventDefault(); setSearchParams({ keyword, countryCode, regionCode, page: 0 }); @@ -182,11 +192,15 @@ export function HostUniversityTab() { const handleOpenCreate = () => { pendingEditIdRef.current = null; setForm(EMPTY_FORM); + setLogoFile(null); + setBackgroundFile(null); setModal({ open: true, mode: "create" }); }; const handleOpenEdit = (univ: HostUniversityResponse) => { pendingEditIdRef.current = univ.id; + setLogoFile(null); + setBackgroundFile(null); const cached = detailMap.get(univ.id); if (cached) { setForm(detailToForm(cached)); @@ -210,33 +224,31 @@ export function HostUniversityTab() { deleteMutation.mutate(id); }; - const uploadImage = (kind: "logo" | "background", file: File | undefined) => { + const handleLogoChange = (file: File | undefined) => { if (!file) return; - if (!form.formatName.trim()) { - toast.error("표시명을 먼저 입력해 주세요."); - return; - } + setLogoFile(file); + }; - const variables = { file, englishName: form.formatName }; - if (kind === "logo") { - logoUploadMutation.mutate(variables); - } else { - backgroundUploadMutation.mutate(variables); - } + const handleBackgroundChange = (file: File | undefined) => { + if (!file) return; + setBackgroundFile(file); }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); const payload = toPayload(form); if (modal.open && modal.mode === "edit") { - updateMutation.mutate({ id: modal.id, data: payload }); + updateMutation.mutate({ id: modal.id, data: payload, logo: logoFile, background: backgroundFile }); } else { - createMutation.mutate(payload); + if (!logoFile || !backgroundFile) { + toast.error("로고 및 배경 이미지를 모두 선택해 주세요."); + return; + } + createMutation.mutate({ data: payload, logo: logoFile, background: backgroundFile }); } }; const isMutating = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; - const isUploading = logoUploadMutation.isPending || backgroundUploadMutation.isPending; const universities = query.data?.content ?? []; const totalPages = query.data?.totalPages ?? 0; const currentPage = searchParams.page; @@ -439,100 +451,81 @@ export function HostUniversityTab() { onChange={(e) => setForm((prev) => ({ ...prev, [field]: e.target.value }))} required /> - {field === "logoImageUrl" && ( -
- {form.logoImageUrl ? ( - 로고 미리보기 - ) : ( -
- -
- )} - +
+ ))} + +
+

로고 이미지{modal.mode === "create" ? " *" : ""}

+
+ {logoPreviewUrl || form.logoImageUrl ? ( + 로고 미리보기 + ) : ( +
+
)} - {field === "backgroundImageUrl" && ( -
- {form.backgroundImageUrl ? ( - 배경 미리보기 - ) : ( -
- -
- )} - + +
+
+ +
+

배경 이미지{modal.mode === "create" ? " *" : ""}

+
+ {backgroundPreviewUrl || form.backgroundImageUrl ? ( + 배경 미리보기 + ) : ( +
+
)} +
- ))} +
+ {OPTIONAL_FIELDS.map((field) => (
diff --git a/apps/admin/src/lib/api/admin.test.ts b/apps/admin/src/lib/api/admin.test.ts index f58f548a..a81bd1b9 100644 --- a/apps/admin/src/lib/api/admin.test.ts +++ b/apps/admin/src/lib/api/admin.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { post } = vi.hoisted(() => ({ post: vi.fn() })); +const { post, put } = vi.hoisted(() => ({ post: vi.fn(), put: vi.fn() })); vi.mock("@/lib/api/client", () => ({ axiosInstance: { get: vi.fn(), post, - put: vi.fn(), + put, patch: vi.fn(), delete: vi.fn(), }, @@ -14,29 +14,67 @@ vi.mock("@/lib/api/client", () => ({ import { adminApi } from "./admin"; -describe("admin university image uploads", () => { +describe("admin host university multipart API", () => { beforeEach(() => { post.mockReset(); + put.mockReset(); }); - it.each([ - ["logo", "/file/admin/university/logo"], - ["background", "/file/admin/university/background"], - ] as const)("uploads the %s with formatName under the existing englishName wire key", async (kind, endpoint) => { - post.mockResolvedValue({ data: { fileUrl: `admin/${kind}/image.webp` } }); - const file = new File(["image"], `${kind}.png`, { type: "image/png" }); - - const result = - kind === "logo" - ? await adminApi.uploadAdminUniversityLogo(file, "university_of_test") - : await adminApi.uploadAdminUniversityBackground(file, "university_of_test"); - - expect(post).toHaveBeenCalledWith(endpoint, expect.any(FormData), { - headers: { "Content-Type": "multipart/form-data" }, - }); + it("createHostUniversity sends multipart/form-data with request JSON blob and files", async () => { + post.mockResolvedValue({ data: { id: 1, koreanName: "테스트 대학교" } }); + const logoFile = new File(["logo"], "logo.png", { type: "image/png" }); + const backgroundFile = new File(["bg"], "bg.png", { type: "image/png" }); + const request = { + koreanName: "테스트 대학교", + englishName: "Test Univ", + formatName: "Test U", + countryCode: "JP", + regionCode: "ASIA", + }; + + await adminApi.createHostUniversity(request, logoFile, backgroundFile); + + expect(post).toHaveBeenCalledWith("/admin/host-universities", expect.any(FormData)); const formData = post.mock.calls[0]?.[1] as FormData; - expect(formData.get("file")).toBe(file); - expect(formData.get("englishName")).toBe("university_of_test"); - expect(result).toEqual({ fileUrl: `admin/${kind}/image.webp` }); + expect(formData.get("logoFile")).toBe(logoFile); + expect(formData.get("backgroundFile")).toBe(backgroundFile); + const requestBlob = formData.get("request") as Blob; + expect(requestBlob.type).toBe("application/json"); + }); + + it("updateHostUniversity sends multipart/form-data with optional files", async () => { + put.mockResolvedValue({ data: { id: 1 } }); + const logoFile = new File(["logo"], "logo.png", { type: "image/png" }); + const request = { + koreanName: "변경된 대학교", + englishName: "Changed Univ", + formatName: "Changed U", + countryCode: "KR", + regionCode: "ASIA", + }; + + await adminApi.updateHostUniversity(1, request, logoFile, null); + + expect(put).toHaveBeenCalledWith("/admin/host-universities/1", expect.any(FormData)); + const formData = put.mock.calls[0]?.[1] as FormData; + expect(formData.get("logoFile")).toBe(logoFile); + expect(formData.get("backgroundFile")).toBeNull(); + }); + + it("updateHostUniversity omits both file parts when no files are provided", async () => { + put.mockResolvedValue({ data: { id: 1 } }); + const request = { + koreanName: "대학교", + englishName: "University", + formatName: "U", + countryCode: "JP", + regionCode: "ASIA", + }; + + await adminApi.updateHostUniversity(1, request); + + const formData = put.mock.calls[0]?.[1] as FormData; + expect(formData.get("logoFile")).toBeNull(); + expect(formData.get("backgroundFile")).toBeNull(); }); }); diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts index 1e1a48ad..b79fafe2 100644 --- a/apps/admin/src/lib/api/admin.ts +++ b/apps/admin/src/lib/api/admin.ts @@ -122,8 +122,6 @@ export interface HostUniversityPayload { koreanName: string; englishName: string; formatName: string; - logoImageUrl: string; - backgroundImageUrl: string; countryCode: string; regionCode: string; homepageUrl?: string; @@ -132,10 +130,6 @@ export interface HostUniversityPayload { detailsForLocal?: string; } -export interface AdminUniversityImageUploadResponse { - fileUrl: string; -} - export interface UnivApplyInfoLanguageRequirement { languageTestType: string; minScore: string; @@ -339,37 +333,34 @@ export const adminApi = { getHostUniversity: (id: number) => axiosInstance.get(`/admin/host-universities/${id}`).then((res) => res.data), - createHostUniversity: (data: HostUniversityPayload) => - axiosInstance.post("/admin/host-universities", data).then((res) => res.data), - - updateHostUniversity: (id: number, data: HostUniversityPayload) => - axiosInstance.put(`/admin/host-universities/${id}`, data).then((res) => res.data), - - deleteHostUniversity: (id: number) => - axiosInstance.delete(`/admin/host-universities/${id}`).then((res) => res.data), - - uploadAdminUniversityLogo: (file: File, englishName: string) => { + createHostUniversity: (data: HostUniversityPayload, logoFile: File, backgroundFile: File) => { const formData = new FormData(); - formData.append("file", file); - formData.append("englishName", englishName); + formData.append("request", new Blob([JSON.stringify(data)], { type: "application/json" })); + formData.append("logoFile", logoFile); + formData.append("backgroundFile", backgroundFile); return axiosInstance - .post("/file/admin/university/logo", formData, { - headers: { "Content-Type": "multipart/form-data" }, - }) + .post("/admin/host-universities", formData) .then((res) => res.data); }, - uploadAdminUniversityBackground: (file: File, englishName: string) => { + updateHostUniversity: ( + id: number, + data: HostUniversityPayload, + logoFile?: File | null, + backgroundFile?: File | null, + ) => { const formData = new FormData(); - formData.append("file", file); - formData.append("englishName", englishName); + formData.append("request", new Blob([JSON.stringify(data)], { type: "application/json" })); + if (logoFile) formData.append("logoFile", logoFile); + if (backgroundFile) formData.append("backgroundFile", backgroundFile); return axiosInstance - .post("/file/admin/university/background", formData, { - headers: { "Content-Type": "multipart/form-data" }, - }) + .put(`/admin/host-universities/${id}`, formData) .then((res) => res.data); }, + deleteHostUniversity: (id: number) => + axiosInstance.delete(`/admin/host-universities/${id}`).then((res) => res.data), + createUnivApplyInfo: (data: UnivApplyInfoCreatePayload) => axiosInstance.post("/admin/univ-apply-infos", data).then((res) => res.data),