Skip to content

Commit 51a0162

Browse files
authored
Merge branch 'main' into feature/qrcode-app
2 parents c7e9019 + 20bcd49 commit 51a0162

File tree

12 files changed

+1115
-65
lines changed

12 files changed

+1115
-65
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import logger from '@/configs/logger.config';
2+
import { NextFunction, RequestHandler, Request, Response } from 'express';
3+
import { LeaderboardService } from '@/services/leaderboard.service';
4+
import {
5+
GetUserLeaderboardQuery,
6+
GetPostLeaderboardQuery,
7+
UserLeaderboardResponseDto,
8+
PostLeaderboardResponseDto,
9+
} from '@/types/index';
10+
11+
export class LeaderboardController {
12+
constructor(private leaderboardService: LeaderboardService) {}
13+
14+
getUserLeaderboard: RequestHandler = async (
15+
req: Request<object, object, object, GetUserLeaderboardQuery>,
16+
res: Response<UserLeaderboardResponseDto>,
17+
next: NextFunction,
18+
) => {
19+
try {
20+
const { sort, dateRange, limit } = req.query;
21+
22+
const users = await this.leaderboardService.getUserLeaderboard(sort, dateRange, limit);
23+
const response = new UserLeaderboardResponseDto(true, '사용자 리더보드 조회에 성공하였습니다.', users, null);
24+
25+
res.status(200).json(response);
26+
} catch (error) {
27+
logger.error('사용자 리더보드 조회 실패:', error);
28+
next(error);
29+
}
30+
};
31+
32+
getPostLeaderboard: RequestHandler = async (
33+
req: Request<object, object, object, GetPostLeaderboardQuery>,
34+
res: Response<PostLeaderboardResponseDto>,
35+
next: NextFunction,
36+
) => {
37+
try {
38+
const { sort, dateRange, limit } = req.query;
39+
40+
const posts = await this.leaderboardService.getPostLeaderboard(sort, dateRange, limit);
41+
const response = new PostLeaderboardResponseDto(true, '게시물 리더보드 조회에 성공하였습니다.', posts, null);
42+
43+
res.status(200).json(response);
44+
} catch (error) {
45+
logger.error('게시물 리더보드 조회 실패:', error);
46+
next(error);
47+
}
48+
};
49+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Pool, QueryResult } from 'pg';
2+
import { DBError } from '@/exception';
3+
import { LeaderboardRepository } from '@/repositories/leaderboard.repository';
4+
5+
jest.mock('pg');
6+
7+
// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성
8+
function createMockQueryResult<T extends Record<string, unknown>>(rows: T[]): QueryResult<T> {
9+
return {
10+
rows,
11+
rowCount: rows.length,
12+
command: '',
13+
oid: 0,
14+
fields: [],
15+
} satisfies QueryResult<T>;
16+
}
17+
18+
const mockPool: {
19+
query: jest.Mock<Promise<QueryResult<Record<string, unknown>>>, unknown[]>;
20+
} = {
21+
query: jest.fn(),
22+
};
23+
24+
describe('LeaderboardRepository', () => {
25+
let repo: LeaderboardRepository;
26+
27+
beforeEach(() => {
28+
repo = new LeaderboardRepository(mockPool as unknown as Pool);
29+
});
30+
31+
describe('getUserLeaderboard', () => {
32+
it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
33+
const mockResult = [
34+
{
35+
id: '1',
36+
37+
total_views: 100,
38+
total_likes: 50,
39+
total_posts: 1,
40+
view_diff: 20,
41+
like_diff: 10,
42+
post_diff: 1,
43+
},
44+
{
45+
id: '2',
46+
47+
total_views: 200,
48+
total_likes: 100,
49+
total_posts: 2,
50+
view_diff: 10,
51+
like_diff: 5,
52+
post_diff: 1,
53+
},
54+
];
55+
mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));
56+
57+
const result = await repo.getUserLeaderboard('viewCount', 30, 10);
58+
59+
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything());
60+
expect(result).toEqual(mockResult);
61+
});
62+
63+
it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
64+
await repo.getUserLeaderboard('viewCount', 30, 10);
65+
66+
expect(mockPool.query).toHaveBeenCalledWith(
67+
expect.stringContaining('ORDER BY view_diff DESC'),
68+
expect.anything(),
69+
);
70+
});
71+
72+
it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
73+
await repo.getUserLeaderboard('likeCount', 30, 10);
74+
75+
expect(mockPool.query).toHaveBeenCalledWith(
76+
expect.stringContaining('ORDER BY like_diff DESC'),
77+
expect.anything(),
78+
);
79+
});
80+
81+
it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
82+
await repo.getUserLeaderboard('postCount', 30, 10);
83+
84+
expect(mockPool.query).toHaveBeenCalledWith(
85+
expect.stringContaining('ORDER BY post_diff DESC'),
86+
expect.anything(),
87+
);
88+
});
89+
90+
it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
91+
const mockLimit = 5;
92+
93+
await repo.getUserLeaderboard('viewCount', 30, mockLimit);
94+
95+
expect(mockPool.query).toHaveBeenCalledWith(
96+
expect.stringContaining('LIMIT $2'),
97+
expect.arrayContaining([30, mockLimit]),
98+
);
99+
});
100+
101+
it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
102+
const mockDateRange = 30;
103+
104+
await repo.getUserLeaderboard('viewCount', mockDateRange, 10);
105+
106+
expect(mockPool.query).toHaveBeenCalledWith(
107+
expect.stringContaining('make_interval(days := $1::int)'),
108+
expect.arrayContaining([mockDateRange, expect.anything()]),
109+
);
110+
});
111+
112+
it('에러 발생 시 DBError를 던져야 한다', async () => {
113+
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
114+
await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
115+
});
116+
});
117+
118+
describe('getPostLeaderboard', () => {
119+
it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
120+
const mockResult = [
121+
{
122+
id: '2',
123+
title: 'test2',
124+
slug: 'test2',
125+
total_views: 200,
126+
total_likes: 100,
127+
view_diff: 20,
128+
like_diff: 10,
129+
released_at: '2025-01-02',
130+
},
131+
{
132+
id: '1',
133+
title: 'test',
134+
slug: 'test',
135+
total_views: 100,
136+
total_likes: 50,
137+
view_diff: 10,
138+
like_diff: 5,
139+
released_at: '2025-01-01',
140+
},
141+
];
142+
143+
mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));
144+
145+
const result = await repo.getPostLeaderboard('viewCount', 30, 10);
146+
147+
expect(result).toEqual(mockResult);
148+
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything());
149+
});
150+
151+
it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
152+
await repo.getPostLeaderboard('viewCount', 30, 10);
153+
154+
expect(mockPool.query).toHaveBeenCalledWith(
155+
expect.stringContaining('ORDER BY view_diff DESC'),
156+
expect.anything(),
157+
);
158+
});
159+
160+
it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
161+
await repo.getPostLeaderboard('likeCount', 30, 10);
162+
163+
expect(mockPool.query).toHaveBeenCalledWith(
164+
expect.stringContaining('ORDER BY like_diff DESC'),
165+
expect.anything(),
166+
);
167+
});
168+
169+
it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
170+
const mockLimit = 5;
171+
172+
await repo.getPostLeaderboard('viewCount', 30, mockLimit);
173+
174+
expect(mockPool.query).toHaveBeenCalledWith(
175+
expect.stringContaining('LIMIT $2'),
176+
expect.arrayContaining([30, mockLimit]),
177+
);
178+
});
179+
180+
it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
181+
const mockDateRange = 30;
182+
183+
await repo.getPostLeaderboard('viewCount', mockDateRange, 10);
184+
185+
expect(mockPool.query).toHaveBeenCalledWith(
186+
expect.stringContaining('make_interval(days := $1::int)'),
187+
expect.arrayContaining([mockDateRange, expect.anything()]),
188+
);
189+
});
190+
191+
it('에러 발생 시 DBError를 던져야 한다', async () => {
192+
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
193+
await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)