diff --git a/client/package-lock.json b/client/package-lock.json index 32dcb982..30b4f82f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.20", "@tanstack/react-query-devtools": "^5.59.20", + "@testing-library/user-event": "^14.6.0", "avvvatars-react": "^0.4.2", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", @@ -112,7 +113,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", @@ -261,7 +261,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2776,7 +2775,6 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -2862,6 +2860,18 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz", + "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz", @@ -2890,7 +2900,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-array": { @@ -3492,7 +3501,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -3935,7 +3943,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4493,7 +4500,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4534,7 +4540,6 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, "license": "MIT" }, "node_modules/dom-helpers": { @@ -5702,7 +5707,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6693,7 +6697,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -7450,7 +7453,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -7465,7 +7467,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7577,7 +7578,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/react-remove-scroll": { @@ -8561,7 +8561,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" diff --git a/client/package.json b/client/package.json index bddae703..e4fc570e 100644 --- a/client/package.json +++ b/client/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.20", "@tanstack/react-query-devtools": "^5.59.20", + "@testing-library/user-event": "^14.6.0", "avvvatars-react": "^0.4.2", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", diff --git a/client/src/__tests__/__mocks__/components/ui/DropdownMenu.tsx b/client/src/__tests__/__mocks__/components/ui/DropdownMenu.tsx new file mode 100644 index 00000000..c41148c0 --- /dev/null +++ b/client/src/__tests__/__mocks__/components/ui/DropdownMenu.tsx @@ -0,0 +1,24 @@ +export const mockDropdownMenu = { + DropdownMenu: ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode; asChild?: boolean }) => { + return
{children}
; + }, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }, + DropdownMenuItem: ({ + children, + onClick, + className, + }: { + children: React.ReactNode; + onClick?: () => void; + className?: string; + }) => ( +
+ {children} +
+ ), +}; \ No newline at end of file diff --git a/client/src/__tests__/__mocks__/external/lucide-react.tsx b/client/src/__tests__/__mocks__/external/lucide-react.tsx index 191eed36..2af6eb6d 100644 --- a/client/src/__tests__/__mocks__/external/lucide-react.tsx +++ b/client/src/__tests__/__mocks__/external/lucide-react.tsx @@ -9,4 +9,10 @@ export const mockLucideIcons = { ChevronLeft: () =>
Mock Chevron Left Icon
, ChevronRight: () =>
Mock Chevron Right Icon
, MoreHorizontal: () =>
Mock More Horizontal Icon
, + LogOut: () =>
Mock Logout Icon
, + Eye: () =>
Mock Eye Icon
, + EyeOff: () =>
Mock EyeOff Icon
, + CheckCircle: () =>
Mock Check Circle Icon
, + XCircle: () =>
Mock X Circle Icon
, + TriangleAlert: () =>
Mock Triangle Alert Icon
, }; diff --git a/client/src/__tests__/components/common/admin/layout/AdminHeader.test.tsx b/client/src/__tests__/components/common/admin/layout/AdminHeader.test.tsx new file mode 100644 index 00000000..75c6784c --- /dev/null +++ b/client/src/__tests__/components/common/admin/layout/AdminHeader.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { AdminHeader } from "@/components/admin/layout/AdminHeader"; +import userEvent from "@testing-library/user-event"; +import { auth } from "@/api/services/admin/auth"; + +vi.mock("@/api/services/admin/auth", () => ({ + auth: { + logout: vi.fn(), + }, +})); + +vi.mock("@/components/admin/layout/AdminNavigationMenu", () => ({ + AdminNavigationMenu: ({ handleTap }: { handleTap: (tap: "RSS" | "MEMBER") => void }) => ( +
+ + +
+ ), +})); + +describe("AdminHeader 컴포넌트", () => { + const mockSetLogin = vi.fn(); + const mockHandleTap = vi.fn(); + const originalLocation = window.location; + + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, "location", { + configurable: true, + value: { reload: vi.fn() }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + it("로고와 네비게이션 메뉴가 정상적으로 렌더링되어야 한다", () => { + render(); + + expect(screen.getByAltText("Logo")).toBeInTheDocument(); + expect(screen.getByTestId("admin-nav-menu")).toBeInTheDocument(); + expect(screen.getByTestId("user-icon")).toBeInTheDocument(); + }); + + it("로고 클릭 시 페이지가 새로고침되어야 한다", () => { + render(); + + const logo = screen.getByAltText("Logo"); + fireEvent.click(logo); + + expect(window.location.reload).toHaveBeenCalled(); + }); + + it("네비게이션 메뉴 클릭 시 올바른 탭으로 전환되어야 한다", () => { + render(); + + fireEvent.click(screen.getByText("RSS")); + expect(mockHandleTap).toHaveBeenCalledWith("RSS"); + + fireEvent.click(screen.getByText("MEMBER")); + expect(mockHandleTap).toHaveBeenCalledWith("MEMBER"); + }); + + + it("로그아웃 버튼 클릭 시 로그아웃 처리가 되어야 한다", async () => { + render(); + + await userEvent.click(screen.getByTestId("user-menu-button")); + + const logoutButton = await screen.findByTestId("logout-button"); + + await userEvent.click(logoutButton); + + expect(auth.logout).toHaveBeenCalledTimes(1); + expect(mockSetLogin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/__tests__/components/common/admin/layout/AdminMember.test.tsx b/client/src/__tests__/components/common/admin/layout/AdminMember.test.tsx new file mode 100644 index 00000000..6f50fec1 --- /dev/null +++ b/client/src/__tests__/components/common/admin/layout/AdminMember.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import AdminMember from "@/components/admin/layout/AdminMember"; + +vi.mock("@/hooks/queries/useAdminAuth", () => ({ + useAdminRegister: (onSuccess: Mock, onError: Mock) => ({ + mutate: (data: { loginId: string; password: string }) => { + if (data.loginId === "fail") { + onError({ response: { data: "아이디가 올바르지 않습니다." } }); + } else { + onSuccess({ message: "생성완료" }); + } + }, + }), +})); + +const mockAlert = vi.fn(); +vi.stubGlobal("alert", mockAlert); + +describe("AdminMember", () => { + const queryClient = new QueryClient(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setup = () => + render( + + + + ); + + it("컴포넌트가 초기 렌더링될 때 기본 요소가 표시되어야 한다", () => { + setup(); + expect(screen.getByText("관리자 계정 생성")).toBeInTheDocument(); + expect(screen.getByLabelText("ID")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "가입" })).toBeInTheDocument(); + }); + + it("Password Toggle 버튼 클릭 시 비밀번호 표시/숨기기가 전환되어야 한다", async () => { + setup(); + const toggleButton = screen.getByRole("button", { name: "Toggle bold" }); + const passwordInput = screen.getByLabelText("Password") as HTMLInputElement; + + expect(passwordInput.type).toBe("password"); + await userEvent.click(toggleButton); + expect(passwordInput.type).toBe("text"); + await userEvent.click(toggleButton); + expect(passwordInput.type).toBe("password"); + }); + + it("ID와 Password 입력 후 가입 시 onSuccess가 호출되어 폼이 초기화되어야 한다", async () => { + setup(); + const idInput = screen.getByLabelText("ID") as HTMLInputElement; + const pwInput = screen.getByLabelText("Password") as HTMLInputElement; + const submitBtn = screen.getByRole("button", { name: "가입" }); + + await userEvent.type(idInput, "admin"); + await userEvent.type(pwInput, "adminpw"); + fireEvent.click(submitBtn); + + expect(mockAlert).toHaveBeenCalledWith("관리자 등록 성공: 생성완료"); + expect(idInput.value).toBe(""); + expect(pwInput.value).toBe(""); + }); + + it("ID가 'fail'일 때 가입 시 onError가 호출되어 에러 알림이 표시되어야 한다", async () => { + setup(); + const idInput = screen.getByLabelText("ID"); + const pwInput = screen.getByLabelText("Password"); + const submitBtn = screen.getByRole("button", { name: "가입" }); + + await userEvent.type(idInput, "fail"); + await userEvent.type(pwInput, "somepassword"); + fireEvent.click(submitBtn); + + expect(mockAlert).toHaveBeenCalledWith( + '관리자 등록 실패: "아이디가 올바르지 않습니다."' + ); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/layout/AdminNavigationMenu.test.tsx b/client/src/__tests__/components/common/admin/layout/AdminNavigationMenu.test.tsx new file mode 100644 index 00000000..908b2933 --- /dev/null +++ b/client/src/__tests__/components/common/admin/layout/AdminNavigationMenu.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AdminNavigationMenu, TAB_TYPES } from "@/components/admin/layout/AdminNavigationMenu"; + +describe("AdminNavigationMenu", () => { + it("컴포넌트가 초기 렌더링될 때, 기본 요소가 표시되어야 한다", () => { + render(); + expect(screen.getByText("RSS 목록")).toBeInTheDocument(); + expect(screen.getByText("회원 관리")).toBeInTheDocument(); + }); + + it("RSS 목록 버튼 클릭 시 handleTap이 RSS로 호출되어야 한다", () => { + const handleTap = vi.fn(); + render(); + + const rssButton = screen.getByText("RSS 목록"); + fireEvent.click(rssButton); + + expect(handleTap).toHaveBeenCalledWith(TAB_TYPES.RSS); + }); + + it("회원 관리 버튼 클릭 시 handleTap이 MEMBER로 호출되어야 한다", () => { + const handleTap = vi.fn(); + render(); + + const memberButton = screen.getByText("회원 관리"); + fireEvent.click(memberButton); + + expect(handleTap).toHaveBeenCalledWith(TAB_TYPES.MEMBER); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/layout/AdminTabs.test.tsx b/client/src/__tests__/components/common/admin/layout/AdminTabs.test.tsx new file mode 100644 index 00000000..95bfbc22 --- /dev/null +++ b/client/src/__tests__/components/common/admin/layout/AdminTabs.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AdminTabs } from "@/components/admin/layout/AdminTabs"; +import { useFetchRss, useFetchAccept, useFetchReject } from "@/hooks/queries/useFetchRss"; +import { useAdminAccept, useAdminReject } from "@/hooks/queries/useRssActions"; + +vi.mock("@/hooks/queries/useFetchRss", () => ({ + useFetchRss: vi.fn(), + useFetchAccept: vi.fn(), + useFetchReject: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRssActions", () => ({ + useAdminAccept: vi.fn(), + useAdminReject: vi.fn(), +})); + +const mockSetLogout = vi.fn(); +const queryClient = new QueryClient(); + +const setup = () => + render( + + + + ); + +describe("AdminTabs", () => { + beforeEach(() => { + vi.clearAllMocks(); + (useAdminAccept as Mock).mockReturnValue({ mutate: vi.fn() }); + (useAdminReject as Mock).mockReturnValue({ mutate: vi.fn() }); + }); + + it("로딩 중일 때 로딩 메시지를 표시해야 한다", () => { + (useFetchRss as Mock).mockReturnValue({ isLoading: true }); + (useFetchAccept as Mock).mockReturnValue({ isLoading: true }); + (useFetchReject as Mock).mockReturnValue({ isLoading: true }); + + setup(); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("에러 발생 시 에러 메시지를 표시해야 한다", () => { + (useFetchRss as Mock).mockReturnValue({ error: true }); + (useFetchAccept as Mock).mockReturnValue({ error: true }); + (useFetchReject as Mock).mockReturnValue({ error: true }); + + setup(); + + expect(screen.getByText("세션이 만료되었습니다!")).toBeInTheDocument(); + expect(screen.getByText("서비스를 계속 사용하려면 로그인하세요.")).toBeInTheDocument(); + }); + + it("탭과 데이터를 정상적으로 렌더링해야 한다", () => { + (useFetchRss as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + (useFetchAccept as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + (useFetchReject as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + + setup(); + + expect(screen.getByText("대기 중")).toBeInTheDocument(); + expect(screen.getByText("승인됨")).toBeInTheDocument(); + expect(screen.getByText("거부됨")).toBeInTheDocument(); + }); + + it("대기 중 탭에서 승인 버튼 클릭 시 handleActions가 호출되어야 한다", () => { + const mockAcceptMutate = vi.fn(); + (useFetchRss as Mock).mockReturnValue({ data: { data: [{ id: 1, name: "Test RSS" }] }, isLoading: false }); + (useFetchAccept as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + (useFetchReject as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + (useAdminAccept as Mock).mockReturnValue({ mutate: mockAcceptMutate }); + + setup(); + + const approveButton = screen.getByText("승인"); + fireEvent.click(approveButton); + + expect(mockAcceptMutate).toHaveBeenCalledWith({ id: 1, name: "Test RSS" }); + }); + + it("대기 중 탭에서 거부 버튼 클릭 시 handleSelectedBlog가 호출되어야 한다", () => { + (useFetchRss as Mock).mockReturnValue({ data: { data: [{ id: 1, name: "Test RSS" }] }, isLoading: false }); + (useFetchAccept as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + (useFetchReject as Mock).mockReturnValue({ data: { data: [] }, isLoading: false }); + + setup(); + + const rejectButton = screen.getByText("거부"); + fireEvent.click(rejectButton); + + expect(screen.getByText("Test RSS")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/login/AdminLoginModal.test.tsx b/client/src/__tests__/components/common/admin/login/AdminLoginModal.test.tsx new file mode 100644 index 00000000..53afab1e --- /dev/null +++ b/client/src/__tests__/components/common/admin/login/AdminLoginModal.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { AxiosError } from "axios"; + +import { vi, describe, it, beforeEach, expect } from "vitest"; +import AdminLogin from "@/components/admin/login/AdminLoginModal"; + +vi.mock("@/hooks/queries/useAdminAuth", () => ({ + useAdminAuth: ( + onSuccess: () => void, + onError: (error: AxiosError) => void + ) => ({ + mutate: (data: { loginId: string; password: string }) => { + if (data.loginId === "admin" && data.password === "password") { + onSuccess(); + } else { + onError(new AxiosError("Invalid credentials")); + } + }, + }), +})); + +vi.mock("@/hooks/common/useKeyboardShortcut", () => ({ + useKeyboardShortcut: (key: string, callback: () => void) => { + document.addEventListener("keydown", (event) => { + if (event.key === key) { + callback(); + } + }); + }, +})); + +describe("AdminLogin", () => { + const setLogin = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setup = () => + render(); + + it("로그인 폼이 렌더링된다", () => { + setup(); + expect(screen.getByText("관리자 로그인")).toBeInTheDocument(); + expect(screen.getByLabelText("ID")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument(); + }); + + it("로그인 실패 시 오류 알림이 표시된다", async () => { + setup(); + fireEvent.change(screen.getByLabelText("ID"), { target: { value: "wrong" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + expect(await screen.findByText("로그인 실패")).toBeInTheDocument(); + expect(screen.getByText("아이디 또는 비밀번호를 확인하세요.")).toBeInTheDocument(); + }); + + it("로그인 성공 시 setLogin 함수가 호출된다", async () => { + setup(); + fireEvent.change(screen.getByLabelText("ID"), { target: { value: "admin" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "password" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + expect(setLogin).toHaveBeenCalled(); + }); + + it("Enter 키를 눌러 로그인 시도 시 setLogin 함수가 호출된다", async () => { + setup(); + fireEvent.change(screen.getByLabelText("ID"), { target: { value: "admin" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "password" } }); + fireEvent.keyDown(document, { key: "Enter" }); + expect(setLogin).toHaveBeenCalled(); + }); + + it("오류 알림의 확인 버튼을 클릭하면 오류 알림이 닫힌다", async () => { + setup(); + fireEvent.change(screen.getByLabelText("ID"), { target: { value: "wrong" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } }); + fireEvent.click(screen.getByRole("button", { name: "로그인" })); + expect(await screen.findByText("로그인 실패")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "확인" })); + expect(screen.queryByText("로그인 실패")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/rss/RejectModal.test.tsx b/client/src/__tests__/components/common/admin/rss/RejectModal.test.tsx new file mode 100644 index 00000000..d46e38b7 --- /dev/null +++ b/client/src/__tests__/components/common/admin/rss/RejectModal.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { RejectModal } from "@/components/admin/rss/RejectModal"; +import {describe, it, expect, vi, beforeEach} from "vitest"; + +describe("RejectModal", () => { + const mockHandleReason = vi.fn(); + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + const setup = (rejectMessage = "") => + render( + + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("거부하기 버튼 클릭 시 onSubmit과 onCancel 함수가 호출된다", () => { + setup("Test reason"); + fireEvent.click(screen.getByRole("button", { name: "거부하기" })); + expect(mockOnSubmit).toHaveBeenCalled(); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("취소 버튼 클릭 시 onCancel 함수가 호출된다", () => { + setup(); + fireEvent.click(screen.getByRole("button", { name: "취소" })); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("거부 사유 입력 시 handleReason 함수가 호출된다", () => { + setup(); + fireEvent.change(screen.getByPlaceholderText("거부 사유를 입력하세요..."), { target: { value: "New reason" } }); + expect(mockHandleReason).toHaveBeenCalledWith("New reason"); + }); + + it("거부 사유가 없을 때 거부하기 버튼이 비활성화된다", () => { + setup(); + expect(screen.getByRole("button", { name: "거부하기" })).toBeDisabled(); + }); + + it("거부 사유가 있을 때 거부하기 버튼이 활성화된다", () => { + setup("Test reason"); + expect(screen.getByRole("button", { name: "거부하기" })).toBeEnabled(); + }); + + it("Dialog의 onOpenChange 이벤트가 호출되면 onCancel 함수가 호출된다", () => { + setup(); + fireEvent.click(screen.getByRole("button", { name: "취소" })); + expect(mockOnCancel).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/rss/RssResponseCard.test.tsx b/client/src/__tests__/components/common/admin/rss/RssResponseCard.test.tsx new file mode 100644 index 00000000..7ddb29cf --- /dev/null +++ b/client/src/__tests__/components/common/admin/rss/RssResponseCard.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { RssResponseCard } from "@/components/admin/rss/RssResponseCard"; +import { AdminRssData } from "@/types/rss"; +import { it, describe, expect} from "vitest"; + +const mockRequest: AdminRssData = { + id: 1, + name: "Test RSS Feed", + rssUrl: "https://example.com/rss", + description: "This is a test description", + userName: "testuser", + email: "testuser@example.com", +}; + +describe("RssResponseCard", () => { + it("컴포넌트가 렌더링된다", () => { + render(); + expect(screen.getByText("Test RSS Feed")).toBeInTheDocument(); + expect(screen.getByText("https://example.com/rss")).toBeInTheDocument(); + expect(screen.getByText("거부 사유:This is a test description")).toBeInTheDocument(); + expect(screen.getByText("신청자: testuser")).toBeInTheDocument(); + }); + + it("설명이 없는 경우 설명이 렌더링되지 않는다", () => { + const requestWithoutDescription = { ...mockRequest, description: "" }; + render(); + expect(screen.getByText("Test RSS Feed")).toBeInTheDocument(); + expect(screen.getByText("https://example.com/rss")).toBeInTheDocument(); + expect(screen.queryByText("거부 사유:")).not.toBeInTheDocument(); + expect(screen.getByText("신청자: testuser")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/common/admin/rss/RssSearchBar.test.tsx b/client/src/__tests__/components/common/admin/rss/RssSearchBar.test.tsx new file mode 100644 index 00000000..efa400b7 --- /dev/null +++ b/client/src/__tests__/components/common/admin/rss/RssSearchBar.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { RssRequestSearchBar } from "@/components/admin/rss/RssSearchBar"; +import { useAdminSearchStore } from "@/store/useSearchStore"; +import {vi, describe, it, beforeEach, afterEach, expect, Mock} from "vitest"; + +vi.mock("@/store/useSearchStore", () => ({ + useAdminSearchStore: vi.fn(), +})); + +describe("RssRequestSearchBar", () => { + const mockSetSearchParam = vi.fn(); + const mockUseAdminSearchStore = useAdminSearchStore as unknown as Mock; + + beforeEach(() => { + mockUseAdminSearchStore.mockReturnValue({ + searchParam: "", + setSearchParam: mockSetSearchParam, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("컴포넌트가 렌더링된다", () => { + render(); + expect(screen.getByPlaceholderText("블로그명, URL 또는 신청자로 검색")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("입력 필드에 값을 입력하면 setSearchParam 함수가 호출된다", () => { + render(); + const input = screen.getByPlaceholderText("블로그명, URL 또는 신청자로 검색"); + fireEvent.change(input, { target: { value: "test" } }); + expect(mockSetSearchParam).toHaveBeenCalledWith("test"); + }); + + it("입력 필드의 초기 값이 searchParam과 일치한다", () => { + mockUseAdminSearchStore.mockReturnValueOnce({ + searchParam: "initial value", + setSearchParam: mockSetSearchParam, + }); + render(); + const input = screen.getByPlaceholderText("블로그명, URL 또는 신청자로 검색"); + expect(input).toHaveValue("initial value"); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/setup.tsx b/client/src/__tests__/setup.tsx index b75a6580..d1496cf4 100644 --- a/client/src/__tests__/setup.tsx +++ b/client/src/__tests__/setup.tsx @@ -5,6 +5,7 @@ import { mockPlatformSelector, mockRssUrlInput } from "@/__tests__/__mocks__/com import { mockAvatar } from "@/__tests__/__mocks__/components/ui/Avatar.tsx"; import { mockCard } from "@/__tests__/__mocks__/components/ui/Card.tsx"; import { mockCommand } from "@/__tests__/__mocks__/components/ui/Command.tsx"; +import { mockDropdownMenu } from "@/__tests__/__mocks__/components/ui/DropdownMenu.tsx"; import { mockDialog } from "@/__tests__/__mocks__/components/ui/Dialog.tsx"; import { mockPagination } from "@/__tests__/__mocks__/components/ui/Pagination.tsx"; import { mockLucideIcons } from "@/__tests__/__mocks__/external/lucide-react.tsx"; @@ -21,6 +22,7 @@ vi.mock("@/components/ui/Card", () => mockCard); vi.mock("@/components/ui/Avatar", () => mockAvatar); vi.mock("@/components/ui/Command", () => mockCommand); vi.mock("@/components/ui/pagination", () => mockPagination); +vi.mock("@/components/ui/DropdownMenu", () => mockDropdownMenu); vi.mock("@/components/ui/Dialog", () => mockDialog); vi.mock("@/components/common/LazyImage", () => mockLazyImage); vi.mock("@/components/RssRegistration/PlatformSelector", () => mockPlatformSelector); diff --git a/client/src/components/admin/layout/AdminHeader.tsx b/client/src/components/admin/layout/AdminHeader.tsx index 23da6715..4bd097fa 100644 --- a/client/src/components/admin/layout/AdminHeader.tsx +++ b/client/src/components/admin/layout/AdminHeader.tsx @@ -36,17 +36,15 @@ export const AdminHeader = ({ - {/* Right Side Menu */}
- {/* Notifications */} - - + 로그아웃 diff --git a/client/src/components/admin/login/AdminLoginModal.tsx b/client/src/components/admin/login/AdminLoginModal.tsx index 652d7577..b475c515 100644 --- a/client/src/components/admin/login/AdminLoginModal.tsx +++ b/client/src/components/admin/login/AdminLoginModal.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import FormInput from "@/components/RssRegistration/FormInput"; +import { FormInput } from "@/components/RssRegistration/FormInput"; import { AlertDialog, AlertDialogAction,