diff --git a/README.md b/README.md index ca8e8a4..ad80db4 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,96 @@ +
+ # Dreamhack Readme Stats -GitHub README 프로필에 표시할 수 있는 Dreamhack 워게임 통계 SVG 생성기입니다. +**GitHub README에 Dreamhack 워게임 통계를 표시하세요** -## 사용 방법 +[![Used By](https://img.shields.io/badge/used%20by-13%20README.md-blue)](https://github.com/search?q=%22dreamhack-readme-stats.vercel.app%2Fapi%2F%22+in%3Afile+filename%3AREADME.md&type=code) +![GitHub release](https://img.shields.io/github/v/release/with-developer/dreamhack-readme-stats) +![License](https://img.shields.io/github/license/with-developer/dreamhack-readme-stats) -### Markdown +
-```markdown -![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명) -``` +![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness) -### HTML +![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness) -```html - - Dreamhack Stats - -``` +
-실제 사용 시에는 `사용자명`을 여러분의 Dreamhack 사용자 이름으로 변경하세요. +--- -## 예시 +## Quick Start -다음은 실제 렌더링된 결과입니다: +README에 아래 코드를 추가하고 `사용자명`을 본인의 Dreamhack 닉네임으로 변경하세요. -![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness) +### Wargame Stats -마크다운 코드: ```markdown -![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness) -``` - -HTML 코드: -```html - - Dreamhack Stats - +![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명) ``` -## 카테고리 차트 - -Dreamhack의 워게임 카테고리별 점수를 원형 차트로 표시합니다. 각 카테고리별 점수와 랭킹을 확인할 수 있습니다. +### Most Solved Categories -### 사용 방법 - -#### Markdown ```markdown -![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) +![Dreamhack Categories](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) ``` -#### HTML -```html - - Dreamhack Category Chart - -``` +> 💡 클릭 시 Dreamhack 프로필로 이동하게 하려면 HTML 사용: +> ```html +> +> +> +> ``` -### 예시 +--- -![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness) +## Features -## 기술 스택 +| Feature | Description | +|---------|-------------| +| **Wargame Stats** | 해결한 문제 수, 랭킹, 점수, TOP % 표시 | +| **Category Chart** | 카테고리별 점수 분포를 파이 차트로 시각화 | +| **Auto Update** | 실시간으로 최신 통계 반영 | +| **Caching** | Redis 캐싱으로 빠른 응답 속도 | -- Next.js -- TypeScript -- Node.js -- Redis (캐싱) +--- -## 로컬에서 실행하기 +## Local Development -1. 저장소 클론 -``` -git clone https://github.com/yourusername/dreamhack-readme-stats.git +```bash +# 저장소 클론 +git clone https://github.com/with-developer/dreamhack-readme-stats.git cd dreamhack-readme-stats -``` -2. 의존성 설치 -``` +# 의존성 설치 npm install -``` - -3. 개발 서버 실행 -``` -npm run dev -``` - -4. 브라우저에서 확인 -``` -http://localhost:3000 -``` - -## Redis 캐싱 설정 -성능 향상을 위해 Redis 캐싱을 사용합니다. 사용자 ID 조회 결과를 캐싱하여 API 응답 시간을 크게 단축합니다. - -### 로컬 환경에서 Redis 설정하기 - -1. `.env.local.example` 파일을 `.env.local`로 복사합니다. -``` +# 환경변수 설정 cp .env.local.example .env.local + +# 개발 서버 실행 +npm run dev ``` -2. `.env.local` 파일을 편집하여 Redis 연결 정보를 설정합니다. +http://localhost:3000 에서 확인 -Redis 연결은 두 가지 방법으로 설정할 수 있습니다: +### Environment Variables -#### 방법 1: REDIS_URL 사용 (권장) -``` -REDIS_URL=redis://username:password@host:port -``` +| Variable | Required | Description | +|----------|----------|-------------| +| `REDIS_URL` | No | Redis 연결 URL (캐싱용) | +| `GITHUB_TOKEN` | No | GitHub API 토큰 (사용자 수 집계용) | -#### 방법 2: 개별 설정 사용 -``` -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_USERNAME=default -REDIS_PASSWORD=your_password -REDIS_TLS=false -``` +--- -### Redis 서비스 제공업체 +## Tech Stack -다음과 같은 Redis 서비스를 사용할 수 있습니다: +- **Framework**: Next.js 14 +- **Language**: TypeScript +- **Cache**: Redis (Upstash) +- **Deploy**: Vercel -- [Upstash](https://upstash.com/) - 서버리스 Redis (무료 티어 제공) -- [Redis Cloud](https://redis.com/redis-enterprise-cloud/overview/) - 관리형 Redis 서비스 -- 로컬 Redis 서버 +--- -### Redis 없이 실행하기 +## License -Redis 설정이 없어도 애플리케이션은 정상적으로 작동합니다. 다만, 캐싱 기능이 비활성화되어 모든 요청이 Dreamhack API를 직접 호출하게 됩니다. \ No newline at end of file +MIT License diff --git a/src/__tests__/pages/api/users-count.test.ts b/src/__tests__/pages/api/users-count.test.ts new file mode 100644 index 0000000..df00f09 --- /dev/null +++ b/src/__tests__/pages/api/users-count.test.ts @@ -0,0 +1,92 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '../../../pages/api/users-count'; + +// fetch 모킹 +global.fetch = jest.fn(); + +describe('users-count API 엔드포인트 테스트', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, GITHUB_TOKEN: 'test-token' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('고유 사용자 수를 shields.io 형식으로 반환해야 함', async () => { + const mockResponse = { + total_count: 3, + items: [ + { repository: { owner: { login: 'user1' } } }, + { repository: { owner: { login: 'user2' } } }, + { repository: { owner: { login: 'user1' } } }, // 중복 사용자 + ] + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse + }); + + const { req, res } = createMocks({ + method: 'GET', + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data).toEqual({ + schemaVersion: 1, + label: 'users', + message: '2', // 고유 사용자 2명 + color: 'blue' + }); + }); + + it('GITHUB_TOKEN이 없으면 500 에러를 반환해야 함', async () => { + delete process.env.GITHUB_TOKEN; + + const { req, res } = createMocks({ + method: 'GET', + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(500); + expect(JSON.parse(res._getData())).toEqual({ error: 'GitHub token not configured' }); + }); + + it('GitHub API 에러 시 해당 상태 코드를 반환해야 함', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Rate limit exceeded' + }); + + const { req, res } = createMocks({ + method: 'GET', + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(403); + expect(JSON.parse(res._getData())).toEqual({ error: 'GitHub API error' }); + }); + + it('fetch 예외 발생 시 500 에러를 반환해야 함', async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const { req, res } = createMocks({ + method: 'GET', + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(500); + expect(JSON.parse(res._getData())).toEqual({ error: 'Internal server error' }); + }); +}); diff --git a/src/pages/api/users-count.ts b/src/pages/api/users-count.ts new file mode 100644 index 0000000..9c5e8ad --- /dev/null +++ b/src/pages/api/users-count.ts @@ -0,0 +1,62 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +interface SearchItem { + repository: { + owner: { + login: string; + }; + }; +} + +interface SearchResponse { + total_count: number; + items: SearchItem[]; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const githubToken = process.env.GITHUB_TOKEN; + + if (!githubToken) { + return res.status(500).json({ error: 'GitHub token not configured' }); + } + + try { + const query = encodeURIComponent('"dreamhack-readme-stats.vercel.app/api/" in:file filename:README.md'); + const response = await fetch( + `https://api.github.com/search/code?q=${query}&per_page=100`, + { + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + } + ); + + if (!response.ok) { + const error = await response.text(); + console.error('GitHub API error:', error); + return res.status(response.status).json({ error: 'GitHub API error' }); + } + + const data: SearchResponse = await response.json(); + + // 고유 사용자 수 계산 + const uniqueUsers = new Set( + data.items.map(item => item.repository.owner.login) + ); + const count = uniqueUsers.size; + + // shields.io endpoint format + res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + return res.status(200).json({ + schemaVersion: 1, + label: 'users', + message: String(count), + color: 'blue' + }); + } catch (error) { + console.error('Error fetching user count:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}