Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { fetchVelogApi } from '@/modules/velog/velog.api';

type Token10 = string & { __lengthBrand: 10 };

// eslint-disable-next-line @typescript-eslint/naming-convention
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

대문자 only + 언더바는 워닝이 뜨게 되어 있을텐데!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 이 부분 네이밍 컨벤션은 왜 ignore하셨을까요?
현우님 말대로 경고가 뜨는게 맞을 것 같은데, 혹시 강제로라도 무시할 이유가 있을까요?

const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000;

export class UserController {
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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(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);
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('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);
Expand Down
39 changes: 25 additions & 14 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(ss.start_view, 0)) != 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
ss.post_id IS NOT NULL
)
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id
LIMIT $1;
`;
Expand All @@ -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
Expand All @@ -89,17 +102,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