Skip to content
Merged

1.6.0 #128

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
86cca46
fix: 특수 이름색 수정
hafskjfha Jan 29, 2026
e8c8cea
fix: 가림막 버그 수정
hafskjfha Jan 29, 2026
a94e1d3
fix: 모드 페이지 쿼리 파라미터 추가
hafskjfha Jan 29, 2026
87d0ceb
fix: html lang수정
hafskjfha Feb 7, 2026
48b9564
docs: api 문서 수정 및 타입 마이그레이션
hafskjfha Feb 8, 2026
c05353f
feat(kkuko): 랭킹에 전체모드 추가
hafskjfha Feb 10, 2026
32d3b43
feat(notification): 공지사항 열람 페이지 추가
hafskjfha Feb 10, 2026
2923c50
feat(notification): 공지사항 추가/수정/삭제 구현
hafskjfha Feb 10, 2026
8541713
fix(admin): 관리자페이지 전용 공지사항 관리 페이지를 이동
hafskjfha Feb 10, 2026
d28d954
fix: dark 모드 기본 색상 변경
hafskjfha Feb 10, 2026
9af1aa2
chore: 안쓰는 요소 제거
hafskjfha Feb 10, 2026
c57a31c
feat(admin): api-server 아이템 관리자 페이지 제작
hafskjfha Feb 10, 2026
2966e4c
chore: 이메일 주소 변경
hafskjfha Feb 10, 2026
4ccb275
feat(kkuko): 플레이 판수 탭 추가 및 탭 옵션 쿼리파라미터 추가
hafskjfha Feb 11, 2026
d78980d
fix(kkuko): 캐싱 추가로 429 줄이기 및 성능 향상
hafskjfha Feb 11, 2026
16b6e74
feat(kkuko): 중복된 닉네임 검색 처리 추가
hafskjfha Feb 11, 2026
8bc4843
fix: 이미지 캐싱
hafskjfha Feb 12, 2026
688410c
feat: 정보 비공개 요청 링크 추가
hafskjfha Feb 12, 2026
61e929a
test: kkuko 테스트 코드 수정
hafskjfha Feb 12, 2026
5e732a5
chore: lint 에러 수정
hafskjfha Feb 12, 2026
4712eba
chore: package install error fix
hafskjfha Feb 12, 2026
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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion __tests__/kkuko/KkukoHome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
90 changes: 0 additions & 90 deletions __tests__/kkuko/profile/hooks/useKkukoProfile.test.ts

This file was deleted.

118 changes: 118 additions & 0 deletions __tests__/kkuko/profile/hooks/useKkukoProfile.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

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('프로필을 불러오는데 실패했습니다.');
});
});
});
22 changes: 18 additions & 4 deletions __tests__/kkuko/ranking/components/Podium.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => () => <div data-testid="profile-avatar" />);

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

describe('Podium', () => {
const mockEntries: RankingEntry[] = [
{
Expand Down Expand Up @@ -41,12 +55,12 @@ describe('Podium', () => {

it('should calculate style correctly', () => {
// Just checking rendering to ensure no crashes
render(<Podium topThree={mockEntries} option="win" />);
render(<Podium topThree={mockEntries} option="win" />, { wrapper: createWrapper() });
// Wait for effects
});

it('should render top 3 users', async () => {
render(<Podium topThree={mockEntries} option="win" />);
render(<Podium topThree={mockEntries} option="win" />, { wrapper: createWrapper() });

await waitFor(() => {
expect(screen.getByText('User1')).toBeInTheDocument();
Expand All @@ -56,7 +70,7 @@ describe('Podium', () => {
});

it('should fetch profile data on mount', async () => {
render(<Podium topThree={mockEntries} option="win" />);
render(<Podium topThree={mockEntries} option="win" />, { wrapper: createWrapper() });

await waitFor(() => {
expect(api.fetchProfile).toHaveBeenCalledTimes(3);
Expand Down
4 changes: 2 additions & 2 deletions app/admin/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -233,7 +233,7 @@ const AdminDashboard = () => {
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">로그 관리</span>
</button>
<button
onClick={() => handleNavigation('/admin/notice')}
onClick={() => handleNavigation('/notification')}
className="flex flex-col items-center p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900 transition-all duration-200 bg-white dark:bg-transparent"
>
<Megaphone className="w-8 h-8 text-yellow-600 mb-2" />
Expand Down
5 changes: 5 additions & 0 deletions app/admin/api-server/ApiServerMangerHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export default function ApiServerAdminHome() {
description: 'API Server 및 Crawler 로그 조회',
href: '/admin/api-server/logs',
},
{
title: 'Items 관리',
description: '아이템 상태 확인 및 수정',
href: '/admin/api-server/items',
}
];

return (
Expand Down
76 changes: 75 additions & 1 deletion app/admin/api-server/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// API Server Admin API Functions
import axios from 'axios';
import type { CrawlerHealthResponse, SaveSessionRequest, SaveSessionResponse, RestartCrawlerResponse } from './types';
import type {
CrawlerHealthResponse,
SaveSessionRequest,
SaveSessionResponse,
RestartCrawlerResponse,
ItemsResponse,
Item,
CreateItemRequest,
UpdateItemRequest
} from './types';
import { SCM } from '@/app/lib/supabaseClient';
import zlib from 'zlib';

Expand Down Expand Up @@ -49,6 +58,71 @@ export const restartCrawler = async (
return response.data;
};

// Item APIs
export const fetchItems = async (page: number = 1): Promise<ItemsResponse> => {
const headers = await getAuthHeaders();
const response = await axios.get<ItemsResponse>(
`${BASE_URL}/admin/item/items`,
{
headers,
params: { page }
}
);
return response.data;
};

export const createItem = async (data: CreateItemRequest): Promise<Item> => {
const headers = await getAuthHeaders();
const response = await axios.post<Item>(
`${BASE_URL}/admin/item`,
data,
{ headers }
);
return response.data;
};

export const updateItem = async (id: string, data: UpdateItemRequest): Promise<Item> => {
const headers = await getAuthHeaders();
const response = await axios.put<Item>(
`${BASE_URL}/admin/item/${id}`,
data,
{ headers }
);
return response.data;
};

export const deleteItem = async (id: string): Promise<void> => {
const headers = await getAuthHeaders();
await axios.delete(
`${BASE_URL}/admin/item/${id}`,
{ headers }
);
};

export const searchItems = async (name: string, page: number = 1): Promise<ItemsResponse> => {
const headers = await getAuthHeaders();
const response = await axios.get<ItemsResponse>(
`${BASE_URL}/admin/item/items/name/${encodeURIComponent(name)}`,
{
headers,
params: { page }
}
);
return response.data;
};

export const searchItemsByGroup = async (group: string, page: number = 1): Promise<ItemsResponse> => {
const headers = await getAuthHeaders();
const response = await axios.get<ItemsResponse>(
`${BASE_URL}/admin/item/items/group/${encodeURIComponent(group)}`,
{
headers,
params: { page }
}
);
return response.data;
};

// Logs APIs
const isGzip = (u8: Uint8Array) => u8 && u8.length >= 2 && u8[0] === 0x1f && u8[1] === 0x8b;

Expand Down
Loading
Loading