Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/controllers/leaderboard.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logger from '@/configs/logger.config';
import { NextFunction, RequestHandler, Request, Response } from 'express';
import { LeaderboardService } from '@/services/leaderboard.service';
import {
GetUserLeaderboardQuery,
GetPostLeaderboardQuery,
UserLeaderboardResponseDto,
PostLeaderboardResponseDto,
} from '@/types/index';

export class LeaderboardController {
constructor(private leaderboardService: LeaderboardService) {}

getUserLeaderboard: RequestHandler = async (
req: Request<object, object, object, GetUserLeaderboardQuery>,
res: Response<UserLeaderboardResponseDto>,
next: NextFunction,
) => {
try {
const { sort, dateRange, limit } = req.query;

const users = await this.leaderboardService.getUserLeaderboard(sort, dateRange, limit);
const response = new UserLeaderboardResponseDto(true, '사용자 리더보드 조회에 성공하였습니다.', users, null);

res.status(200).json(response);
} catch (error) {
logger.error('사용자 리더보드 조회 실패:', error);
next(error);
}
};

getPostLeaderboard: RequestHandler = async (
req: Request<object, object, object, GetPostLeaderboardQuery>,
res: Response<PostLeaderboardResponseDto>,
next: NextFunction,
) => {
try {
const { sort, dateRange, limit } = req.query;

const posts = await this.leaderboardService.getPostLeaderboard(sort, dateRange, limit);
const response = new PostLeaderboardResponseDto(true, '게시물 리더보드 조회에 성공하였습니다.', posts, null);

res.status(200).json(response);
} catch (error) {
logger.error('게시물 리더보드 조회 실패:', error);
next(error);
}
};
}
196 changes: 196 additions & 0 deletions src/repositories/__test__/leaderboard.repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Pool, QueryResult } from 'pg';
import { DBError } from '@/exception';
import { LeaderboardRepository } from '@/repositories/leaderboard.repository';

jest.mock('pg');

// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성
function createMockQueryResult<T extends Record<string, unknown>>(rows: T[]): QueryResult<T> {
return {
rows,
rowCount: rows.length,
command: '',
oid: 0,
fields: [],
} satisfies QueryResult<T>;
}

const mockPool: {
query: jest.Mock<Promise<QueryResult<Record<string, unknown>>>, unknown[]>;
} = {
query: jest.fn(),
};

describe('LeaderboardRepository', () => {
let repo: LeaderboardRepository;

beforeEach(() => {
repo = new LeaderboardRepository(mockPool as unknown as Pool);
});

describe('getUserLeaderboard', () => {
it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
const mockResult = [
{
id: 1,
email: 'test@test.com',
total_views: 100,
total_likes: 50,
total_posts: 1,
view_diff: 20,
like_diff: 10,
post_diff: 1,
},
{
id: 2,
email: 'test2@test.com',
total_views: 200,
total_likes: 100,
total_posts: 2,
view_diff: 10,
like_diff: 5,
post_diff: 1,
},
];
mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));

const result = await repo.getUserLeaderboard('viewCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything());
expect(result).toEqual(mockResult);
});

it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
await repo.getUserLeaderboard('viewCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY view_diff DESC'),
expect.anything(),
);
});

it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
await repo.getUserLeaderboard('likeCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY like_diff DESC'),
expect.anything(),
);
});

it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
await repo.getUserLeaderboard('postCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY post_diff DESC'),
expect.anything(),
);
});

it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
const mockLimit = 5;

await repo.getUserLeaderboard('viewCount', 30, mockLimit);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2'),
expect.arrayContaining([30, mockLimit]),
);
});

it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
const mockDateRange = 30;

await repo.getUserLeaderboard('viewCount', mockDateRange, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('make_interval(days := $1::int)'),
expect.arrayContaining([mockDateRange, expect.anything()]),
);
});

it('에러 발생 시 DBError를 던져야 한다', async () => {
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
});
});

describe('getPostLeaderboard', () => {
it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
const mockResult = [
{
id: 2,
title: 'test2',
slug: 'test2',
total_views: 200,
total_likes: 100,
view_diff: 20,
like_diff: 10,
released_at: '2025-01-02',
},
{
id: 1,
title: 'test',
slug: 'test',
total_views: 100,
total_likes: 50,
view_diff: 10,
like_diff: 5,
released_at: '2025-01-01',
},
];

mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));

const result = await repo.getPostLeaderboard('viewCount', 30, 10);

expect(result).toEqual(mockResult);
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything());
});

it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
await repo.getPostLeaderboard('viewCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY view_diff DESC'),
expect.anything(),
);
});

it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
await repo.getPostLeaderboard('likeCount', 30, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('ORDER BY like_diff DESC'),
expect.anything(),
);
});

it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
const mockLimit = 5;

await repo.getPostLeaderboard('viewCount', 30, mockLimit);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2'),
expect.arrayContaining([30, mockLimit]),
);
});

it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
const mockDateRange = 30;

await repo.getPostLeaderboard('viewCount', mockDateRange, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('make_interval(days := $1::int)'),
expect.arrayContaining([mockDateRange, expect.anything()]),
);
});

it('에러 발생 시 DBError를 던져야 한다', async () => {
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
});
});
});
99 changes: 99 additions & 0 deletions src/repositories/leaderboard.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import logger from '@/configs/logger.config';
import { Pool } from 'pg';
import { DBError } from '@/exception';
import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index';

export class LeaderboardRepository {
constructor(private pool: Pool) {}

async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) {
try {
const cteQuery = this.buildLeaderboardCteQuery();
const sortCol = sort === 'postCount' ? 'post_diff' : sort === 'likeCount' ? 'like_diff' : 'view_diff';

const query = `
${cteQuery}
SELECT
u.id AS id,
u.email AS email,
COALESCE(SUM(ts.today_view), 0) AS total_views,
COALESCE(SUM(ts.today_like), 0) AS total_likes,
COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts,
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))) AS view_diff,
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))) AS like_diff,
COUNT(DISTINCT CASE WHEN p.released_at >= CURRENT_DATE - make_interval(days := $1::int) AND p.is_active = true THEN p.id END) AS post_diff
FROM users_user u
LEFT JOIN posts_post p ON p.user_id = u.id
LEFT JOIN today_stats ts ON ts.post_id = p.id
LEFT JOIN start_stats ss ON ss.post_id = p.id
WHERE u.email IS NOT NULL
GROUP BY u.id, u.email
ORDER BY ${sortCol} DESC
LIMIT $2;
`;
const result = await this.pool.query(query, [dateRange, limit]);

return result.rows;
} catch (error) {
logger.error(`Leaderboard Repo getUserLeaderboard error:`, error);
throw new DBError(`사용자 리더보드 조회 중 문제가 발생했습니다.`);
}
}

async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) {
try {
const cteQuery = this.buildLeaderboardCteQuery();
const sortCol = sort === 'viewCount' ? 'view_diff' : 'like_diff';

const query = `
${cteQuery}
SELECT
p.id AS id,
p.title,
p.slug,
p.released_at,
COALESCE(ts.today_view, 0) AS total_views,
COALESCE(ts.today_like, 0) AS total_likes,
COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)) AS view_diff,
COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)) AS like_diff
FROM posts_post p
LEFT JOIN today_stats ts ON ts.post_id = p.id
LEFT JOIN start_stats ss ON ss.post_id = p.id
WHERE p.is_active = true
ORDER BY ${sortCol} DESC
LIMIT $2;
`;
const result = await this.pool.query(query, [dateRange, limit]);

return result.rows;
} catch (error) {
logger.error(`Leaderboard Repo getPostLeaderboard error:`, error);
throw new DBError(`게시물 리더보드 조회 중 문제가 발생했습니다.`);
}
}

// 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드
private buildLeaderboardCteQuery() {
return `
WITH
today_stats AS (
SELECT DISTINCT ON (post_id)
post_id,
daily_view_count AS today_view,
daily_like_count AS today_like
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= (NOW() AT TIME ZONE 'UTC')::date
ORDER BY post_id, date DESC
),
start_stats AS (
SELECT DISTINCT ON (post_id)
post_id,
daily_view_count AS start_view,
daily_like_count AS start_like
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ((NOW() AT TIME ZONE 'UTC')::date - make_interval(days := $1::int))
ORDER BY post_id, date ASC
)
`;
}
}
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express, { Router } from 'express';
import UserRouter from './user.router';
import PostRouter from './post.router';
import NotiRouter from './noti.router';
import LeaderboardRouter from './leaderboard.router';

const router: Router = express.Router();

Expand All @@ -12,4 +13,5 @@ router.use('/ping', (req, res) => {
router.use('/', UserRouter);
router.use('/', PostRouter);
router.use('/', NotiRouter);
router.use('/', LeaderboardRouter);
export default router;
Loading