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,