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/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)); + } + }); + }); }); }); 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/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 2daf91f..39801e2 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(ss.start_view, 0)) != 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('ss.post_id IS NOT NULL'), + 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..a184e53 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} @@ -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(ss.start_view, 0)) != 0 ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id LIMIT $1; `; @@ -44,7 +45,8 @@ export class LeaderboardRepository { async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { try { - const cteQuery = this.buildLeaderboardCteQuery(dateRange); + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST); const query = ` ${cteQuery} @@ -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 + ss.post_id IS NOT NULL + ) ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id LIMIT $1; `; @@ -76,10 +83,16 @@ export class LeaderboardRepository { } // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 - private buildLeaderboardCteQuery(dateRange: number) { - const nowDateKST = getCurrentKSTDateString(); - // 과거 날짜 계산 (dateRange일 전) - const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) { + // 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); + } return ` WITH @@ -89,8 +102,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 +110,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}' ) `; }