From 0586e31f31d46affb1ae6aafd796d9e20e250433 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 22 Jun 2026 11:39:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=8C=80=ED=95=99=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tabs/HostUniversityTab.test.tsx | 89 +++++++++++++++++++ .../tabs/HostUniversityTab.tsx | 74 ++++++++++++++- apps/admin/src/lib/api/admin.test.ts | 42 +++++++++ apps/admin/src/lib/api/admin.ts | 26 ++++++ 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx create mode 100644 apps/admin/src/lib/api/admin.test.ts 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..fd9fa162 --- /dev/null +++ b/apps/admin/src/components/features/univ-apply-infos/tabs/HostUniversityTab.test.tsx @@ -0,0 +1,89 @@ +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"), + ); + }); + + 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..eed1c976 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 @@ -145,6 +145,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 +207,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 +233,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 +436,36 @@ export function HostUniversityTab() { onChange={(e) => setForm((prev) => ({ ...prev, [field]: e.target.value }))} required /> + {field === "logoImageUrl" && ( + <> + { + uploadImage("logo", e.target.files?.[0]); + e.target.value = ""; + }} + /> + {logoUploadMutation.isPending &&

업로드 중...

} + + )} + {field === "backgroundImageUrl" && ( + <> + { + uploadImage("background", e.target.files?.[0]); + e.target.value = ""; + }} + /> + {backgroundUploadMutation.isPending &&

업로드 중...

} + + )} ))} {OPTIONAL_FIELDS.map((field) => ( @@ -423,7 +495,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), From f80eca007f116f72dc85ee7ca7faad1184d88125 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 22 Jun 2026 11:55:18 +0900 Subject: [PATCH 2/4] feat: enhance image upload functionality with previews and loading states --- .../tabs/HostUniversityTab.tsx | 114 ++++++++++++++---- 1 file changed, 88 insertions(+), 26 deletions(-) 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 eed1c976..ae9d5a45 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,7 @@ import { type HostUniversityPayload, type HostUniversityResponse, } from "@/lib/api/admin"; +import { cn } from "@/lib/utils"; type ModalState = { open: false } | { open: true; mode: "create" } | { open: true; mode: "edit"; id: number }; @@ -437,34 +439,94 @@ export function HostUniversityTab() { required /> {field === "logoImageUrl" && ( - <> - { - uploadImage("logo", e.target.files?.[0]); - e.target.value = ""; - }} - /> - {logoUploadMutation.isPending &&

업로드 중...

} - +
+ {form.logoImageUrl ? ( + 로고 미리보기 + ) : ( +
+ +
+ )} + +
)} {field === "backgroundImageUrl" && ( - <> - { - uploadImage("background", e.target.files?.[0]); - e.target.value = ""; - }} - /> - {backgroundUploadMutation.isPending &&

업로드 중...

} - +
+ {form.backgroundImageUrl ? ( + 배경 미리보기 + ) : ( +
+ +
+ )} + +
)} ))} From 9d578f5ab9ebd1c7f5322630e9e51a7da5afde57 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 22 Jun 2026 13:12:36 +0900 Subject: [PATCH 3/4] feat: integrate CDN normalization for logo and background image uploads --- .../features/univ-apply-infos/tabs/HostUniversityTab.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ae9d5a45..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 @@ -15,6 +15,7 @@ import { 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 }; @@ -442,7 +443,7 @@ export function HostUniversityTab() {
{form.logoImageUrl ? ( 로고 미리보기 @@ -473,6 +474,7 @@ export function HostUniversityTab() { { @@ -487,7 +489,7 @@ export function HostUniversityTab() {
{form.backgroundImageUrl ? ( 배경 미리보기 @@ -518,6 +520,7 @@ export function HostUniversityTab() { { From b2fb02da6706408a5d4c2929efcc6985f3e431c8 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 22 Jun 2026 13:13:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EB=8C=80=ED=95=99=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20CDN?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/univ-apply-infos/tabs/HostUniversityTab.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 fd9fa162..d3b0f343 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 @@ -62,6 +62,9 @@ describe("HostUniversityTab image uploads", () => { 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 () => {