diff --git a/.vscode/settings.json b/.vscode/settings.json index 759123b..2a03361 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "discord.enabled": true, "chat.tools.terminal.autoApprove": { "npx jest": true, - "npm run lint": true + "npm run lint": true, + "npx tec --noEmit": true } } diff --git a/__tests__/kkuko/KkukoHome.test.tsx b/__tests__/kkuko/KkukoHome.test.tsx index 74e8343..7992e44 100644 --- a/__tests__/kkuko/KkukoHome.test.tsx +++ b/__tests__/kkuko/KkukoHome.test.tsx @@ -26,6 +26,6 @@ describe('KkukoHome', () => { expect(screen.getByText('각 모드별로 승리가 많은 유저들의 랭킹을 확인할 수 있습니다.')).toBeInTheDocument(); const rankingLink = screen.getByRole('link', { name: /구경하기/i }); - expect(rankingLink).toHaveAttribute('href', '/kkuko/ranking'); + expect(rankingLink).toHaveAttribute('href', '/kkuko/ranking?mode=ALL'); }); }); diff --git a/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts b/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts deleted file mode 100644 index 6e20ec3..0000000 --- a/__tests__/kkuko/profile/hooks/useKkukoProfile.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useKkukoProfile } from '@/app/kkuko/profile/hooks/useKkukoProfile'; -import * as api from '@/app/kkuko/shared/lib/api'; - -// Mock API -jest.mock('@/app/kkuko/shared/lib/api', () => ({ - fetchModes: jest.fn(), - fetchTotalUsers: jest.fn(), - fetchProfile: jest.fn(), - fetchItems: jest.fn(), - fetchExpRank: jest.fn() -})); - -// Mock useRecentSearches -const mockSaveToRecentSearches = jest.fn(); -const mockRemoveFromRecentSearches = jest.fn(); -jest.mock('@/app/kkuko/profile/hooks/useRecentSearches', () => ({ - useRecentSearches: jest.fn(() => ({ - recentSearches: [], - saveToRecentSearches: mockSaveToRecentSearches, - removeFromRecentSearches: mockRemoveFromRecentSearches - })) -})); - -describe('useKkukoProfile', () => { - beforeEach(() => { - jest.clearAllMocks(); - (api.fetchModes as jest.Mock).mockResolvedValue({ data: { status: 200, data: [] } }); - (api.fetchTotalUsers as jest.Mock).mockResolvedValue({ data: { status: 200, data: { totalUsers: 100 } } }); - }); - - it('should fetch modes and total users on mount', async () => { - const { result } = renderHook(() => useKkukoProfile()); - - await waitFor(() => { - expect(api.fetchModes).toHaveBeenCalled(); - expect(api.fetchTotalUsers).toHaveBeenCalled(); - expect(result.current.totalUserCount).toBe(100); - }); - }); - - it('should fetch profile successfully', async () => { - const mockProfileData = { - user: { id: 'test', nickname: 'Test', level: 1, exp: 0, exordial: '' }, - equipment: [{ itemId: 'item1', slot: 'head' }], - presence: { updatedAt: new Date().toISOString() } - }; - const mockItemsData = [{ id: 'item1', name: 'Item 1' }]; - - (api.fetchProfile as jest.Mock).mockResolvedValue({ status: 200, data: { status: 200, data: mockProfileData } }); - (api.fetchItems as jest.Mock).mockResolvedValue({ data: { status: 200, data: mockItemsData } }); - (api.fetchExpRank as jest.Mock).mockResolvedValue({ data: { rank: 5 } }); - - const { result } = renderHook(() => useKkukoProfile()); - - await act(async () => { - await result.current.fetchProfile('Test', 'nick'); - }); - - expect(result.current.profileData).toEqual(mockProfileData); - expect(result.current.itemsData).toEqual(mockItemsData); - expect(result.current.expRank).toBe(5); - expect(mockSaveToRecentSearches).toHaveBeenCalledWith('Test', 'nick'); - }); - - it('should handle profile not found (status 404)', async () => { - (api.fetchProfile as jest.Mock).mockResolvedValue({ status: 404 }); - - const { result } = renderHook(() => useKkukoProfile()); - - await act(async () => { - await result.current.fetchProfile('Unknown', 'nick'); - }); - - expect(result.current.error).toBe('등록된 유저가 아닙니다.'); - expect(result.current.profileData).toBeNull(); - }); - - it('should handle API errors', async () => { - (api.fetchProfile as jest.Mock).mockRejectedValue(new Error('Network Error')); - - const { result } = renderHook(() => useKkukoProfile()); - - await act(async () => { - await result.current.fetchProfile('Test', 'nick'); - }); - - expect(result.current.error).toBe('프로필을 불러오는데 실패했습니다.'); - }); -}); diff --git a/__tests__/kkuko/profile/hooks/useKkukoProfile.test.tsx b/__tests__/kkuko/profile/hooks/useKkukoProfile.test.tsx new file mode 100644 index 0000000..5f202a8 --- /dev/null +++ b/__tests__/kkuko/profile/hooks/useKkukoProfile.test.tsx @@ -0,0 +1,118 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useKkukoProfile } from '@/app/kkuko/profile/hooks/useKkukoProfile'; +import * as api from '@/app/kkuko/shared/lib/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { ReactNode } from 'react'; + +// Mock API +jest.mock('@/app/kkuko/shared/lib/api', () => ({ + fetchModes: jest.fn(), + fetchTotalUsers: jest.fn(), + fetchProfile: jest.fn(), + fetchProfileByNickname: jest.fn(), + fetchItems: jest.fn(), + fetchExpRank: jest.fn() +})); + +// Mock useRecentSearches +const mockSaveToRecentSearches = jest.fn(); +const mockRemoveFromRecentSearches = jest.fn(); +jest.mock('@/app/kkuko/profile/hooks/useRecentSearches', () => ({ + useRecentSearches: jest.fn(() => ({ + recentSearches: [], + saveToRecentSearches: mockSaveToRecentSearches, + removeFromRecentSearches: mockRemoveFromRecentSearches + })) +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe('useKkukoProfile', () => { + beforeEach(() => { + jest.clearAllMocks(); + (api.fetchModes as jest.Mock).mockResolvedValue({ data: { status: 200, data: [] } }); + (api.fetchTotalUsers as jest.Mock).mockResolvedValue({ data: { status: 200, data: { totalUsers: 100 } } }); + }); + + it('should fetch modes and total users on mount', async () => { + const { result } = renderHook(() => useKkukoProfile(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(api.fetchModes).toHaveBeenCalled(); + expect(api.fetchTotalUsers).toHaveBeenCalled(); + expect(result.current.totalUserCount).toBe(100); + }); + }); + + it('should fetch profile successfully', async () => { + const mockProfileData = { + user: { id: 'test', nickname: 'Test', level: 1, exp: 0, exordial: '' }, + equipment: [{ itemId: 'item1', slot: 'head' }], + presence: { updatedAt: new Date().toISOString() } + }; + const mockItemsData = [{ id: 'item1', name: 'Item 1' }]; + + // Mock fetchProfileByNicknameApi not fetchProfile because default is 'nick' + (api.fetchProfileByNickname as jest.Mock).mockResolvedValue({ data: { status: 200, data: mockProfileData } }); + (api.fetchProfile as jest.Mock).mockResolvedValue({ data: { status: 200, data: mockProfileData } }); + (api.fetchItems as jest.Mock).mockResolvedValue({ data: { status: 200, data: mockItemsData } }); + (api.fetchExpRank as jest.Mock).mockResolvedValue({ data: { rank: 5 } }); + + const { result } = renderHook(() => useKkukoProfile(), { wrapper: createWrapper() }); + + act(() => { + result.current.fetchProfile('Test', 'nick'); + }); + + await waitFor(() => { + expect(result.current.profileData).toEqual(mockProfileData); + expect(result.current.itemsData).toEqual(mockItemsData); + expect(result.current.expRank).toBe(5); + }); + + expect(mockSaveToRecentSearches).toHaveBeenCalledWith('Test', 'nick'); + }); + + it('should handle profile not found (status 404)', async () => { + const error = new Error('Not Found'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).response = { status: 404 }; + (api.fetchProfileByNickname as jest.Mock).mockRejectedValue(error); + + const { result } = renderHook(() => useKkukoProfile(), { wrapper: createWrapper() }); + + act(() => { + result.current.fetchProfile('Unknown', 'nick'); + }); + + await waitFor(() => { + expect(result.current.error).toBe('등록된 유저가 아닙니다.'); + expect(result.current.profileData).toBeNull(); + }); + }); + + it('should handle API errors', async () => { + (api.fetchProfileByNickname as jest.Mock).mockRejectedValue(new Error('Network Error')); + + const { result } = renderHook(() => useKkukoProfile(), { wrapper: createWrapper() }); + + act(() => { + result.current.fetchProfile('Test', 'nick'); + }); + + await waitFor(() => { + expect(result.current.error).toBe('프로필을 불러오는데 실패했습니다.'); + }); + }); +}); diff --git a/__tests__/kkuko/ranking/components/Podium.test.tsx b/__tests__/kkuko/ranking/components/Podium.test.tsx index dc2ab36..b4b1427 100644 --- a/__tests__/kkuko/ranking/components/Podium.test.tsx +++ b/__tests__/kkuko/ranking/components/Podium.test.tsx @@ -1,12 +1,26 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { Podium } from '@/app/kkuko/ranking/components/Podium'; import { RankingEntry, ProfileData } from '@/app/types/kkuko.types'; import * as api from '@/app/kkuko/shared/lib/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; jest.mock('@/app/kkuko/shared/lib/api'); jest.mock('@/app/kkuko/shared/components/ProfileAvatar', () => () =>
); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + describe('Podium', () => { const mockEntries: RankingEntry[] = [ { @@ -41,12 +55,12 @@ describe('Podium', () => { it('should calculate style correctly', () => { // Just checking rendering to ensure no crashes - render(); + render(, { wrapper: createWrapper() }); // Wait for effects }); it('should render top 3 users', async () => { - render(); + render(, { wrapper: createWrapper() }); await waitFor(() => { expect(screen.getByText('User1')).toBeInTheDocument(); @@ -56,7 +70,7 @@ describe('Podium', () => { }); it('should fetch profile data on mount', async () => { - render(); + render(, { wrapper: createWrapper() }); await waitFor(() => { expect(api.fetchProfile).toHaveBeenCalledTimes(3); diff --git a/app/admin/AdminPage.tsx b/app/admin/AdminPage.tsx index edffbdd..794f640 100644 --- a/app/admin/AdminPage.tsx +++ b/app/admin/AdminPage.tsx @@ -99,7 +99,7 @@ const AdminDashboard = () => { title: '공지사항 관리', description: '서비스 공지사항을 작성하고 관리합니다', icon: Megaphone, - path: '/admin/notice', + path: '/notification', color: 'text-orange-600', bgColor: 'bg-orange-50 hover:bg-orange-100', borderColor: 'border-orange-200' @@ -233,7 +233,7 @@ const AdminDashboard = () => { 로그 관리 +
+ +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+
+ + + + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+ + + Page {data.currentPage} of {data.totalPages} + + +
+ )} + + + + setIsConfirmOpen(false)} + onConfirm={handleConfirmDelete} + title="Delete Item" + description={`Are you sure you want to delete item "${deletingItem?.name}"? This action cannot be undone.`} + /> + + {error && ( + setError(null)} + /> + )} + + ) +} diff --git a/app/admin/api-server/items/_components/EditItemModal.tsx b/app/admin/api-server/items/_components/EditItemModal.tsx new file mode 100644 index 0000000..5f99666 --- /dev/null +++ b/app/admin/api-server/items/_components/EditItemModal.tsx @@ -0,0 +1,214 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/app/components/ui/dialog' +import { Button } from '@/app/components/ui/button' +import { Input } from '@/app/components/ui/input' +import { Label } from '@/app/components/ui/label' +import { Textarea } from '@/app/components/ui/textarea' +import { Item, ItemInput } from './types' + +interface EditItemModalProps { + open: boolean + onOpenChange: (open: boolean) => void + item: Item | null + onSave: (item: ItemInput) => void + isSaving: boolean + readOnly?: boolean +} + +/** + * EditItemModal component + * Modal for creating or editing an item. + * Validates JSON for the 'options' field. + */ +export default function EditItemModal({ + open, + onOpenChange, + item, + onSave, + isSaving, + readOnly = false, +}: EditItemModalProps) { + const isEditMode = !!item + const title = readOnly ? 'View Item' : isEditMode ? 'Edit Item' : 'Create Item' + + const [formData, setFormData] = useState({ + id: '', + name: '', + description: '', + group: '', + options: {}, + }) + + const [optionsJson, setOptionsJson] = useState('{}') + const [jsonError, setJsonError] = useState(null) + + useEffect(() => { + if (open) { + if (item) { + setFormData({ + id: item.id, + name: item.name, + description: item.description, + group: item.group, + options: item.options, + }) + setOptionsJson(JSON.stringify(item.options, null, 2)) + } else { + // Reset for create mode + setFormData({ + id: '', + name: '', + description: '', + group: '', + options: {}, + }) + setOptionsJson('{}') + } + setJsonError(null) + } + }, [open, item]) + + const handleInputChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + } + + const handleJsonChange = (e: React.ChangeEvent) => { + const value = e.target.value + setOptionsJson(value) + try { + JSON.parse(value) + setJsonError(null) + } catch (err) { + setJsonError((err as Error).message) + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (jsonError) return + + try { + const parsedOptions = JSON.parse(optionsJson) + onSave({ + ...formData, + options: parsedOptions, + }) + } catch (_err) { + setJsonError('Invalid JSON') + } + } + + return ( + + + + + {title} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +