Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof import("@tanstack/react-query")>();
return { ...actual, useQueries: () => [] };
});

function renderTab() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<HostUniversityTab />
</QueryClientProvider>,
);
}

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",
);
});
Comment thread
whqtker marked this conversation as resolved.

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");
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 };

Expand Down Expand Up @@ -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 }));
Comment thread
whqtker marked this conversation as resolved.
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 });
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -394,6 +439,98 @@ export function HostUniversityTab() {
onChange={(e) => setForm((prev) => ({ ...prev, [field]: e.target.value }))}
required
/>
{field === "logoImageUrl" && (
<div className="flex items-center gap-3 rounded-lg border border-k-100 bg-k-50 p-3">
{form.logoImageUrl ? (
<img
src={normalizeImageUrlToUploadCdn(form.logoImageUrl)}
alt="로고 미리보기"
className="h-14 w-14 shrink-0 rounded-md border border-k-100 bg-white object-contain p-1"
/>
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-dashed border-k-200 bg-white">
<ImageIcon className="h-5 w-5 text-k-300" />
</div>
)}
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 typo-regular-4 transition-colors",
logoUploadMutation.isPending
? "cursor-not-allowed border-k-200 text-k-400 opacity-60"
: "border-k-200 text-k-600 hover:border-primary hover:bg-primary/5 hover:text-primary",
)}
>
{logoUploadMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
업로드 중...
</>
) : (
<>
<Upload className="h-4 w-4" />
파일 선택
</>
)}
<input
type="file"
accept="image/*"
aria-label="로고 이미지 파일"
className="sr-only"
disabled={logoUploadMutation.isPending}
onChange={(e) => {
uploadImage("logo", e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
</div>
)}
{field === "backgroundImageUrl" && (
<div className="flex items-center gap-3 rounded-lg border border-k-100 bg-k-50 p-3">
{form.backgroundImageUrl ? (
<img
src={normalizeImageUrlToUploadCdn(form.backgroundImageUrl)}
alt="배경 미리보기"
className="h-14 w-28 shrink-0 rounded-md border border-k-100 bg-white object-cover"
/>
) : (
<div className="flex h-14 w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-k-200 bg-white">
<ImageIcon className="h-5 w-5 text-k-300" />
</div>
)}
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 typo-regular-4 transition-colors",
backgroundUploadMutation.isPending
? "cursor-not-allowed border-k-200 text-k-400 opacity-60"
: "border-k-200 text-k-600 hover:border-primary hover:bg-primary/5 hover:text-primary",
)}
>
{backgroundUploadMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
업로드 중...
</>
) : (
<>
<Upload className="h-4 w-4" />
파일 선택
</>
)}
<input
type="file"
accept="image/*"
aria-label="배경 이미지 파일"
className="sr-only"
disabled={backgroundUploadMutation.isPending}
onChange={(e) => {
uploadImage("background", e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
</div>
)}
</div>
))}
{OPTIONAL_FIELDS.map((field) => (
Expand Down Expand Up @@ -423,7 +560,7 @@ export function HostUniversityTab() {
<Button type="button" variant="secondary" onClick={closeModal}>
취소
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending || isUploading}>
{modal.mode === "create" ? "생성" : "저장"}
</Button>
</div>
Expand Down
42 changes: 42 additions & 0 deletions apps/admin/src/lib/api/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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` });
});
});
26 changes: 26 additions & 0 deletions apps/admin/src/lib/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export interface HostUniversityPayload {
detailsForLocal?: string;
}

export interface AdminUniversityImageUploadResponse {
fileUrl: string;
}

export interface UnivApplyInfoLanguageRequirement {
languageTestType: string;
minScore: string;
Expand Down Expand Up @@ -344,6 +348,28 @@ export const adminApi = {
deleteHostUniversity: (id: number) =>
axiosInstance.delete<void>(`/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<AdminUniversityImageUploadResponse>("/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<AdminUniversityImageUploadResponse>("/file/admin/university/background", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
.then((res) => res.data);
},

createUnivApplyInfo: (data: UnivApplyInfoCreatePayload) =>
axiosInstance.post<UnivApplyInfoManageResponse>("/admin/univ-apply-infos", data).then((res) => res.data),

Expand Down
Loading