From 72cdb9d9a33ef34df7e2066d57bac981db8b22a6 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 21 Apr 2025 12:46:25 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feature:=20Leaderboard=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8C=8C=EC=9D=BC=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/leaderboard.controller.ts | 14 ++ src/repositories/leaderboard.repository.ts | 5 + src/routes/index.ts | 2 + src/routes/leaderboard.router.ts | 51 +++++++ src/services/leaderboard.service.ts | 5 + .../dto/requests/getLeaderboardQuery.type.ts | 98 ++++++++++++++ .../dto/responses/leaderboardResponse.type.ts | 125 ++++++++++++++++++ 7 files changed, 300 insertions(+) create mode 100644 src/controllers/leaderboard.controller.ts create mode 100644 src/repositories/leaderboard.repository.ts create mode 100644 src/routes/leaderboard.router.ts create mode 100644 src/services/leaderboard.service.ts create mode 100644 src/types/dto/requests/getLeaderboardQuery.type.ts create mode 100644 src/types/dto/responses/leaderboardResponse.type.ts diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts new file mode 100644 index 0000000..5081bf6 --- /dev/null +++ b/src/controllers/leaderboard.controller.ts @@ -0,0 +1,14 @@ +import { LeaderboardService } from '@/services/leaderboard.service'; +import { NextFunction, RequestHandler, Request, Response } from 'express'; + +export class LeaderboardController { + constructor(private leaderboardService: LeaderboardService) {} + + getLeaderboard: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + try { + res.status(200).json({ message: 'ok' }); + } catch (error) { + next(error); + } + }; +} diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts new file mode 100644 index 0000000..0cb32dc --- /dev/null +++ b/src/repositories/leaderboard.repository.ts @@ -0,0 +1,5 @@ +import { Pool } from 'pg'; + +export class LeaderboardRepository { + constructor(private pool: Pool) {} +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 351fecc..a2c1a7a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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(); @@ -12,4 +13,5 @@ router.use('/ping', (req, res) => { router.use('/', UserRouter); router.use('/', PostRouter); router.use('/', NotiRouter); +router.use('/', LeaderboardRouter); export default router; diff --git a/src/routes/leaderboard.router.ts b/src/routes/leaderboard.router.ts new file mode 100644 index 0000000..1ebceb9 --- /dev/null +++ b/src/routes/leaderboard.router.ts @@ -0,0 +1,51 @@ +import express, { Router } from 'express'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import pool from '@/configs/db.config'; +import { LeaderboardService } from '@/services/leaderboard.service'; +import { LeaderboardController } from '@/controllers/leaderboard.controller'; +import { validateRequestDto } from '@/middlewares/validation.middleware'; +import { GetLeaderboardQueryDto } from '@/types/dto/requests/getLeaderboardQuery.type'; + +const router: Router = express.Router(); + +const leaderboardRepository = new LeaderboardRepository(pool); +const leaderboardService = new LeaderboardService(leaderboardRepository); +const leaderboardController = new LeaderboardController(leaderboardService); + +/** + * @swagger + * /leaderboard: + * get: + * summary: 리더보드 조회 + * tags: + * - Leaderboard + * parameters: + * - in: query + * name: type + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/type' + * - in: query + * name: sort + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/sort' + * - in: query + * name: dateRange + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/dateRange' + * - in: query + * name: limit + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/limit' + * responses: + * '200': + * description: 리더보드 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LeaderboardResponseDto' + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.get('/leaderboard', validateRequestDto(GetLeaderboardQueryDto, 'query'), leaderboardController.getLeaderboard); + +export default router; diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts new file mode 100644 index 0000000..3a7acef --- /dev/null +++ b/src/services/leaderboard.service.ts @@ -0,0 +1,5 @@ +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; + +export class LeaderboardService { + constructor(private leaderboardRepo: LeaderboardRepository) {} +} diff --git a/src/types/dto/requests/getLeaderboardQuery.type.ts b/src/types/dto/requests/getLeaderboardQuery.type.ts new file mode 100644 index 0000000..1e48bed --- /dev/null +++ b/src/types/dto/requests/getLeaderboardQuery.type.ts @@ -0,0 +1,98 @@ +import { Transform } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +/** + * @swagger + * components: + * schemas: + * LeaderboardType: + * type: string + * description: 리더보드 조회 타입 + * nullable: true + * enum: ['user', 'post'] + * default: 'user' + */ +export type LeaderboardType = 'user' | 'post'; + +/** + * @swagger + * components: + * schemas: + * LeaderboardSortType: + * type: string + * description: 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount', 'postCount'] + * default: 'viewCount' + */ +export type LeaderboardSortType = 'viewCount' | 'likeCount' | 'postCount'; + +export interface GetLeaderboardQuery { + type?: LeaderboardType; + sort?: LeaderboardSortType; + dateRange?: number; + limit?: number; +} + +/** + * @swagger + * components: + * schemas: + * GetLeaderboardQueryDto: + * type: object + * properties: + * type: + * $ref: '#/components/schemas/LeaderboardType' + * description: 리더보드 조회 타입 + * nullable: true + * default: 'user' + * sort: + * $ref: '#/components/schemas/LeaderboardSortType' + * description: 리더보드 정렬 기준 + * nullable: true + * default: 'viewCount' + * dateRange: + * type: number + * description: 리더보드 조회 기간 (일수) + * nullable: true + * default: 30 + * minimum: 1 + * maximum: 30 + * limit: + * type: number + * description: 리더보드 조회 제한 수 + * nullable: true + * default: 10 + * minimum: 1 + * maximum: 30 + */ +export class GetLeaderboardQueryDto { + @IsOptional() + @IsString() + type?: LeaderboardType; + + @IsOptional() + @IsString() + sort?: LeaderboardSortType; + + @IsOptional() + @IsNumber() + @Transform(({ value }) => Number(value)) + @Min(1) + @Max(30) + dateRange?: number; + + @IsOptional() + @IsNumber() + @Transform(({ value }) => Number(value)) + @Min(1) + @Max(30) + limit?: number; + + constructor(type?: LeaderboardType, sort?: LeaderboardSortType, dateRange?: number, limit?: number) { + this.type = type; + this.sort = sort; + this.dateRange = dateRange; + this.limit = limit; + } +} diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts new file mode 100644 index 0000000..b35a50b --- /dev/null +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -0,0 +1,125 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + +/** + * @swagger + * components: + * schemas: + * LeaderboardUserType: + * type: object + * properties: + * id: + * type: integer + * description: 사용자 ID + * email: + * type: string + * description: 사용자 이메일 + * totalViews: + * type: integer + * description: 누적 조회수 + * totalLikes: + * type: integer + * description: 누적 좋아요 수 + * totalPosts: + * type: integer + * description: 누적 게시물 수 + * viewDiff: + * type: integer + * description: 구간 조회수 상승값 + * likeDiff: + * type: integer + * description: 구간 좋아요 수 상승값 + * postDiff: + * type: integer + * description: 구간 게시물 수 상승값 + */ +export interface LeaderboardUserType { + id: number; + email: string; + totalViews: number; + totalLikes: number; + totalPosts: number; + viewDiff: number; + likeDiff: number; + postDiff: number; +} + +/** + * @swagger + * components: + * schemas: + * LeaderboardPostType: + * type: object + * properties: + * id: + * type: integer + * description: 게시물 ID + * title: + * type: string + * description: 게시물 제목 + * slug: + * type: string + * description: 게시물 url slug 값 + * totalViews: + * type: integer + * description: 누적 조회수 + * totalLikes: + * type: integer + * description: 누적 좋아요 수 + * viewDiff: + * type: integer + * description: 구간 조회수 상승값 + * likeDiff: + * type: integer + * description: 구간 좋아요 수 상승값 + * releasedAt: + * type: string + * format: date-time + * description: 게시물 업로드 일시 + */ +export interface LeaderboardPostType { + id: number; + title: string; + slug: string; + totalViews: number; + totalLikes: number; + viewDiff: number; + likeDiff: number; + releasedAt: string; +} + +/** + * @swagger + * components: + * schemas: + * LeaderboardResponseData: + * type: object + * properties: + * posts: + * type: array + * nullable: true + * items: + * $ref: '#/components/schemas/LeaderboardPostType' + * users: + * type: array + * nullable: true + * items: + * $ref: '#/components/schemas/LeaderboardUserType' + */ +export interface LeaderboardResponseData { + users: LeaderboardUserType[] | null; + posts: LeaderboardPostType[] | null; +} + +/** + * @swagger + * components: + * schemas: + * LeaderboardResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * $ref: '#/components/schemas/LeaderboardResponseData' + */ +export class LeaderboardResponseDto extends BaseResponseDto {} From 8f8feb30eb23c3f75f3eab80c36be76132f0cc9a Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 21 Apr 2025 21:57:47 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feature:=20Leaderboard=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/leaderboard.controller.ts | 17 ++- src/repositories/leaderboard.repository.ts | 150 +++++++++++++++++++++ src/services/leaderboard.service.ts | 49 +++++++ 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts index 5081bf6..3ef93b7 100644 --- a/src/controllers/leaderboard.controller.ts +++ b/src/controllers/leaderboard.controller.ts @@ -1,13 +1,26 @@ +import logger from '@/configs/logger.config'; import { LeaderboardService } from '@/services/leaderboard.service'; +import { GetLeaderboardQuery } from '@/types/dto/requests/getLeaderboardQuery.type'; +import { LeaderboardResponseDto } from '@/types/dto/responses/leaderboardResponse.type'; import { NextFunction, RequestHandler, Request, Response } from 'express'; export class LeaderboardController { constructor(private leaderboardService: LeaderboardService) {} - getLeaderboard: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { + getLeaderboard: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { try { - res.status(200).json({ message: 'ok' }); + const { type, sort, dateRange, limit } = req.query; + + const result = await this.leaderboardService.getLeaderboard(type, sort, dateRange, limit); + const response = new LeaderboardResponseDto(true, '리더보드 조회에 성공하였습니다.', result, null); + + res.status(200).json(response); } catch (error) { + logger.error('리더보드 조회 실패:', error); next(error); } }; diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 0cb32dc..c33629b 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -1,5 +1,155 @@ import { Pool } from 'pg'; +import logger from '@/configs/logger.config'; +import { DBError } from '@/exception'; export class LeaderboardRepository { constructor(private pool: Pool) {} + + async getLeaderboard(type: string, sort: string, dateRange: number, limit: number) { + try { + const cteQuery = this.buildLeaderboardCteQuery(); + const selectQuery = this.buildLeaderboardSelectQuery(type); + const fromClause = this.buildLeaderboardFromClause(type); + const sortCol = this.mapSortColByType(sort, type); + const groupOrderClause = this.buildLeaderboardGroupOrderClause(sortCol, type); + + const query = `${cteQuery} ${selectQuery} ${fromClause} ${groupOrderClause}`; + const values = await this.pool.query(query, [dateRange, limit]); + + return values.rows; + } catch (error) { + logger.error(`Leaderboard Repo getLeaderboard error:`, error); + throw new DBError(`${type === 'user' ? '유저' : '게시글'} 리더보드 조회 중 문제가 발생했습니다.`); + } + } + + // 오늘 날짜와 기준 날짜의 통계를 가져오는 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 - ($1::int * INTERVAL '1 day')) + ORDER BY post_id, date ASC + ) + `; + } + + // 메인 연산을 포함하는 SELECT 절 빌드 + private buildLeaderboardSelectQuery(type: string) { + if (type === 'post') { + return ` + 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, + + -- 조회수 / 좋아요 수 상승값 = 오늘 - 기준일 (기준일이 없으면 diff = 0) + 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 + `; + } else { + return ` + 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, + + -- 전체 게시물 조회수 / 좋아요 수 상승값 합계 = 오늘 - 기준일 (기준일이 없으면 diff = 0) + 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, + + -- 최근 dateRange내 업로드된 게시물 수 + COUNT(DISTINCT CASE + WHEN p.released_at >= CURRENT_DATE - $1::int + AND p.is_active = true + THEN p.id + END) AS post_diff, + + -- 전체 활성 게시물 수 + COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts + `; + } + } + + // CTE 테이블 조인 및 WHERE 절을 포함하는 FROM 절 빌드 + private buildLeaderboardFromClause(type: string) { + if (type === 'post') { + return ` + 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 + `; + } else { + return ` + 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 + `; + } + } + + // sort 매개변수를 정렬 컬럼으로 매핑 + private mapSortColByType(sort: string, type: string) { + let sortCol = ''; + + switch (sort) { + case 'postCount': + sortCol = type === 'user' ? 'post_diff' : 'view_diff'; + break; + case 'likeCount': + sortCol = 'like_diff'; + break; + case 'viewCount': + default: + sortCol = 'view_diff'; + break; + } + + return sortCol; + } + + // 매핑된 정렬 컬럼으로 ORDER BY 절 및 LIMIT 절 빌드 + private buildLeaderboardGroupOrderClause(sortCol: string, type: string) { + if (type === 'post') { + return ` + ORDER BY ${sortCol} DESC + LIMIT $2; + `; + } else { + return ` + GROUP BY u.id + ORDER BY ${sortCol} DESC + LIMIT $2; + `; + } + } } diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index 3a7acef..26b06fc 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,5 +1,54 @@ +import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { LeaderboardResponseData } from '@/types/dto/responses/leaderboardResponse.type'; export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} + + async getLeaderboard( + type: string = 'user', + sort: string = 'viewCount', + dateRange: number = 30, + limit: number = 10, + ): Promise { + try { + const rawResult = await this.leaderboardRepo.getLeaderboard(type, sort, dateRange, limit); + const result = this.mapRawResultToLeaderboardResponseData(rawResult, type); + + return result; + } catch (error) { + logger.error('LeaderboardService getLeaderboard error : ', error); + throw error; + } + } + + private mapRawResultToLeaderboardResponseData(rawResult: unknown[], type: string): LeaderboardResponseData { + const result = { posts: null, users: null }; + + if (type === 'post') { + result.posts = rawResult.map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + totalViews: post.total_views, + totalLikes: post.total_likes, + viewDiff: post.view_diff, + likeDiff: post.like_diff, + releasedAt: post.released_at, + })); + } else { + result.users = rawResult.map((user) => ({ + id: user.id, + email: user.email, + totalViews: user.total_views, + totalLikes: user.total_likes, + totalPosts: user.total_posts, + viewDiff: user.view_diff, + likeDiff: user.like_diff, + postDiff: user.post_diff, + })); + } + + return result; + } } From 3b26d44bd0e98d792b71ef953618cadb2658cb98 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 00:00:11 +0900 Subject: [PATCH 03/14] =?UTF-8?q?modify:=20Leaderboard=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=8B=9C=20type=20=EB=B6=84=EA=B8=B0=EB=8A=94=20post?= =?UTF-8?q?=EB=A5=BC=20=EB=A8=BC=EC=A0=80=20=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index c33629b..10e8dc3 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -19,7 +19,7 @@ export class LeaderboardRepository { return values.rows; } catch (error) { logger.error(`Leaderboard Repo getLeaderboard error:`, error); - throw new DBError(`${type === 'user' ? '유저' : '게시글'} 리더보드 조회 중 문제가 발생했습니다.`); + throw new DBError(`${type === 'post' ? '게시글' : '유저'} 리더보드 조회 중 문제가 발생했습니다.`); } } From 9a194ddcca15b177b3a8b59b27fc54e93bdd4b8a Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 00:01:15 +0900 Subject: [PATCH 04/14] =?UTF-8?q?test:=20Leaderboard=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EC=84=9C=EB=B9=84=EC=8A=A4,=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EA=B3=84=EC=B8=B5=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/leaderboard.repo.test.ts | 271 ++++++++++++++++++ .../__test__/leaderboard.service.test.ts | 160 +++++++++++ 2 files changed, 431 insertions(+) create mode 100644 src/repositories/__test__/leaderboard.repo.test.ts create mode 100644 src/services/__test__/leaderboard.service.test.ts diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts new file mode 100644 index 0000000..062a9f8 --- /dev/null +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -0,0 +1,271 @@ +import { DBError } from '@/exception'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { Pool, QueryResult } from 'pg'; + +jest.mock('pg'); + +const mockPool: { + query: jest.Mock>>, unknown[]>; +} = { + query: jest.fn(), +}; + +describe('LeaderboardRepository', () => { + let repo: LeaderboardRepository; + + beforeEach(() => { + repo = new LeaderboardRepository(mockPool as unknown as Pool); + }); + + describe('getLeaderboard', () => { + it('type이 post인 경우 post 데이터를 반환해야 한다.', 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({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('post', 'viewCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything()); + }); + + it('type이 user인 경우 user 데이터를 반환해야 한다.', 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({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything()); + expect(result).toEqual(mockResult); + }); + + it('sort가 조회수인 경우 정렬 순서를 보장해야 한다.', async () => { + const mockResult = [ + { view_diff: 20, like_diff: 5, post_diff: 1 }, + { view_diff: 10, like_diff: 10, post_diff: 2 }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(result[0].view_diff).toBeGreaterThan(result[1].view_diff); + }); + + it('sort가 좋아요 수인 경우 정렬 순서를 보장해야 한다.', async () => { + const mockResult = [ + { view_diff: 10, like_diff: 10, post_diff: 1 }, + { view_diff: 20, like_diff: 5, post_diff: 1 }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('user', 'likeCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(result[0].like_diff).toBeGreaterThan(result[1].like_diff); + }); + + it('sort가 게시물 수인 경우 정렬 순서를 보장해야 한다.', async () => { + const mockResult = [ + { view_diff: 10, like_diff: 10, post_diff: 4 }, + { view_diff: 20, like_diff: 5, post_diff: 1 }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('user', 'postCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(result[0].post_diff).toBeGreaterThan(result[1].post_diff); + }); + + it('limit 만큼의 데이터만 반환해야 한다', async () => { + const mockData = [ + { id: 1, title: 'test' }, + { id: 2, title: 'test2' }, + { id: 3, title: 'test3' }, + { id: 4, title: 'test4' }, + { id: 5, title: 'test5' }, + ]; + const mockLimit = 5; + + mockPool.query.mockResolvedValue({ + rows: mockData, + rowCount: mockData.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('post', 'viewCount', 30, mockLimit); + + expect(result).toEqual(mockData); + expect(result.length).toEqual(mockLimit); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('LIMIT $2'), + expect.arrayContaining([30, mockLimit]), + ); + }); + + it('type이 post이고 sort가 게시물 수인 경우 조회수를 기준으로 정렬해야 한다.', async () => { + const mockResult = [ + { total_views: 200, total_likes: 5, view_diff: 20, like_diff: 0 }, + { total_views: 100, total_likes: 50, view_diff: 10, like_diff: 5 }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('post', 'postCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY view_diff DESC'), + expect.anything(), + ); + expect(result[0].view_diff).toBeGreaterThan(result[1].view_diff); + }); + + it('user 타입에는 GROUP BY 절이 포함되어야 한다', async () => { + mockPool.query.mockResolvedValue({ + rows: [], + rowCount: 0, + } as unknown as QueryResult); + + await repo.getLeaderboard('user', 'viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id'), expect.anything()); + }); + + it('post 타입에는 GROUP BY 절이 포함되지 않아야 한다', async () => { + mockPool.query.mockResolvedValue({ + rows: [], + rowCount: 0, + } as unknown as QueryResult); + + await repo.getLeaderboard('post', 'viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.not.stringContaining('GROUP BY'), expect.anything()); + }); + + it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockResult = [{ id: 1 }]; + const testDateRange = 30; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + await repo.getLeaderboard('user', 'viewCount', testDateRange, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('$1::int'), + expect.arrayContaining([testDateRange, expect.anything()]), + ); + }); + + it('유효하지 않은 sort 값이 전달되면 기본값(view_diff)을 사용해야 한다', async () => { + const mockResult = [{ view_diff: 10 }]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + await repo.getLeaderboard('user', 'invalidSort', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('view_diff DESC'), expect.anything()); + }); + + it('유효하지 않은 type 값이 전달되면 기본값(user)을 사용해야 한다', async () => { + const mockResult = [{ view_diff: 10 }]; + + mockPool.query.mockResolvedValue({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('invalidType', 'viewCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything()); + }); + + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { + mockPool.query.mockResolvedValue({ + rows: [], + rowCount: 0, + } as unknown as QueryResult); + + const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + + expect(result).toEqual([]); + }); + + it('에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + await expect(repo.getLeaderboard('post', 'postCount', 30, 10)).rejects.toThrow(DBError); + }); + }); +}); diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts new file mode 100644 index 0000000..f9f2ec9 --- /dev/null +++ b/src/services/__test__/leaderboard.service.test.ts @@ -0,0 +1,160 @@ +import { DBError } from '@/exception'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { LeaderboardService } from '@/services/leaderboard.service'; +import { Pool } from 'pg'; + +jest.mock('@/repositories/leaderboard.repository'); + +describe('LeaderboardService', () => { + let service: LeaderboardService; + let repo: jest.Mocked; + let mockPool: jest.Mocked; + + beforeEach(() => { + const mockPoolObj = {}; + mockPool = mockPoolObj as jest.Mocked; + + const repoInstance = new LeaderboardRepository(mockPool); + repo = repoInstance as jest.Mocked; + + service = new LeaderboardService(repo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getLeaderboard', () => { + it('type이 user인 경우 posts는 null이고, users는 응답 형식에 맞게 변환되어야 한다', async () => { + const mockRawResult = [ + { + 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, + }, + ]; + + const mockResult = { + posts: null, + users: [ + { + id: 1, + email: 'test@test.com', + totalViews: 100, + totalLikes: 50, + totalPosts: 1, + viewDiff: 20, + likeDiff: 10, + postDiff: 1, + }, + { + id: 2, + email: 'test2@test.com', + totalViews: 200, + totalLikes: 100, + totalPosts: 2, + viewDiff: 10, + likeDiff: 5, + postDiff: 1, + }, + ], + }; + + repo.getLeaderboard.mockResolvedValue(mockRawResult); + + const result = await service.getLeaderboard('user'); + + expect(result.posts).toBeNull(); + expect(result.users).toEqual(mockResult.users); + }); + + it('type이 post인 경우 users는 null이고, posts는 응답 형식에 맞게 변환되어야 한다', async () => { + const mockRawResult = [ + { + id: 1, + title: 'test', + slug: 'test-slug', + total_views: 100, + total_likes: 50, + view_diff: 20, + like_diff: 10, + released_at: '2025-01-01', + }, + { + id: 2, + title: 'test2', + slug: 'test2-slug', + total_views: 200, + total_likes: 100, + view_diff: 10, + like_diff: 5, + released_at: '2025-01-02', + }, + ]; + + const mockResult = { + posts: [ + { + id: 1, + title: 'test', + slug: 'test-slug', + totalViews: 100, + totalLikes: 50, + viewDiff: 20, + likeDiff: 10, + releasedAt: '2025-01-01', + }, + { + id: 2, + title: 'test2', + slug: 'test2-slug', + totalViews: 200, + totalLikes: 100, + viewDiff: 10, + likeDiff: 5, + releasedAt: '2025-01-02', + }, + ], + users: null, + }; + + repo.getLeaderboard.mockResolvedValue(mockRawResult); + + const result = await service.getLeaderboard('post'); + + expect(result.users).toBeNull(); + expect(result.posts).toEqual(mockResult.posts); + }); + + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { + repo.getLeaderboard.mockResolvedValue([]); + await service.getLeaderboard(); + + expect(repo.getLeaderboard).toHaveBeenCalledWith('user', 'viewCount', 30, 10); + }); + + it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { + const errorMessage = '유저 리더보드 조회 중 문제가 발생했습니다.'; + const dbError = new DBError(errorMessage); + repo.getLeaderboard.mockRejectedValue(dbError); + + await expect(service.getLeaderboard()).rejects.toThrow(errorMessage); + expect(repo.getLeaderboard).toHaveBeenCalledTimes(1); + }); + }); +}); From 4d79f7deb3dd32534624d621eec7c76873350c35 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 00:01:58 +0900 Subject: [PATCH 05/14] =?UTF-8?q?modify:=20Leaderboard=20Repo=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EC=8B=9C=20=EC=98=A4=EB=8A=94=20RawResult=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit any로 두었을 때 lint 검증 및 실행은 잘 되었으나, 테스트 코드 작성시 에러 나서 내부에 정의 --- src/services/leaderboard.service.ts | 74 ++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index 26b06fc..c05769a 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,6 +1,10 @@ import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -import { LeaderboardResponseData } from '@/types/dto/responses/leaderboardResponse.type'; +import { + LeaderboardPostType, + LeaderboardResponseData, + LeaderboardUserType, +} from '@/types/dto/responses/leaderboardResponse.type'; export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} @@ -23,32 +27,58 @@ export class LeaderboardService { } private mapRawResultToLeaderboardResponseData(rawResult: unknown[], type: string): LeaderboardResponseData { - const result = { posts: null, users: null }; + const result: LeaderboardResponseData = { posts: null, users: null }; if (type === 'post') { - result.posts = rawResult.map((post) => ({ - id: post.id, - title: post.title, - slug: post.slug, - totalViews: post.total_views, - totalLikes: post.total_likes, - viewDiff: post.view_diff, - likeDiff: post.like_diff, - releasedAt: post.released_at, - })); + result.posts = (rawResult as RawPostResult[]).map( + (post): LeaderboardPostType => ({ + id: post.id, + title: post.title, + slug: post.slug, + totalViews: post.total_views, + totalLikes: post.total_likes, + viewDiff: post.view_diff, + likeDiff: post.like_diff, + releasedAt: post.released_at, + }), + ); } else { - result.users = rawResult.map((user) => ({ - id: user.id, - email: user.email, - totalViews: user.total_views, - totalLikes: user.total_likes, - totalPosts: user.total_posts, - viewDiff: user.view_diff, - likeDiff: user.like_diff, - postDiff: user.post_diff, - })); + result.users = (rawResult as RawUserResult[]).map( + (user): LeaderboardUserType => ({ + id: user.id, + email: user.email, + totalViews: user.total_views, + totalLikes: user.total_likes, + totalPosts: user.total_posts, + viewDiff: user.view_diff, + likeDiff: user.like_diff, + postDiff: user.post_diff, + }), + ); } return result; } } + +interface RawPostResult { + id: number; + title: string; + slug: string; + total_views: number; + total_likes: number; + view_diff: number; + like_diff: number; + released_at: string; +} + +interface RawUserResult { + id: number; + email: string; + total_views: number; + total_likes: number; + total_posts: number; + view_diff: number; + like_diff: number; + post_diff: number; +} From 4765e250895fe62eda8a05c28bdb9f5d29724b19 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 00:07:45 +0900 Subject: [PATCH 06/14] =?UTF-8?q?test:=20Leaderboard=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/__test__/leaderboard.service.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index f9f2ec9..edd5666 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -148,6 +148,13 @@ describe('LeaderboardService', () => { expect(repo.getLeaderboard).toHaveBeenCalledWith('user', 'viewCount', 30, 10); }); + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { + repo.getLeaderboard.mockResolvedValue([]); + const result = await service.getLeaderboard(); + + expect(result).toEqual({ users: [], posts: null }); + }); + it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { const errorMessage = '유저 리더보드 조회 중 문제가 발생했습니다.'; const dbError = new DBError(errorMessage); From d99696ee651ba62147d3108bb2fd6ca5f27c3118 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 21:56:06 +0900 Subject: [PATCH 07/14] =?UTF-8?q?modify:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유저 리더보드 조회시 GROUP BY절에 user.email 추가 타입 안정성 강화: DTO에 유효성 검증 추가 및 관련 코드 수정에 따른 테스트 케이스 삭제 --- .../__test__/leaderboard.repo.test.ts | 29 +------------------ src/repositories/leaderboard.repository.ts | 13 +++++---- src/services/leaderboard.service.ts | 11 ++++--- .../dto/requests/getLeaderboardQuery.type.ts | 8 +++-- 4 files changed, 20 insertions(+), 41 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 062a9f8..98337e0 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -194,7 +194,7 @@ describe('LeaderboardRepository', () => { await repo.getLeaderboard('user', 'viewCount', 30, 10); - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id'), expect.anything()); + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id, u.email'), expect.anything()); }); it('post 타입에는 GROUP BY 절이 포함되지 않아야 한다', async () => { @@ -225,33 +225,6 @@ describe('LeaderboardRepository', () => { ); }); - it('유효하지 않은 sort 값이 전달되면 기본값(view_diff)을 사용해야 한다', async () => { - const mockResult = [{ view_diff: 10 }]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); - - await repo.getLeaderboard('user', 'invalidSort', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('view_diff DESC'), expect.anything()); - }); - - it('유효하지 않은 type 값이 전달되면 기본값(user)을 사용해야 한다', async () => { - const mockResult = [{ view_diff: 10 }]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); - - const result = await repo.getLeaderboard('invalidType', 'viewCount', 30, 10); - - expect(result).toEqual(mockResult); - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything()); - }); - it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { mockPool.query.mockResolvedValue({ rows: [], diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 10e8dc3..34776a3 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -1,11 +1,12 @@ import { Pool } from 'pg'; import logger from '@/configs/logger.config'; import { DBError } from '@/exception'; +import { LeaderboardSortType, LeaderboardType } from '@/types/dto/requests/getLeaderboardQuery.type'; export class LeaderboardRepository { constructor(private pool: Pool) {} - async getLeaderboard(type: string, sort: string, dateRange: number, limit: number) { + async getLeaderboard(type: LeaderboardType, sort: LeaderboardSortType, dateRange: number, limit: number) { try { const cteQuery = this.buildLeaderboardCteQuery(); const selectQuery = this.buildLeaderboardSelectQuery(type); @@ -49,7 +50,7 @@ export class LeaderboardRepository { } // 메인 연산을 포함하는 SELECT 절 빌드 - private buildLeaderboardSelectQuery(type: string) { + private buildLeaderboardSelectQuery(type: LeaderboardType) { if (type === 'post') { return ` SELECT @@ -98,7 +99,7 @@ export class LeaderboardRepository { } // CTE 테이블 조인 및 WHERE 절을 포함하는 FROM 절 빌드 - private buildLeaderboardFromClause(type: string) { + private buildLeaderboardFromClause(type: LeaderboardType) { if (type === 'post') { return ` FROM posts_post p @@ -118,7 +119,7 @@ export class LeaderboardRepository { } // sort 매개변수를 정렬 컬럼으로 매핑 - private mapSortColByType(sort: string, type: string) { + private mapSortColByType(sort: LeaderboardSortType, type: LeaderboardType) { let sortCol = ''; switch (sort) { @@ -138,7 +139,7 @@ export class LeaderboardRepository { } // 매핑된 정렬 컬럼으로 ORDER BY 절 및 LIMIT 절 빌드 - private buildLeaderboardGroupOrderClause(sortCol: string, type: string) { + private buildLeaderboardGroupOrderClause(sortCol: string, type: LeaderboardType) { if (type === 'post') { return ` ORDER BY ${sortCol} DESC @@ -146,7 +147,7 @@ export class LeaderboardRepository { `; } else { return ` - GROUP BY u.id + GROUP BY u.id, u.email ORDER BY ${sortCol} DESC LIMIT $2; `; diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index c05769a..d6aa154 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,5 +1,6 @@ import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { LeaderboardSortType, LeaderboardType } from '@/types/dto/requests/getLeaderboardQuery.type'; import { LeaderboardPostType, LeaderboardResponseData, @@ -10,8 +11,8 @@ export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} async getLeaderboard( - type: string = 'user', - sort: string = 'viewCount', + type: LeaderboardType = 'user', + sort: LeaderboardSortType = 'viewCount', dateRange: number = 30, limit: number = 10, ): Promise { @@ -26,7 +27,10 @@ export class LeaderboardService { } } - private mapRawResultToLeaderboardResponseData(rawResult: unknown[], type: string): LeaderboardResponseData { + private mapRawResultToLeaderboardResponseData( + rawResult: RawPostResult[] | RawUserResult[], + type: LeaderboardType, + ): LeaderboardResponseData { const result: LeaderboardResponseData = { posts: null, users: null }; if (type === 'post') { @@ -60,7 +64,6 @@ export class LeaderboardService { return result; } } - interface RawPostResult { id: number; title: string; diff --git a/src/types/dto/requests/getLeaderboardQuery.type.ts b/src/types/dto/requests/getLeaderboardQuery.type.ts index 1e48bed..c4a569f 100644 --- a/src/types/dto/requests/getLeaderboardQuery.type.ts +++ b/src/types/dto/requests/getLeaderboardQuery.type.ts @@ -1,5 +1,5 @@ import { Transform } from 'class-transformer'; -import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator'; /** * @swagger @@ -68,11 +68,13 @@ export interface GetLeaderboardQuery { */ export class GetLeaderboardQueryDto { @IsOptional() - @IsString() + @IsEnum(['user', 'post']) + @Transform(({ value }) => (value === '' ? 'user' : value)) type?: LeaderboardType; @IsOptional() - @IsString() + @IsEnum(['viewCount', 'likeCount', 'postCount']) + @Transform(({ value }) => (value === '' ? 'viewCount' : value)) sort?: LeaderboardSortType; @IsOptional() From e15be82ec81815c5cdf5d9a3153eeccc5426d189 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 22 Apr 2025 22:05:24 +0900 Subject: [PATCH 08/14] =?UTF-8?q?modify:=20=EA=B4=80=EB=A0=A8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20index.ts=EC=97=90=20=EB=84=A3=EA=B3=A0=20import=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/leaderboard.controller.ts | 5 ++--- src/repositories/__test__/leaderboard.repo.test.ts | 2 +- src/repositories/leaderboard.repository.ts | 4 ++-- src/services/__test__/leaderboard.service.test.ts | 2 +- src/services/leaderboard.service.ts | 13 +++++++------ src/types/dto/responses/leaderboardResponse.type.ts | 8 ++++---- src/types/index.ts | 12 ++++++++++++ 7 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts index 3ef93b7..c965232 100644 --- a/src/controllers/leaderboard.controller.ts +++ b/src/controllers/leaderboard.controller.ts @@ -1,8 +1,7 @@ import logger from '@/configs/logger.config'; -import { LeaderboardService } from '@/services/leaderboard.service'; -import { GetLeaderboardQuery } from '@/types/dto/requests/getLeaderboardQuery.type'; -import { LeaderboardResponseDto } from '@/types/dto/responses/leaderboardResponse.type'; import { NextFunction, RequestHandler, Request, Response } from 'express'; +import { LeaderboardService } from '@/services/leaderboard.service'; +import { GetLeaderboardQuery, LeaderboardResponseDto } from '@/types/index'; export class LeaderboardController { constructor(private leaderboardService: LeaderboardService) {} diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 98337e0..ff91bad 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -1,6 +1,6 @@ +import { Pool, QueryResult } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -import { Pool, QueryResult } from 'pg'; jest.mock('pg'); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 34776a3..b48e06d 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -1,7 +1,7 @@ -import { Pool } from 'pg'; import logger from '@/configs/logger.config'; +import { Pool } from 'pg'; import { DBError } from '@/exception'; -import { LeaderboardSortType, LeaderboardType } from '@/types/dto/requests/getLeaderboardQuery.type'; +import { LeaderboardSortType, LeaderboardType } from '@/types/index'; export class LeaderboardRepository { constructor(private pool: Pool) {} diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index edd5666..258bb0b 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -1,7 +1,7 @@ +import { Pool } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { LeaderboardService } from '@/services/leaderboard.service'; -import { Pool } from 'pg'; jest.mock('@/repositories/leaderboard.repository'); diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index d6aa154..462d081 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,11 +1,12 @@ import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -import { LeaderboardSortType, LeaderboardType } from '@/types/dto/requests/getLeaderboardQuery.type'; import { - LeaderboardPostType, LeaderboardResponseData, - LeaderboardUserType, -} from '@/types/dto/responses/leaderboardResponse.type'; + LeaderboardType, + LeaderboardSortType, + LeaderboardUserTypeData, + LeaderboardPostTypeData, +} from '@/types/index'; export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} @@ -35,7 +36,7 @@ export class LeaderboardService { if (type === 'post') { result.posts = (rawResult as RawPostResult[]).map( - (post): LeaderboardPostType => ({ + (post): LeaderboardPostTypeData => ({ id: post.id, title: post.title, slug: post.slug, @@ -48,7 +49,7 @@ export class LeaderboardService { ); } else { result.users = (rawResult as RawUserResult[]).map( - (user): LeaderboardUserType => ({ + (user): LeaderboardUserTypeData => ({ id: user.id, email: user.email, totalViews: user.total_views, diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts index b35a50b..92e2873 100644 --- a/src/types/dto/responses/leaderboardResponse.type.ts +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -32,7 +32,7 @@ import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; * type: integer * description: 구간 게시물 수 상승값 */ -export interface LeaderboardUserType { +export interface LeaderboardUserTypeData { id: number; email: string; totalViews: number; @@ -76,7 +76,7 @@ export interface LeaderboardUserType { * format: date-time * description: 게시물 업로드 일시 */ -export interface LeaderboardPostType { +export interface LeaderboardPostTypeData { id: number; title: string; slug: string; @@ -106,8 +106,8 @@ export interface LeaderboardPostType { * $ref: '#/components/schemas/LeaderboardUserType' */ export interface LeaderboardResponseData { - users: LeaderboardUserType[] | null; - posts: LeaderboardPostType[] | null; + users: LeaderboardUserTypeData[] | null; + posts: LeaderboardPostTypeData[] | null; } /** diff --git a/src/types/index.ts b/src/types/index.ts index c2b2f49..f209bc3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,12 @@ export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery. export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; +export { + GetLeaderboardQueryDto, + LeaderboardType, + LeaderboardSortType, + GetLeaderboardQuery, +} from '@/types/dto/requests/getLeaderboardQuery.type'; export { LoginResponseDto } from '@/types/dto/responses/loginResponse.type'; export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; export { @@ -16,5 +22,11 @@ export { PostStatisticsResponseDto, RawPostType, } from '@/types/dto/responses/postResponse.type'; +export { + LeaderboardResponseDto, + LeaderboardUserTypeData, + LeaderboardPostTypeData, + LeaderboardResponseData, +} from '@/types/dto/responses/leaderboardResponse.type'; export { UserWithTokenDto } from '@/types/dto/userWithToken.type'; export { VelogUserLoginDto } from '@/types/dto/velogUser.type'; From abc3a3a61e4a985f9f0291e33e80c673c54aad24 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Thu, 24 Apr 2025 23:11:39 +0900 Subject: [PATCH 09/14] =?UTF-8?q?modify:=20=EC=82=AC=EC=9A=A9=EC=9E=90,=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EB=B3=84=EB=A1=9C=20Leaderboard?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터, 타입, 함수 모두 분리 repo 쿼리 주석 삭제 및 파라미터에 따른 날짜 변환을 make_interval로 통일 불필요해진 테스트 코드 삭제 --- src/controllers/leaderboard.controller.ts | 39 +++- .../__test__/leaderboard.repo.test.ts | 134 +++++++------ src/repositories/leaderboard.repository.ts | 181 ++++++------------ src/routes/leaderboard.router.ts | 56 +++++- .../__test__/leaderboard.service.test.ts | 63 ++++-- src/services/leaderboard.service.ts | 96 +++++----- .../dto/requests/getLeaderboardQuery.type.ts | 112 +++++++---- .../dto/responses/leaderboardResponse.type.ts | 59 ++++-- src/types/index.ts | 18 +- 9 files changed, 431 insertions(+), 327 deletions(-) diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts index c965232..3ca73df 100644 --- a/src/controllers/leaderboard.controller.ts +++ b/src/controllers/leaderboard.controller.ts @@ -1,25 +1,48 @@ import logger from '@/configs/logger.config'; import { NextFunction, RequestHandler, Request, Response } from 'express'; import { LeaderboardService } from '@/services/leaderboard.service'; -import { GetLeaderboardQuery, LeaderboardResponseDto } from '@/types/index'; +import { + GetUserLeaderboardQuery, + GetPostLeaderboardQuery, + UserLeaderboardResponseDto, + PostLeaderboardResponseDto, +} from '@/types/index'; export class LeaderboardController { constructor(private leaderboardService: LeaderboardService) {} - getLeaderboard: RequestHandler = async ( - req: Request, - res: Response, + getUserLeaderboard: RequestHandler = async ( + req: Request, + res: Response, next: NextFunction, ) => { try { - const { type, sort, dateRange, limit } = req.query; + const { sort, dateRange, limit } = req.query; - const result = await this.leaderboardService.getLeaderboard(type, sort, dateRange, limit); - const response = new LeaderboardResponseDto(true, '리더보드 조회에 성공하였습니다.', result, null); + 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); + logger.error('사용자 리더보드 조회 실패:', error); + next(error); + } + }; + + getPostLeaderboard: RequestHandler = async ( + req: Request, + res: Response, + 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); } }; diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index ff91bad..01ad3bb 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -17,43 +17,8 @@ describe('LeaderboardRepository', () => { repo = new LeaderboardRepository(mockPool as unknown as Pool); }); - describe('getLeaderboard', () => { - it('type이 post인 경우 post 데이터를 반환해야 한다.', 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({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); - - const result = await repo.getLeaderboard('post', 'viewCount', 30, 10); - - expect(result).toEqual(mockResult); - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything()); - }); - - it('type이 user인 경우 user 데이터를 반환해야 한다.', async () => { + describe('getUserLeaderboard', () => { + it('사용자 리더보드를 조회할 수 있어야 한다', async () => { const mockResult = [ { id: 1, @@ -82,7 +47,7 @@ describe('LeaderboardRepository', () => { rowCount: mockResult.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + const result = await repo.getUserLeaderboard('viewCount', 30, 10); expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything()); expect(result).toEqual(mockResult); @@ -99,7 +64,7 @@ describe('LeaderboardRepository', () => { rowCount: mockResult.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + const result = await repo.getUserLeaderboard('viewCount', 30, 10); expect(result).toEqual(mockResult); expect(result[0].view_diff).toBeGreaterThan(result[1].view_diff); @@ -116,7 +81,7 @@ describe('LeaderboardRepository', () => { rowCount: mockResult.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('user', 'likeCount', 30, 10); + const result = await repo.getUserLeaderboard('likeCount', 30, 10); expect(result).toEqual(mockResult); expect(result[0].like_diff).toBeGreaterThan(result[1].like_diff); @@ -133,7 +98,7 @@ describe('LeaderboardRepository', () => { rowCount: mockResult.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('user', 'postCount', 30, 10); + const result = await repo.getUserLeaderboard('postCount', 30, 10); expect(result).toEqual(mockResult); expect(result[0].post_diff).toBeGreaterThan(result[1].post_diff); @@ -154,7 +119,7 @@ describe('LeaderboardRepository', () => { rowCount: mockData.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('post', 'viewCount', 30, mockLimit); + const result = await repo.getUserLeaderboard('viewCount', 30, mockLimit); expect(result).toEqual(mockData); expect(result.length).toEqual(mockLimit); @@ -165,45 +130,94 @@ describe('LeaderboardRepository', () => { ); }); - it('type이 post이고 sort가 게시물 수인 경우 조회수를 기준으로 정렬해야 한다.', async () => { - const mockResult = [ - { total_views: 200, total_likes: 5, view_diff: 20, like_diff: 0 }, - { total_views: 100, total_likes: 50, view_diff: 10, like_diff: 5 }, - ]; + it('GROUP BY 절이 포함되어야 한다', async () => { + mockPool.query.mockResolvedValue({ + rows: [], + rowCount: 0, + } as unknown as QueryResult); + + await repo.getUserLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id, u.email'), expect.anything()); + }); + + it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockResult = [{ id: 1 }]; + const testDateRange = 30; mockPool.query.mockResolvedValue({ rows: mockResult, rowCount: mockResult.length, } as unknown as QueryResult); - const result = await repo.getLeaderboard('post', 'postCount', 30, 10); + await repo.getUserLeaderboard('viewCount', testDateRange, 10); - expect(result).toEqual(mockResult); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY view_diff DESC'), - expect.anything(), + expect.stringContaining('$1::int'), + expect.arrayContaining([testDateRange, expect.anything()]), ); - expect(result[0].view_diff).toBeGreaterThan(result[1].view_diff); }); - it('user 타입에는 GROUP BY 절이 포함되어야 한다', async () => { + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { mockPool.query.mockResolvedValue({ rows: [], rowCount: 0, } as unknown as QueryResult); - await repo.getLeaderboard('user', 'viewCount', 30, 10); + const result = await repo.getUserLeaderboard('viewCount', 30, 10); - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id, u.email'), expect.anything()); + expect(result).toEqual([]); + }); + + 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({ + rows: mockResult, + rowCount: mockResult.length, + } as unknown as QueryResult); + + 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('post 타입에는 GROUP BY 절이 포함되지 않아야 한다', async () => { + it('GROUP BY 절이 포함되지 않아야 한다', async () => { mockPool.query.mockResolvedValue({ rows: [], rowCount: 0, } as unknown as QueryResult); - await repo.getLeaderboard('post', 'viewCount', 30, 10); + await repo.getPostLeaderboard('viewCount', 30, 10); expect(mockPool.query).toHaveBeenCalledWith(expect.not.stringContaining('GROUP BY'), expect.anything()); }); @@ -217,7 +231,7 @@ describe('LeaderboardRepository', () => { rowCount: mockResult.length, } as unknown as QueryResult); - await repo.getLeaderboard('user', 'viewCount', testDateRange, 10); + await repo.getPostLeaderboard('viewCount', testDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( expect.stringContaining('$1::int'), @@ -231,14 +245,14 @@ describe('LeaderboardRepository', () => { rowCount: 0, } as unknown as QueryResult); - const result = await repo.getLeaderboard('user', 'viewCount', 30, 10); + const result = await repo.getPostLeaderboard('viewCount', 30, 10); expect(result).toEqual([]); }); it('에러 발생 시 DBError를 던져야 한다', async () => { mockPool.query.mockRejectedValue(new Error('DB connection failed')); - await expect(repo.getLeaderboard('post', 'postCount', 30, 10)).rejects.toThrow(DBError); + await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); }); }); }); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index b48e06d..4e7f545 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -1,33 +1,82 @@ import logger from '@/configs/logger.config'; import { Pool } from 'pg'; import { DBError } from '@/exception'; -import { LeaderboardSortType, LeaderboardType } from '@/types/index'; +import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index'; export class LeaderboardRepository { constructor(private pool: Pool) {} - async getLeaderboard(type: LeaderboardType, sort: LeaderboardSortType, dateRange: number, limit: number) { + async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) { try { const cteQuery = this.buildLeaderboardCteQuery(); - const selectQuery = this.buildLeaderboardSelectQuery(type); - const fromClause = this.buildLeaderboardFromClause(type); - const sortCol = this.mapSortColByType(sort, type); - const groupOrderClause = this.buildLeaderboardGroupOrderClause(sortCol, type); + const sortCol = sort === 'postCount' ? 'post_diff' : sort === 'likeCount' ? 'like_diff' : 'view_diff'; - const query = `${cteQuery} ${selectQuery} ${fromClause} ${groupOrderClause}`; - const values = await this.pool.query(query, [dateRange, limit]); + 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 values.rows; + return result.rows; } catch (error) { - logger.error(`Leaderboard Repo getLeaderboard error:`, error); - throw new DBError(`${type === 'post' ? '게시글' : '유저'} 리더보드 조회 중 문제가 발생했습니다.`); + 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(SUM(ts.today_view), 0) AS total_views, + COALESCE(SUM(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 ( + WITH + today_stats AS ( SELECT DISTINCT ON (post_id) post_id, daily_view_count AS today_view, @@ -36,121 +85,15 @@ export class LeaderboardRepository { 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 - ($1::int * INTERVAL '1 day')) + 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 ) `; } - - // 메인 연산을 포함하는 SELECT 절 빌드 - private buildLeaderboardSelectQuery(type: LeaderboardType) { - if (type === 'post') { - return ` - 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, - - -- 조회수 / 좋아요 수 상승값 = 오늘 - 기준일 (기준일이 없으면 diff = 0) - 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 - `; - } else { - return ` - 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, - - -- 전체 게시물 조회수 / 좋아요 수 상승값 합계 = 오늘 - 기준일 (기준일이 없으면 diff = 0) - 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, - - -- 최근 dateRange내 업로드된 게시물 수 - COUNT(DISTINCT CASE - WHEN p.released_at >= CURRENT_DATE - $1::int - AND p.is_active = true - THEN p.id - END) AS post_diff, - - -- 전체 활성 게시물 수 - COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts - `; - } - } - - // CTE 테이블 조인 및 WHERE 절을 포함하는 FROM 절 빌드 - private buildLeaderboardFromClause(type: LeaderboardType) { - if (type === 'post') { - return ` - 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 - `; - } else { - return ` - 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 - `; - } - } - - // sort 매개변수를 정렬 컬럼으로 매핑 - private mapSortColByType(sort: LeaderboardSortType, type: LeaderboardType) { - let sortCol = ''; - - switch (sort) { - case 'postCount': - sortCol = type === 'user' ? 'post_diff' : 'view_diff'; - break; - case 'likeCount': - sortCol = 'like_diff'; - break; - case 'viewCount': - default: - sortCol = 'view_diff'; - break; - } - - return sortCol; - } - - // 매핑된 정렬 컬럼으로 ORDER BY 절 및 LIMIT 절 빌드 - private buildLeaderboardGroupOrderClause(sortCol: string, type: LeaderboardType) { - if (type === 'post') { - return ` - ORDER BY ${sortCol} DESC - LIMIT $2; - `; - } else { - return ` - GROUP BY u.id, u.email - ORDER BY ${sortCol} DESC - LIMIT $2; - `; - } - } } diff --git a/src/routes/leaderboard.router.ts b/src/routes/leaderboard.router.ts index 1ebceb9..64a6cb4 100644 --- a/src/routes/leaderboard.router.ts +++ b/src/routes/leaderboard.router.ts @@ -1,10 +1,10 @@ +import pool from '@/configs/db.config'; import express, { Router } from 'express'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -import pool from '@/configs/db.config'; import { LeaderboardService } from '@/services/leaderboard.service'; import { LeaderboardController } from '@/controllers/leaderboard.controller'; import { validateRequestDto } from '@/middlewares/validation.middleware'; -import { GetLeaderboardQueryDto } from '@/types/dto/requests/getLeaderboardQuery.type'; +import { GetUserLeaderboardQueryDto, GetPostLeaderboardQueryDto } from '@/types/dto/requests/getLeaderboardQuery.type'; const router: Router = express.Router(); @@ -14,20 +14,52 @@ const leaderboardController = new LeaderboardController(leaderboardService); /** * @swagger - * /leaderboard: + * /leaderboard/user: * get: - * summary: 리더보드 조회 + * summary: 사용자 리더보드 조회 * tags: * - Leaderboard * parameters: * - in: query - * name: type + * name: sort * schema: - * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/type' + * $ref: '#/components/schemas/UserLeaderboardSortType' + * - in: query + * name: dateRange + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/dateRange' + * - in: query + * name: limit + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/limit' + * responses: + * '200': + * description: 사용자 리더보드 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserLeaderboardResponseDto' + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.get( + '/leaderboard/user', + validateRequestDto(GetUserLeaderboardQueryDto, 'query'), + leaderboardController.getUserLeaderboard, +); + +/** + * @swagger + * /leaderboard/post: + * get: + * summary: 게시물 리더보드 조회 + * tags: + * - Leaderboard + * parameters: * - in: query * name: sort * schema: - * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/sort' + * $ref: '#/components/schemas/PostLeaderboardSortType' * - in: query * name: dateRange * schema: @@ -38,14 +70,18 @@ const leaderboardController = new LeaderboardController(leaderboardService); * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/limit' * responses: * '200': - * description: 리더보드 조회 성공 + * description: 게시물 리더보드 조회 성공 * content: * application/json: * schema: - * $ref: '#/components/schemas/LeaderboardResponseDto' + * $ref: '#/components/schemas/PostLeaderboardResponseDto' * '500': * description: 서버 오류 / 데이터 베이스 조회 오류 */ -router.get('/leaderboard', validateRequestDto(GetLeaderboardQueryDto, 'query'), leaderboardController.getLeaderboard); +router.get( + '/leaderboard/post', + validateRequestDto(GetPostLeaderboardQueryDto, 'query'), + leaderboardController.getPostLeaderboard, +); export default router; diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index 258bb0b..2b36025 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -24,8 +24,8 @@ describe('LeaderboardService', () => { jest.clearAllMocks(); }); - describe('getLeaderboard', () => { - it('type이 user인 경우 posts는 null이고, users는 응답 형식에 맞게 변환되어야 한다', async () => { + describe('getUserLeaderboard', () => { + it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { const mockRawResult = [ { id: 1, @@ -75,15 +75,38 @@ describe('LeaderboardService', () => { ], }; - repo.getLeaderboard.mockResolvedValue(mockRawResult); + repo.getUserLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getUserLeaderboard(); - const result = await service.getLeaderboard('user'); - - expect(result.posts).toBeNull(); expect(result.users).toEqual(mockResult.users); }); - it('type이 post인 경우 users는 null이고, posts는 응답 형식에 맞게 변환되어야 한다', async () => { + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + await service.getUserLeaderboard(); + + expect(repo.getUserLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); + }); + + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + const result = await service.getUserLeaderboard(); + + expect(result).toEqual({ users: [] }); + }); + + it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { + const errorMessage = '사용자 리더보드 조회 중 문제가 발생했습니다.'; + const dbError = new DBError(errorMessage); + repo.getUserLeaderboard.mockRejectedValue(dbError); + + await expect(service.getUserLeaderboard()).rejects.toThrow(errorMessage); + expect(repo.getUserLeaderboard).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPostLeaderboard', () => { + it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { const mockRawResult = [ { id: 1, @@ -133,35 +156,33 @@ describe('LeaderboardService', () => { users: null, }; - repo.getLeaderboard.mockResolvedValue(mockRawResult); - - const result = await service.getLeaderboard('post'); + repo.getPostLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getPostLeaderboard(); - expect(result.users).toBeNull(); expect(result.posts).toEqual(mockResult.posts); }); it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { - repo.getLeaderboard.mockResolvedValue([]); - await service.getLeaderboard(); + repo.getPostLeaderboard.mockResolvedValue([]); + await service.getPostLeaderboard(); - expect(repo.getLeaderboard).toHaveBeenCalledWith('user', 'viewCount', 30, 10); + expect(repo.getPostLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); }); it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { - repo.getLeaderboard.mockResolvedValue([]); - const result = await service.getLeaderboard(); + repo.getPostLeaderboard.mockResolvedValue([]); + const result = await service.getPostLeaderboard(); - expect(result).toEqual({ users: [], posts: null }); + expect(result).toEqual({ posts: [] }); }); it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { - const errorMessage = '유저 리더보드 조회 중 문제가 발생했습니다.'; + const errorMessage = '게시물 리더보드 조회 중 문제가 발생했습니다.'; const dbError = new DBError(errorMessage); - repo.getLeaderboard.mockRejectedValue(dbError); + repo.getPostLeaderboard.mockRejectedValue(dbError); - await expect(service.getLeaderboard()).rejects.toThrow(errorMessage); - expect(repo.getLeaderboard).toHaveBeenCalledTimes(1); + await expect(service.getPostLeaderboard()).rejects.toThrow(errorMessage); + expect(repo.getPostLeaderboard).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index 462d081..c6e3216 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,70 +1,74 @@ import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { - LeaderboardResponseData, - LeaderboardType, - LeaderboardSortType, - LeaderboardUserTypeData, - LeaderboardPostTypeData, + UserLeaderboardSortType, + PostLeaderboardSortType, + UserLeaderboardData, + PostLeaderboardData, } from '@/types/index'; export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} - async getLeaderboard( - type: LeaderboardType = 'user', - sort: LeaderboardSortType = 'viewCount', + async getUserLeaderboard( + sort: UserLeaderboardSortType = 'viewCount', dateRange: number = 30, limit: number = 10, - ): Promise { + ): Promise { try { - const rawResult = await this.leaderboardRepo.getLeaderboard(type, sort, dateRange, limit); - const result = this.mapRawResultToLeaderboardResponseData(rawResult, type); + const rawResult = await this.leaderboardRepo.getUserLeaderboard(sort, dateRange, limit); + return this.mapRawUserResult(rawResult); + } catch (error) { + logger.error('LeaderboardService getUserLeaderboard error : ', error); + throw error; + } + } - return result; + async getPostLeaderboard( + sort: PostLeaderboardSortType = 'viewCount', + dateRange: number = 30, + limit: number = 10, + ): Promise { + try { + const rawResult = await this.leaderboardRepo.getPostLeaderboard(sort, dateRange, limit); + return this.mapRawPostResult(rawResult); } catch (error) { - logger.error('LeaderboardService getLeaderboard error : ', error); + logger.error('LeaderboardService getPostLeaderboard error : ', error); throw error; } } - private mapRawResultToLeaderboardResponseData( - rawResult: RawPostResult[] | RawUserResult[], - type: LeaderboardType, - ): LeaderboardResponseData { - const result: LeaderboardResponseData = { posts: null, users: null }; + private mapRawUserResult(rawResult: RawUserResult[]): UserLeaderboardData { + const users = rawResult.map((user) => ({ + id: user.id, + email: user.email, + totalViews: user.total_views, + totalLikes: user.total_likes, + totalPosts: user.total_posts, + viewDiff: user.view_diff, + likeDiff: user.like_diff, + postDiff: user.post_diff, + })); - if (type === 'post') { - result.posts = (rawResult as RawPostResult[]).map( - (post): LeaderboardPostTypeData => ({ - id: post.id, - title: post.title, - slug: post.slug, - totalViews: post.total_views, - totalLikes: post.total_likes, - viewDiff: post.view_diff, - likeDiff: post.like_diff, - releasedAt: post.released_at, - }), - ); - } else { - result.users = (rawResult as RawUserResult[]).map( - (user): LeaderboardUserTypeData => ({ - id: user.id, - email: user.email, - totalViews: user.total_views, - totalLikes: user.total_likes, - totalPosts: user.total_posts, - viewDiff: user.view_diff, - likeDiff: user.like_diff, - postDiff: user.post_diff, - }), - ); - } + return { users }; + } - return result; + private mapRawPostResult(rawResult: RawPostResult[]): PostLeaderboardData { + const posts = rawResult.map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + totalViews: post.total_views, + totalLikes: post.total_likes, + viewDiff: post.view_diff, + likeDiff: post.like_diff, + releasedAt: post.released_at, + })); + + return { posts }; } } + interface RawPostResult { id: number; title: string; diff --git a/src/types/dto/requests/getLeaderboardQuery.type.ts b/src/types/dto/requests/getLeaderboardQuery.type.ts index c4a569f..1e5f8b9 100644 --- a/src/types/dto/requests/getLeaderboardQuery.type.ts +++ b/src/types/dto/requests/getLeaderboardQuery.type.ts @@ -5,35 +5,41 @@ import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator'; * @swagger * components: * schemas: - * LeaderboardType: + * UserLeaderboardSortType: * type: string - * description: 리더보드 조회 타입 + * description: 사용자 리더보드 정렬 기준 * nullable: true - * enum: ['user', 'post'] - * default: 'user' + * enum: ['viewCount', 'likeCount', 'postCount'] + * default: 'viewCount' */ -export type LeaderboardType = 'user' | 'post'; +export type UserLeaderboardSortType = 'viewCount' | 'likeCount' | 'postCount'; /** * @swagger * components: * schemas: - * LeaderboardSortType: + * PostLeaderboardSortType: * type: string - * description: 리더보드 정렬 기준 + * description: 게시물 리더보드 정렬 기준 * nullable: true - * enum: ['viewCount', 'likeCount', 'postCount'] + * enum: ['viewCount', 'likeCount'] * default: 'viewCount' */ -export type LeaderboardSortType = 'viewCount' | 'likeCount' | 'postCount'; +export type PostLeaderboardSortType = 'viewCount' | 'likeCount'; -export interface GetLeaderboardQuery { - type?: LeaderboardType; - sort?: LeaderboardSortType; +interface GetLeaderboardQuery { dateRange?: number; limit?: number; } +export interface GetUserLeaderboardQuery extends GetLeaderboardQuery { + sort?: UserLeaderboardSortType; +} + +export interface GetPostLeaderboardQuery extends GetLeaderboardQuery { + sort?: PostLeaderboardSortType; +} + /** * @swagger * components: @@ -41,16 +47,6 @@ export interface GetLeaderboardQuery { * GetLeaderboardQueryDto: * type: object * properties: - * type: - * $ref: '#/components/schemas/LeaderboardType' - * description: 리더보드 조회 타입 - * nullable: true - * default: 'user' - * sort: - * $ref: '#/components/schemas/LeaderboardSortType' - * description: 리더보드 정렬 기준 - * nullable: true - * default: 'viewCount' * dateRange: * type: number * description: 리더보드 조회 기간 (일수) @@ -66,35 +62,75 @@ export interface GetLeaderboardQuery { * minimum: 1 * maximum: 30 */ -export class GetLeaderboardQueryDto { - @IsOptional() - @IsEnum(['user', 'post']) - @Transform(({ value }) => (value === '' ? 'user' : value)) - type?: LeaderboardType; - - @IsOptional() - @IsEnum(['viewCount', 'likeCount', 'postCount']) - @Transform(({ value }) => (value === '' ? 'viewCount' : value)) - sort?: LeaderboardSortType; - +class GetLeaderboardQueryDto { @IsOptional() @IsNumber() - @Transform(({ value }) => Number(value)) + @Transform(({ value }) => (value === '' ? 30 : Number(value))) @Min(1) @Max(30) dateRange?: number; @IsOptional() @IsNumber() - @Transform(({ value }) => Number(value)) + @Transform(({ value }) => (value === '' ? 10 : Number(value))) @Min(1) @Max(30) limit?: number; - constructor(type?: LeaderboardType, sort?: LeaderboardSortType, dateRange?: number, limit?: number) { - this.type = type; - this.sort = sort; + constructor(dateRange?: number, limit?: number) { this.dateRange = dateRange; this.limit = limit; } } + +/** + * @swagger + * components: + * schemas: + * GetUserLeaderboardQueryDto: + * type: object + * properties: + * sort: + * type: string + * description: 사용자 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount', 'postCount'] + * default: 'viewCount' + */ +export class GetUserLeaderboardQueryDto extends GetLeaderboardQueryDto { + @IsOptional() + @IsEnum(['viewCount', 'likeCount', 'postCount']) + @Transform(({ value }) => (value === '' ? 'viewCount' : value)) + sort?: UserLeaderboardSortType; + + constructor(sort?: UserLeaderboardSortType) { + super(); + this.sort = sort; + } +} + +/** + * @swagger + * components: + * schemas: + * GetPostLeaderboardQueryDto: + * type: object + * properties: + * sort: + * type: string + * description: 게시물 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount'] + * default: 'viewCount' + */ +export class GetPostLeaderboardQueryDto extends GetLeaderboardQueryDto { + @IsOptional() + @IsEnum(['viewCount', 'likeCount']) + @Transform(({ value }) => (value === '' ? 'viewCount' : value)) + sort?: PostLeaderboardSortType; + + constructor(sort?: PostLeaderboardSortType) { + super(); + this.sort = sort; + } +} diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts index 92e2873..53363f7 100644 --- a/src/types/dto/responses/leaderboardResponse.type.ts +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -4,7 +4,7 @@ import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; * @swagger * components: * schemas: - * LeaderboardUserType: + * LeaderboardUser: * type: object * properties: * id: @@ -32,7 +32,7 @@ import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; * type: integer * description: 구간 게시물 수 상승값 */ -export interface LeaderboardUserTypeData { +interface LeaderboardUser { id: number; email: string; totalViews: number; @@ -47,7 +47,38 @@ export interface LeaderboardUserTypeData { * @swagger * components: * schemas: - * LeaderboardPostType: + * UserLeaderboardData: + * type: object + * properties: + * users: + * type: array + * nullable: true + * items: + * $ref: '#/components/schemas/LeaderboardUser' + */ +export interface UserLeaderboardData { + users: LeaderboardUser[]; +} + +/** + * @swagger + * components: + * schemas: + * UserLeaderboardResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * $ref: '#/components/schemas/UserLeaderboardData' + */ +export class UserLeaderboardResponseDto extends BaseResponseDto {} + +/** + * @swagger + * components: + * schemas: + * LeaderboardPost: * type: object * properties: * id: @@ -76,7 +107,7 @@ export interface LeaderboardUserTypeData { * format: date-time * description: 게시물 업로드 일시 */ -export interface LeaderboardPostTypeData { +interface LeaderboardPost { id: number; title: string; slug: string; @@ -91,35 +122,29 @@ export interface LeaderboardPostTypeData { * @swagger * components: * schemas: - * LeaderboardResponseData: + * PostLeaderboardData: * type: object * properties: * posts: * type: array * nullable: true * items: - * $ref: '#/components/schemas/LeaderboardPostType' - * users: - * type: array - * nullable: true - * items: - * $ref: '#/components/schemas/LeaderboardUserType' + * $ref: '#/components/schemas/LeaderboardPost' */ -export interface LeaderboardResponseData { - users: LeaderboardUserTypeData[] | null; - posts: LeaderboardPostTypeData[] | null; +export interface PostLeaderboardData { + posts: LeaderboardPost[]; } /** * @swagger * components: * schemas: - * LeaderboardResponseDto: + * PostLeaderboardResponseDto: * allOf: * - $ref: '#/components/schemas/BaseResponseDto' * - type: object * properties: * data: - * $ref: '#/components/schemas/LeaderboardResponseData' + * $ref: '#/components/schemas/PostLeaderboardData' */ -export class LeaderboardResponseDto extends BaseResponseDto {} +export class PostLeaderboardResponseDto extends BaseResponseDto {} diff --git a/src/types/index.ts b/src/types/index.ts index f209bc3..d399334 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,10 +9,12 @@ export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery. export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; export { - GetLeaderboardQueryDto, - LeaderboardType, - LeaderboardSortType, - GetLeaderboardQuery, + GetUserLeaderboardQueryDto, + GetPostLeaderboardQueryDto, + GetUserLeaderboardQuery, + GetPostLeaderboardQuery, + UserLeaderboardSortType, + PostLeaderboardSortType, } from '@/types/dto/requests/getLeaderboardQuery.type'; export { LoginResponseDto } from '@/types/dto/responses/loginResponse.type'; export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; @@ -23,10 +25,10 @@ export { RawPostType, } from '@/types/dto/responses/postResponse.type'; export { - LeaderboardResponseDto, - LeaderboardUserTypeData, - LeaderboardPostTypeData, - LeaderboardResponseData, + UserLeaderboardResponseDto, + PostLeaderboardResponseDto, + UserLeaderboardData, + PostLeaderboardData, } from '@/types/dto/responses/leaderboardResponse.type'; export { UserWithTokenDto } from '@/types/dto/userWithToken.type'; export { VelogUserLoginDto } from '@/types/dto/velogUser.type'; From ce5f1118cc33b459512706f93d5199ccfe444493 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 25 Apr 2025 02:13:34 +0900 Subject: [PATCH 10/14] =?UTF-8?q?modify:=20=EB=B6=84=EB=A6=AC=EC=A4=91=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=9D=EA=B8=B4=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 4e7f545..a6d02f2 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -52,8 +52,8 @@ export class LeaderboardRepository { p.title, p.slug, p.released_at, - COALESCE(SUM(ts.today_view), 0) AS total_views, - COALESCE(SUM(ts.today_like), 0) AS total_likes, + 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 From 651add93dc658f7e72e846c19301e238c12a71e2 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 25 Apr 2025 20:56:16 +0900 Subject: [PATCH 11/14] =?UTF-8?q?modify:=20=EB=A6=AC=EB=B7=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/leaderboard.repo.test.ts | 190 ++++++------------ .../__test__/leaderboard.service.test.ts | 25 ++- 2 files changed, 85 insertions(+), 130 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 01ad3bb..2d1ac06 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -4,6 +4,17 @@ import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; jest.mock('pg'); +// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성 +function createMockQueryResult>(rows: T[]): QueryResult { + return { + rows, + rowCount: rows.length, + command: '', + oid: 0, + fields: [], + } satisfies QueryResult; +} + const mockPool: { query: jest.Mock>>, unknown[]>; } = { @@ -18,7 +29,7 @@ describe('LeaderboardRepository', () => { }); describe('getUserLeaderboard', () => { - it('사용자 리더보드를 조회할 수 있어야 한다', async () => { + it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { const mockResult = [ { id: 1, @@ -41,11 +52,7 @@ describe('LeaderboardRepository', () => { post_diff: 1, }, ]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + mockPool.query.mockResolvedValue(createMockQueryResult(mockResult)); const result = await repo.getUserLeaderboard('viewCount', 30, 10); @@ -53,76 +60,37 @@ describe('LeaderboardRepository', () => { expect(result).toEqual(mockResult); }); - it('sort가 조회수인 경우 정렬 순서를 보장해야 한다.', async () => { - const mockResult = [ - { view_diff: 20, like_diff: 5, post_diff: 1 }, - { view_diff: 10, like_diff: 10, post_diff: 2 }, - ]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); - - const result = await repo.getUserLeaderboard('viewCount', 30, 10); + it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('viewCount', 30, 10); - expect(result).toEqual(mockResult); - expect(result[0].view_diff).toBeGreaterThan(result[1].view_diff); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY view_diff DESC'), + expect.anything(), + ); }); - it('sort가 좋아요 수인 경우 정렬 순서를 보장해야 한다.', async () => { - const mockResult = [ - { view_diff: 10, like_diff: 10, post_diff: 1 }, - { view_diff: 20, like_diff: 5, post_diff: 1 }, - ]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('likeCount', 30, 10); - const result = await repo.getUserLeaderboard('likeCount', 30, 10); - - expect(result).toEqual(mockResult); - expect(result[0].like_diff).toBeGreaterThan(result[1].like_diff); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY like_diff DESC'), + expect.anything(), + ); }); - it('sort가 게시물 수인 경우 정렬 순서를 보장해야 한다.', async () => { - const mockResult = [ - { view_diff: 10, like_diff: 10, post_diff: 4 }, - { view_diff: 20, like_diff: 5, post_diff: 1 }, - ]; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('postCount', 30, 10); - const result = await repo.getUserLeaderboard('postCount', 30, 10); - - expect(result).toEqual(mockResult); - expect(result[0].post_diff).toBeGreaterThan(result[1].post_diff); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY post_diff DESC'), + expect.anything(), + ); }); - it('limit 만큼의 데이터만 반환해야 한다', async () => { - const mockData = [ - { id: 1, title: 'test' }, - { id: 2, title: 'test2' }, - { id: 3, title: 'test3' }, - { id: 4, title: 'test4' }, - { id: 5, title: 'test5' }, - ]; + it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { const mockLimit = 5; - mockPool.query.mockResolvedValue({ - rows: mockData, - rowCount: mockData.length, - } as unknown as QueryResult); - - const result = await repo.getUserLeaderboard('viewCount', 30, mockLimit); - - expect(result).toEqual(mockData); - expect(result.length).toEqual(mockLimit); + await repo.getUserLeaderboard('viewCount', 30, mockLimit); expect(mockPool.query).toHaveBeenCalledWith( expect.stringContaining('LIMIT $2'), @@ -130,45 +98,17 @@ describe('LeaderboardRepository', () => { ); }); - it('GROUP BY 절이 포함되어야 한다', async () => { - mockPool.query.mockResolvedValue({ - rows: [], - rowCount: 0, - } as unknown as QueryResult); - - await repo.getUserLeaderboard('viewCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.id, u.email'), expect.anything()); - }); - it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { - const mockResult = [{ id: 1 }]; - const testDateRange = 30; - - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + const mockDateRange = 30; - await repo.getUserLeaderboard('viewCount', testDateRange, 10); + await repo.getUserLeaderboard('viewCount', mockDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('$1::int'), - expect.arrayContaining([testDateRange, expect.anything()]), + expect.stringContaining('make_interval(days := $1::int)'), + expect.arrayContaining([mockDateRange, expect.anything()]), ); }); - it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { - mockPool.query.mockResolvedValue({ - rows: [], - rowCount: 0, - } as unknown as QueryResult); - - const result = await repo.getUserLeaderboard('viewCount', 30, 10); - - expect(result).toEqual([]); - }); - it('에러 발생 시 DBError를 던져야 한다', async () => { mockPool.query.mockRejectedValue(new Error('DB connection failed')); await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); @@ -176,7 +116,7 @@ describe('LeaderboardRepository', () => { }); describe('getPostLeaderboard', () => { - it('게시물 리더보드를 조회할 수 있어야 한다', async () => { + it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { const mockResult = [ { id: 2, @@ -200,10 +140,7 @@ describe('LeaderboardRepository', () => { }, ]; - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + mockPool.query.mockResolvedValue(createMockQueryResult(mockResult)); const result = await repo.getPostLeaderboard('viewCount', 30, 10); @@ -211,43 +148,44 @@ describe('LeaderboardRepository', () => { expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything()); }); - it('GROUP BY 절이 포함되지 않아야 한다', async () => { - mockPool.query.mockResolvedValue({ - rows: [], - rowCount: 0, - } as unknown as QueryResult); - + it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { await repo.getPostLeaderboard('viewCount', 30, 10); - expect(mockPool.query).toHaveBeenCalledWith(expect.not.stringContaining('GROUP BY'), expect.anything()); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY view_diff DESC'), + expect.anything(), + ); }); - it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { - const mockResult = [{ id: 1 }]; - const testDateRange = 30; + it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getPostLeaderboard('likeCount', 30, 10); - mockPool.query.mockResolvedValue({ - rows: mockResult, - rowCount: mockResult.length, - } as unknown as QueryResult); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY like_diff DESC'), + expect.anything(), + ); + }); + + it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockLimit = 5; - await repo.getPostLeaderboard('viewCount', testDateRange, 10); + await repo.getPostLeaderboard('viewCount', 30, mockLimit); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('$1::int'), - expect.arrayContaining([testDateRange, expect.anything()]), + expect.stringContaining('LIMIT $2'), + expect.arrayContaining([30, mockLimit]), ); }); - it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { - mockPool.query.mockResolvedValue({ - rows: [], - rowCount: 0, - } as unknown as QueryResult); + it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockDateRange = 30; - const result = await repo.getPostLeaderboard('viewCount', 30, 10); + await repo.getPostLeaderboard('viewCount', mockDateRange, 10); - expect(result).toEqual([]); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('make_interval(days := $1::int)'), + expect.arrayContaining([mockDateRange, expect.anything()]), + ); }); it('에러 발생 시 DBError를 던져야 한다', async () => { diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index 2b36025..7c63680 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -50,7 +50,6 @@ describe('LeaderboardService', () => { ]; const mockResult = { - posts: null, users: [ { id: 1, @@ -76,13 +75,22 @@ describe('LeaderboardService', () => { }; repo.getUserLeaderboard.mockResolvedValue(mockRawResult); - const result = await service.getUserLeaderboard(); + const result = await service.getUserLeaderboard('viewCount', 30, 10); expect(result.users).toEqual(mockResult.users); }); + it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + + await service.getUserLeaderboard('postCount', 30, 10); + + expect(repo.getUserLeaderboard).toHaveBeenCalledWith('postCount', 30, 10); + }); + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { repo.getUserLeaderboard.mockResolvedValue([]); + await service.getUserLeaderboard(); expect(repo.getUserLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); @@ -90,6 +98,7 @@ describe('LeaderboardService', () => { it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { repo.getUserLeaderboard.mockResolvedValue([]); + const result = await service.getUserLeaderboard(); expect(result).toEqual({ users: [] }); @@ -153,17 +162,25 @@ describe('LeaderboardService', () => { releasedAt: '2025-01-02', }, ], - users: null, }; repo.getPostLeaderboard.mockResolvedValue(mockRawResult); - const result = await service.getPostLeaderboard(); + const result = await service.getPostLeaderboard('viewCount', 30, 10); expect(result.posts).toEqual(mockResult.posts); }); + it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { + repo.getPostLeaderboard.mockResolvedValue([]); + + await service.getPostLeaderboard('likeCount', 30, 10); + + expect(repo.getPostLeaderboard).toHaveBeenCalledWith('likeCount', 30, 10); + }); + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { repo.getPostLeaderboard.mockResolvedValue([]); + await service.getPostLeaderboard(); expect(repo.getPostLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); From f8627cb13ca2bc5a006546766b7606c7d808c1b4 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 25 Apr 2025 21:36:22 +0900 Subject: [PATCH 12/14] =?UTF-8?q?modify:=20API=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=A9=B0=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=B4?= =?UTF-8?q?=EC=A7=84=20swagger=20=EC=A3=BC=EC=84=9D=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/dto/responses/leaderboardResponse.type.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts index 53363f7..29989b1 100644 --- a/src/types/dto/responses/leaderboardResponse.type.ts +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -52,7 +52,6 @@ interface LeaderboardUser { * properties: * users: * type: array - * nullable: true * items: * $ref: '#/components/schemas/LeaderboardUser' */ @@ -127,7 +126,6 @@ interface LeaderboardPost { * properties: * posts: * type: array - * nullable: true * items: * $ref: '#/components/schemas/LeaderboardPost' */ From afa5fcf898c62e793f19df33909ef6e3f28b602a Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 25 Apr 2025 22:27:39 +0900 Subject: [PATCH 13/14] =?UTF-8?q?modify:=20bigint=20PK=EB=A5=BC=20string?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/leaderboard.repo.test.ts | 8 ++++---- .../__test__/leaderboard.service.test.ts | 16 ++++++++-------- src/services/leaderboard.service.ts | 4 ++-- .../dto/responses/leaderboardResponse.type.ts | 12 ++++++------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 2d1ac06..7955f51 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -32,7 +32,7 @@ describe('LeaderboardRepository', () => { it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { const mockResult = [ { - id: 1, + id: '1', email: 'test@test.com', total_views: 100, total_likes: 50, @@ -42,7 +42,7 @@ describe('LeaderboardRepository', () => { post_diff: 1, }, { - id: 2, + id: '2', email: 'test2@test.com', total_views: 200, total_likes: 100, @@ -119,7 +119,7 @@ describe('LeaderboardRepository', () => { it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { const mockResult = [ { - id: 2, + id: '2', title: 'test2', slug: 'test2', total_views: 200, @@ -129,7 +129,7 @@ describe('LeaderboardRepository', () => { released_at: '2025-01-02', }, { - id: 1, + id: '1', title: 'test', slug: 'test', total_views: 100, diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index 7c63680..b99338e 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -28,7 +28,7 @@ describe('LeaderboardService', () => { it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { const mockRawResult = [ { - id: 1, + id: '1', email: 'test@test.com', total_views: 100, total_likes: 50, @@ -38,7 +38,7 @@ describe('LeaderboardService', () => { post_diff: 1, }, { - id: 2, + id: '2', email: 'test2@test.com', total_views: 200, total_likes: 100, @@ -52,7 +52,7 @@ describe('LeaderboardService', () => { const mockResult = { users: [ { - id: 1, + id: '1', email: 'test@test.com', totalViews: 100, totalLikes: 50, @@ -62,7 +62,7 @@ describe('LeaderboardService', () => { postDiff: 1, }, { - id: 2, + id: '2', email: 'test2@test.com', totalViews: 200, totalLikes: 100, @@ -118,7 +118,7 @@ describe('LeaderboardService', () => { it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { const mockRawResult = [ { - id: 1, + id: '1', title: 'test', slug: 'test-slug', total_views: 100, @@ -128,7 +128,7 @@ describe('LeaderboardService', () => { released_at: '2025-01-01', }, { - id: 2, + id: '2', title: 'test2', slug: 'test2-slug', total_views: 200, @@ -142,7 +142,7 @@ describe('LeaderboardService', () => { const mockResult = { posts: [ { - id: 1, + id: '1', title: 'test', slug: 'test-slug', totalViews: 100, @@ -152,7 +152,7 @@ describe('LeaderboardService', () => { releasedAt: '2025-01-01', }, { - id: 2, + id: '2', title: 'test2', slug: 'test2-slug', totalViews: 200, diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index c6e3216..f0b1e50 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -70,7 +70,7 @@ export class LeaderboardService { } interface RawPostResult { - id: number; + id: string; title: string; slug: string; total_views: number; @@ -81,7 +81,7 @@ interface RawPostResult { } interface RawUserResult { - id: number; + id: string; email: string; total_views: number; total_likes: number; diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts index 29989b1..0497be1 100644 --- a/src/types/dto/responses/leaderboardResponse.type.ts +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -8,8 +8,8 @@ import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; * type: object * properties: * id: - * type: integer - * description: 사용자 ID + * type: string + * description: 사용자 PK * email: * type: string * description: 사용자 이메일 @@ -33,7 +33,7 @@ import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; * description: 구간 게시물 수 상승값 */ interface LeaderboardUser { - id: number; + id: string; email: string; totalViews: number; totalLikes: number; @@ -81,8 +81,8 @@ export class UserLeaderboardResponseDto extends BaseResponseDto Date: Sat, 26 Apr 2025 16:55:53 +0900 Subject: [PATCH 14/14] =?UTF-8?q?modify:=20sortCol=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=82=BC=ED=95=AD=EC=97=B0=EC=82=B0=EC=9E=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B0=9D=EC=B2=B4=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index a6d02f2..e051fd5 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -9,7 +9,6 @@ export class LeaderboardRepository { 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} @@ -28,7 +27,7 @@ export class LeaderboardRepository { 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 + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC LIMIT $2; `; const result = await this.pool.query(query, [dateRange, limit]); @@ -43,7 +42,6 @@ export class LeaderboardRepository { async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { try { const cteQuery = this.buildLeaderboardCteQuery(); - const sortCol = sort === 'viewCount' ? 'view_diff' : 'like_diff'; const query = ` ${cteQuery} @@ -60,7 +58,7 @@ export class LeaderboardRepository { 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 + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC LIMIT $2; `; const result = await this.pool.query(query, [dateRange, limit]); @@ -96,4 +94,10 @@ export class LeaderboardRepository { ) `; } + + private readonly SORT_COL_MAPPING = { + viewCount: 'view_diff', + likeCount: 'like_diff', + postCount: 'post_diff', + } as const; }