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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable jest/no-disabled-tests */
/**
* 주의: 이 통합 테스트는 현재 시간에 의존적입니다.
* getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로
Expand All @@ -10,22 +9,21 @@ 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 통합 테스트
*
* 이 테스트 파일은 실제 데이터베이스와 연결하여 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,
Expand All @@ -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 필수
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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));
}
});
});
});
});

Expand Down
22 changes: 20 additions & 2 deletions src/repositories/__test__/leaderboard.repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 19 additions & 13 deletions src/repositories/leaderboard.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -21,15 +21,16 @@ 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
LEFT JOIN today_stats ts ON ts.post_id = p.id
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;
`;
Expand All @@ -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}
Expand All @@ -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;
`;
Expand All @@ -76,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
Expand All @@ -89,17 +97,15 @@ 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)
post_id,
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}'
)
`;
}
Expand Down
Loading