From 9ee243f018fc86ed3a08ac8b5d2f67fd2c4a5364 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 25 Aug 2025 23:39:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EB=A6=AC=EB=8D=94=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EB=B9=84=EC=A0=95=EC=83=81=EC=A0=81=EC=9D=B8=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=EA=B0=92=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 | 22 +++++++++++++++++-- src/repositories/leaderboard.repository.ts | 21 +++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 2daf91f..20361bf 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -79,11 +79,20 @@ describe('LeaderboardRepository', () => { await repo.getUserLeaderboard('viewCount', mockDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인 + expect.stringContaining('WHERE date ='), // pastDateKST를 사용하는 부분 확인 [expect.any(Number)], // limit ); }); + it('데이터 수집이 비정상적인 유저는 리더보드에 포함되지 않아야 한다', async () => { + await repo.getUserLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('HAVING SUM(COALESCE(ts.today_view, 0)) != SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0))'), + expect.anything(), + ); + }); + it('에러 발생 시 DBError를 던져야 한다', async () => { mockPool.query.mockRejectedValue(new Error('DB connection failed')); await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); @@ -156,11 +165,20 @@ describe('LeaderboardRepository', () => { await repo.getPostLeaderboard('viewCount', mockDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인 + expect.stringContaining('WHERE date ='), // pastDateKST를 사용하는 부분 확인 [expect.any(Number)], // limit ); }); + it('데이터 수집이 비정상적인 게시물은 리더보드에 포함되지 않아야 한다', async () => { + await repo.getPostLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('COALESCE(ts.today_view, 0) != COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)'), + expect.anything() + ); + }); + it('에러 발생 시 DBError를 던져야 한다', async () => { mockPool.query.mockRejectedValue(new Error('DB connection failed')); 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 025a341..174ed52 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -21,8 +21,8 @@ export class LeaderboardRepository { 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, + SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff, + SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff, COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' 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 @@ -30,6 +30,7 @@ export class LeaderboardRepository { LEFT JOIN start_stats ss ON ss.post_id = p.id WHERE u.username IS NOT NULL GROUP BY u.id, u.email, u.username + HAVING SUM(COALESCE(ts.today_view, 0)) != SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id LIMIT $1; `; @@ -44,6 +45,7 @@ export class LeaderboardRepository { async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { try { + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); const cteQuery = this.buildLeaderboardCteQuery(dateRange); const query = ` @@ -56,13 +58,18 @@ export class LeaderboardRepository { u.username AS username, 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 + COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) AS view_diff, + COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0) AS like_diff FROM posts_post p LEFT JOIN users_user u ON u.id = p.user_id 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 + AND ( + p.released_at >= '${pastDateKST}' + OR + COALESCE(ts.today_view, 0) != COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) + ) ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id LIMIT $1; `; @@ -89,8 +96,7 @@ export class LeaderboardRepository { daily_view_count AS today_view, daily_like_count AS today_like FROM posts_postdailystatistics - WHERE date <= '${nowDateKST}' - ORDER BY post_id, date DESC + WHERE date = '${nowDateKST}' ), start_stats AS ( SELECT DISTINCT ON (post_id) @@ -98,8 +104,7 @@ export class LeaderboardRepository { daily_view_count AS start_view, daily_like_count AS start_like FROM posts_postdailystatistics - WHERE date >= '${pastDateKST}' - ORDER BY post_id, date ASC + WHERE date = '${pastDateKST}' ) `; } From 9c00cebc90a2f6db9c03b294d11d1a71b2755c4e Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 25 Aug 2025 23:40:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EA=B3=84=EC=82=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 174ed52..0c0a794 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -10,7 +10,7 @@ export class LeaderboardRepository { async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) { try { const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); - const cteQuery = this.buildLeaderboardCteQuery(dateRange); + const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST); const query = ` ${cteQuery} @@ -46,7 +46,7 @@ export class LeaderboardRepository { async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { try { const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); - const cteQuery = this.buildLeaderboardCteQuery(dateRange); + const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST); const query = ` ${cteQuery} @@ -83,10 +83,11 @@ export class LeaderboardRepository { } // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 - private buildLeaderboardCteQuery(dateRange: number) { + private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) { const nowDateKST = getCurrentKSTDateString(); - // 과거 날짜 계산 (dateRange일 전) - const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + if (!pastDateKST) { + pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + } return ` WITH From 36dde5eec0c035b8c0b4b805f78892ffeaa3c7cb Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 26 Aug 2025 00:16:30 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=B2=84=EA=B7=B8=20=ED=94=BD?= =?UTF-8?q?=EC=8A=A4=ED=95=9C=20=EB=B6=80=EB=B6=84=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20skip=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../leaderboard.repo.integration.test.ts | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts index 9ec325d..8137ab2 100644 --- a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-disabled-tests */ /** * 주의: 이 통합 테스트는 현재 시간에 의존적입니다. * getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로 @@ -10,10 +9,10 @@ import dotenv from 'dotenv'; import pg, { Pool } from 'pg'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { PostLeaderboardSortType, UserLeaderboardSortType } from '@/types'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; dotenv.config(); - -jest.setTimeout(60000); // 각 케이스당 60초 타임아웃 설정 +jest.setTimeout(30000); // 각 케이스당 30초 타임아웃 설정 /** * LeaderboardRepository 통합 테스트 @@ -21,11 +20,10 @@ jest.setTimeout(60000); // 각 케이스당 60초 타임아웃 설정 * 이 테스트 파일은 실제 데이터베이스와 연결하여 LeaderboardRepository의 모든 메서드를 * 실제 환경과 동일한 조건에서 테스트합니다. */ -describe.skip('LeaderboardRepository 통합 테스트', () => { +describe('LeaderboardRepository 통합 테스트', () => { let testPool: Pool; let repo: LeaderboardRepository; - // eslint-disable-next-line @typescript-eslint/naming-convention const DEFAULT_PARAMS = { USER_SORT: 'viewCount' as UserLeaderboardSortType, POST_SORT: 'viewCount' as PostLeaderboardSortType, @@ -45,7 +43,7 @@ describe.skip('LeaderboardRepository 통합 테스트', () => { idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) allowExitOnIdle: false, // 유휴 상태에서 종료 허용 - statement_timeout: 60000, // 쿼리 타임아웃 증가 (60초) + statement_timeout: 30000, // 쿼리 타임아웃 증가 (30초) }; // localhost 가 아니면 ssl 필수 @@ -80,10 +78,17 @@ describe.skip('LeaderboardRepository 통합 테스트', () => { afterAll(async () => { try { - jest.clearAllMocks(); - - // 풀 완전 종료 - await testPool.end(); + // 모든 쿼리 완료 대기 + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 풀 완전 종료 + if (testPool) { + // 강제 종료: 모든 활성 쿼리와 연결 중지 + await testPool.end(); + } + + // 추가 정리 시간 + await new Promise(resolve => setTimeout(resolve, 1000)); logger.info('LeaderboardRepository 통합 테스트 DB 연결 종료'); } catch (error) { @@ -225,6 +230,16 @@ describe.skip('LeaderboardRepository 통합 테스트', () => { expect(user.username).not.toBeNull(); }); }); + + it('데이터 수집이 비정상적인 유저는 리더보드에 포함되지 않아야 한다', async () => { + const result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, 30); + + if (!isEnoughData(result, 1, '사용자 리더보드 비정상 유저 필터링')) return; + + result.forEach((user) => { + expect(Number(user.total_views)).not.toBe(Number(user.view_diff)); + }); + }); }); describe('getPostLeaderboard', () => { @@ -342,6 +357,20 @@ describe.skip('LeaderboardRepository 통합 테스트', () => { expect(areDifferent).toBe(true); } }); + + it('데이터 수집이 비정상적인 게시물은 리더보드에 포함되지 않아야 한다', async () => { + const result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, DEFAULT_PARAMS.DATE_RANGE, 30); + const pastDateKST = getKSTDateStringWithOffset(-DEFAULT_PARAMS.DATE_RANGE * 24 * 60); + + if (!isEnoughData(result, 1, '게시물 리더보드 비정상 게시물 필터링')) return; + + result.forEach((post) => { + if (post.released_at < pastDateKST) { + // eslint-disable-next-line jest/no-conditional-expect + expect(Number(post.total_views)).not.toBe(Number(post.view_diff)); + } + }); + }); }); }); From af921f20a3e45b6c043cb8eaa4dc6d5be2e0bbdc Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 26 Aug 2025 00:46:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A7=91=20=EC=A0=84(00=EC=8B=9C?= =?UTF-8?q?~01=EC=8B=9C)=EC=9D=98=20=EC=9A=94=EC=B2=AD=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EB=B0=A9=EC=96=B4=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 0c0a794..38e512d 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -84,7 +84,11 @@ export class LeaderboardRepository { // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) { - const nowDateKST = getCurrentKSTDateString(); + // KST 기준 00시~01시 (UTC 15:00~16:00) 사이라면 전날 데이터를 사용 + const nowDateKST = new Date().getUTCHours() === 15 + ? getKSTDateStringWithOffset(-24 * 60) // 전날 데이터 + : getCurrentKSTDateString(); + if (!pastDateKST) { pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); } From b39315a83ac68d720cec3e331c2180b8365a1ad9 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Tue, 26 Aug 2025 00:50:32 +0900 Subject: [PATCH 5/6] fix: linting --- src/controllers/user.controller.ts | 1 - .../__test__/integration/post.repo.integration.test.ts | 1 - src/repositories/leaderboard.repository.ts | 7 ++++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 7f1af81..8ce0ab3 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -8,7 +8,6 @@ import { fetchVelogApi } from '@/modules/velog/velog.api'; type Token10 = string & { __lengthBrand: 10 }; -// eslint-disable-next-line @typescript-eslint/naming-convention const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000; export class UserController { diff --git a/src/repositories/__test__/integration/post.repo.integration.test.ts b/src/repositories/__test__/integration/post.repo.integration.test.ts index 6fcefe6..cdae57d 100644 --- a/src/repositories/__test__/integration/post.repo.integration.test.ts +++ b/src/repositories/__test__/integration/post.repo.integration.test.ts @@ -19,7 +19,6 @@ describe('PostRepository 통합 테스트', () => { let repo: PostRepository; // 테스트에 사용할 기본 데이터 ID - // eslint-disable-next-line @typescript-eslint/naming-convention const TEST_DATA = { USER_ID: 1, POST_ID: 2445, diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 38e512d..0db4dc3 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -85,9 +85,10 @@ export class LeaderboardRepository { // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) { // KST 기준 00시~01시 (UTC 15:00~16:00) 사이라면 전날 데이터를 사용 - const nowDateKST = new Date().getUTCHours() === 15 - ? getKSTDateStringWithOffset(-24 * 60) // 전날 데이터 - : getCurrentKSTDateString(); + const nowDateKST = + new Date().getUTCHours() === 15 + ? getKSTDateStringWithOffset(-24 * 60) // 전날 데이터 + : getCurrentKSTDateString(); if (!pastDateKST) { pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); From 9d343be0cc8a14e73065c8827be399ea720d55e3 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 27 Aug 2025 09:01:32 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EB=B9=84=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EA=B5=AC=EB=AC=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/__test__/leaderboard.repo.test.ts | 4 ++-- src/repositories/leaderboard.repository.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 20361bf..39801e2 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -88,7 +88,7 @@ describe('LeaderboardRepository', () => { await repo.getUserLeaderboard('viewCount', 30, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('HAVING SUM(COALESCE(ts.today_view, 0)) != SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0))'), + expect.stringContaining('HAVING SUM(COALESCE(ss.start_view, 0)) != 0'), expect.anything(), ); }); @@ -174,7 +174,7 @@ describe('LeaderboardRepository', () => { await repo.getPostLeaderboard('viewCount', 30, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('COALESCE(ts.today_view, 0) != COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)'), + expect.stringContaining('ss.post_id IS NOT NULL'), expect.anything() ); }); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 0db4dc3..a184e53 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -30,7 +30,7 @@ export class LeaderboardRepository { LEFT JOIN start_stats ss ON ss.post_id = p.id WHERE u.username IS NOT NULL GROUP BY u.id, u.email, u.username - HAVING SUM(COALESCE(ts.today_view, 0)) != SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) + HAVING SUM(COALESCE(ss.start_view, 0)) != 0 ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id LIMIT $1; `; @@ -68,7 +68,7 @@ export class LeaderboardRepository { AND ( p.released_at >= '${pastDateKST}' OR - COALESCE(ts.today_view, 0) != COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) + ss.post_id IS NOT NULL ) ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id LIMIT $1;