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 new file mode 100644 index 00000000..d3b0f343 --- /dev/null +++ b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { adminApi } from "@/lib/api/admin"; +import { HostUniversityTab } from "./HostUniversityTab"; + +const { toastError, toastSuccess } = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +vi.mock("sonner", () => ({ toast: { error: toastError, success: toastSuccess } })); +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQueries: () => [] }; +}); + +function renderTab() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +async function openCreateModal() { + renderTab(); + fireEvent.click(await screen.findByRole("button", { name: "호스트 대학교 생성" })); +} + +describe("HostUniversityTab image uploads", () => { + beforeEach(() => { + vi.spyOn(adminApi, "getHostUniversities").mockResolvedValue({ + content: [], + page: 0, + size: 20, + totalElements: 0, + totalPages: 0, + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + 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" }); + 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", + ); + }); + + it("does not upload when formatName is blank", async () => { + const upload = vi.spyOn(adminApi, "uploadAdminUniversityLogo"); + await openCreateModal(); + const file = new File(["logo"], "logo.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("로고 이미지 파일"), { target: { files: [file] } }); + + expect(upload).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith("표시명을 먼저 입력해 주세요."); + }); + + it("preserves the current background URL when upload fails", async () => { + vi.spyOn(adminApi, "uploadAdminUniversityBackground").mockRejectedValue(new Error("업로드 실패")); + 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"); + }); +}); 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 034298c5..9a2240fd 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,6 +1,7 @@ "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 { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -13,6 +14,8 @@ import { type HostUniversityPayload, type HostUniversityResponse, } from "@/lib/api/admin"; +import { cn } from "@/lib/utils"; +import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl"; type ModalState = { open: false } | { open: true; mode: "create" } | { open: true; mode: "edit"; id: number }; @@ -145,6 +148,32 @@ 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 }); @@ -181,6 +210,21 @@ export function HostUniversityTab() { deleteMutation.mutate(id); }; + const uploadImage = (kind: "logo" | "background", file: File | undefined) => { + if (!file) return; + if (!form.formatName.trim()) { + toast.error("표시명을 먼저 입력해 주세요."); + return; + } + + const variables = { file, englishName: form.formatName }; + if (kind === "logo") { + logoUploadMutation.mutate(variables); + } else { + backgroundUploadMutation.mutate(variables); + } + }; + const handleSubmit = (e: FormEvent) => { e.preventDefault(); const payload = toPayload(form); @@ -192,6 +236,7 @@ export function HostUniversityTab() { }; 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; @@ -394,6 +439,98 @@ export function HostUniversityTab() { onChange={(e) => setForm((prev) => ({ ...prev, [field]: e.target.value }))} required /> + {field === "logoImageUrl" && ( +
+ {form.logoImageUrl ? ( + 로고 미리보기 + ) : ( +
+ +
+ )} + +
+ )} + {field === "backgroundImageUrl" && ( +
+ {form.backgroundImageUrl ? ( + 배경 미리보기 + ) : ( +
+ +
+ )} + +
+ )} ))} {OPTIONAL_FIELDS.map((field) => ( @@ -423,7 +560,7 @@ export function HostUniversityTab() { - diff --git a/apps/admin/src/lib/api/admin.test.ts b/apps/admin/src/lib/api/admin.test.ts new file mode 100644 index 00000000..f58f548a --- /dev/null +++ b/apps/admin/src/lib/api/admin.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { post } = vi.hoisted(() => ({ post: vi.fn() })); + +vi.mock("@/lib/api/client", () => ({ + axiosInstance: { + get: vi.fn(), + post, + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +import { adminApi } from "./admin"; + +describe("admin university image uploads", () => { + beforeEach(() => { + post.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" }, + }); + 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` }); + }); +}); diff --git a/apps/admin/src/lib/api/admin.ts b/apps/admin/src/lib/api/admin.ts index 4bd8823c..1e1a48ad 100644 --- a/apps/admin/src/lib/api/admin.ts +++ b/apps/admin/src/lib/api/admin.ts @@ -132,6 +132,10 @@ export interface HostUniversityPayload { detailsForLocal?: string; } +export interface AdminUniversityImageUploadResponse { + fileUrl: string; +} + export interface UnivApplyInfoLanguageRequirement { languageTestType: string; minScore: string; @@ -344,6 +348,28 @@ export const adminApi = { deleteHostUniversity: (id: number) => axiosInstance.delete(`/admin/host-universities/${id}`).then((res) => res.data), + uploadAdminUniversityLogo: (file: File, englishName: string) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("englishName", englishName); + return axiosInstance + .post("/file/admin/university/logo", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }) + .then((res) => res.data); + }, + + uploadAdminUniversityBackground: (file: File, englishName: string) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("englishName", englishName); + return axiosInstance + .post("/file/admin/university/background", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }) + .then((res) => res.data); + }, + createUnivApplyInfo: (data: UnivApplyInfoCreatePayload) => axiosInstance.post("/admin/univ-apply-infos", data).then((res) => res.data),