From 75c184b272f400ad8a978ca356827ea43fc03c87 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 30 Apr 2025 08:19:23 +0900 Subject: [PATCH 1/9] =?UTF-8?q?modify:=20=ED=86=B5=EA=B3=84=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B0=92=EC=9D=84=20number=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20::int=20=EC=BA=90=EC=8A=A4=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/leaderboard.repository.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index e051fd5..5a92cda 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -15,12 +15,12 @@ export class LeaderboardRepository { 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 + COALESCE(SUM(ts.today_view), 0)::int AS total_views, + COALESCE(SUM(ts.today_like), 0)::int AS total_likes, + COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END)::int AS total_posts, + SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)))::int AS view_diff, + SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)))::int 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)::int 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 @@ -50,10 +50,10 @@ export class LeaderboardRepository { p.title, p.slug, p.released_at, - 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)::int AS total_views, + COALESCE(ts.today_like, 0)::int AS total_likes, + COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))::int AS view_diff, + COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))::int 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 From 07eaadcb004c00d1bf4a30133b24b243e62e8c83 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 30 Apr 2025 08:19:49 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EC=A0=95=EB=A0=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=93=A4?= =?UTF-8?q?=EC=9D=84=20each=EB=A1=9C=20=EB=AC=B6=EC=96=B4=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=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 | 67 +++++++------------ 1 file changed, 26 insertions(+), 41 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 7955f51..3e41a37 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -1,6 +1,7 @@ import { Pool, QueryResult } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types'; jest.mock('pg'); @@ -60,31 +61,19 @@ describe('LeaderboardRepository', () => { expect(result).toEqual(mockResult); }); - it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { - await repo.getUserLeaderboard('viewCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY view_diff DESC'), - expect.anything(), - ); - }); - - it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { - await repo.getUserLeaderboard('likeCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY like_diff DESC'), - expect.anything(), - ); - }); - - it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { - await repo.getUserLeaderboard('postCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY post_diff DESC'), - expect.anything(), - ); + describe.each([ + { sort: 'viewCount', field: 'view_diff' }, + { sort: 'likeCount', field: 'like_diff' }, + { sort: 'postCount', field: 'post_diff' }, + ])('sort 파라미터에 따라 내림차순 정렬되어야 한다', ({ sort, field }) => { + it(`sort가 ${sort}인 경우 ${field} 필드를 기준으로 정렬해야 한다`, async () => { + await repo.getUserLeaderboard(sort as UserLeaderboardSortType, 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining(`ORDER BY ${field} DESC`), + expect.anything(), + ); + }); }); it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { @@ -148,22 +137,18 @@ describe('LeaderboardRepository', () => { expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything()); }); - it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { - await repo.getPostLeaderboard('viewCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY view_diff DESC'), - expect.anything(), - ); - }); - - it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { - await repo.getPostLeaderboard('likeCount', 30, 10); - - expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY like_diff DESC'), - expect.anything(), - ); + describe.each([ + { sort: 'viewCount', field: 'view_diff' }, + { sort: 'likeCount', field: 'like_diff' }, + ])('sort 파라미터에 따라 내림차순 정렬되어야 한다', ({ sort, field }) => { + it(`sort가 ${sort}인 경우 ${field} 필드를 기준으로 정렬해야 한다`, async () => { + await repo.getPostLeaderboard(sort as PostLeaderboardSortType, 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining(`ORDER BY ${field} DESC`), + expect.anything(), + ); + }); }); it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { From 1202fd23a3781d6fcc43cb1b7ce981641f5eb2d2 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 30 Apr 2025 08:20:39 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test:=20Leaderboard=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=ED=86=B5?= =?UTF-8?q?=ED=95=A9=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 --- .../leaderboard.repo.integration.test.ts | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 src/repositories/__test__/leaderboard.repo.integration.test.ts diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts new file mode 100644 index 0000000..07e2980 --- /dev/null +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -0,0 +1,348 @@ +import logger from '@/configs/logger.config'; +import dotenv from 'dotenv'; +import pg from 'pg'; +import { Pool } from 'pg'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { PostLeaderboardSortType, UserLeaderboardSortType } from '@/types'; + +dotenv.config(); + +/** + * LeaderboardRepository 통합 테스트 + * + * 이 테스트 파일은 실제 데이터베이스와 연결하여 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, + DATE_RANGE: 30, + LIMIT: 10, + }; + + beforeAll(async () => { + try { + const testPoolConfig: pg.PoolConfig = { + database: process.env.DATABASE_NAME, + user: process.env.POSTGRES_USER, + host: process.env.POSTGRES_HOST, + password: process.env.POSTGRES_PASSWORD, + port: Number(process.env.POSTGRES_PORT), + max: 1, // 최대 연결 수 + idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) + connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) + allowExitOnIdle: false, // 유휴 상태에서 종료 허용 + statement_timeout: 30000, + }; + + // localhost 가 아니면 ssl 필수 + if (process.env.POSTGRES_HOST != 'localhost') { + testPoolConfig.ssl = { + rejectUnauthorized: false, + }; + } + + testPool = new Pool(testPoolConfig); + + // 연결 확인 + await testPool.query('SELECT 1'); + logger.info('LeaderboardRepository 통합 테스트 DB 연결 성공'); + + // 리포지토리 인스턴스 생성 + repo = new LeaderboardRepository(testPool); + + // 충분한 데이터가 있는지 확인 (limit 기본값인 10을 기준으로 함) + const userCheck = await testPool.query('SELECT COUNT(*) >= 10 AS is_enough FROM users_user'); + const postCheck = await testPool.query('SELECT COUNT(*) >= 10 AS is_enough FROM posts_post'); + const statsCheck = await testPool.query('SELECT COUNT(*) > 0 AS is_enough FROM posts_postdailystatistics'); + + if (!userCheck.rows[0].is_enough || !postCheck.rows[0].is_enough || !statsCheck.rows[0].is_enough) { + logger.warn('주의: LeaderboardRepository 통합 테스트를 위한 충분한 데이터가 없습니다.'); + } + } catch (error) { + logger.error('LeaderboardRepository 통합 테스트 설정 중 오류 발생:', error); + throw error; + } + }); + + afterAll(async () => { + try { + // 모든 쿼리 완료 대기 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 풀 완전 종료 + if (testPool) { + // 강제 종료: 모든 활성 쿼리와 연결 중지 + await testPool.end(); + } + + logger.info('LeaderboardRepository 통합 테스트 DB 연결 종료'); + } catch (error) { + logger.error('LeaderboardRepository 통합 테스트 종료 중 오류:', error); + } + }); + + describe('getUserLeaderboard', () => { + it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { + const result = await repo.getUserLeaderboard( + DEFAULT_PARAMS.USER_SORT, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + expect(Array.isArray(result)).toBe(true); + + if (!isEnoughData(result, 1, '사용자 리더보드 반환값')) return; + + result.forEach((leaderboardUser) => { + expect(typeof leaderboardUser.id).toEqual('string'); + expect(typeof leaderboardUser.email).toEqual('string'); + expect(typeof leaderboardUser.total_views).toEqual('number'); + expect(typeof leaderboardUser.total_likes).toEqual('number'); + expect(typeof leaderboardUser.total_posts).toEqual('number'); + expect(typeof leaderboardUser.view_diff).toEqual('number'); + expect(typeof leaderboardUser.like_diff).toEqual('number'); + expect(typeof leaderboardUser.post_diff).toEqual('number'); + }); + }); + + it('통계와 관련된 필드는 음수가 아니어야 한다', async () => { + const result = await repo.getUserLeaderboard( + DEFAULT_PARAMS.USER_SORT, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(result, 1, '사용자 리더보드 반환값')) return; + + result.forEach((leaderboardUser) => { + expect(leaderboardUser.total_views).toBeGreaterThanOrEqual(0); + expect(leaderboardUser.total_likes).toBeGreaterThanOrEqual(0); + expect(leaderboardUser.total_posts).toBeGreaterThanOrEqual(0); + expect(leaderboardUser.view_diff).toBeGreaterThanOrEqual(0); + expect(leaderboardUser.like_diff).toBeGreaterThanOrEqual(0); + expect(leaderboardUser.post_diff).toBeGreaterThanOrEqual(0); + }); + }); + + describe.each([ + { sort: 'viewCount', field: 'view_diff' }, + { sort: 'likeCount', field: 'like_diff' }, + { sort: 'postCount', field: 'post_diff' }, + ])('sort 파라미터에 따라 내림차순 정렬되어야 한다', ({ sort, field }) => { + it(`sort가 ${sort}인 경우 ${field} 필드를 기준으로 정렬해야 한다`, async () => { + const result = await repo.getUserLeaderboard( + sort as UserLeaderboardSortType, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(result, 2, `사용자 리더보드 정렬 (${sort})`)) return; + + const isSorted = result.every((leaderboardUser, idx) => { + if (idx === 0) return true; + return leaderboardUser[field] <= result[idx - 1][field]; + }); + + expect(isSorted).toBe(true); + }); + }); + + it('다양한 정렬 기준으로 결과를 반환해야 한다', async () => { + const resultByViewDiff = await repo.getUserLeaderboard( + 'viewCount', + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + const resultByLikeDiff = await repo.getUserLeaderboard( + 'likeCount', + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + const resultByPostDiff = await repo.getUserLeaderboard( + 'postCount', + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(resultByViewDiff, 2, '사용자 리더보드 정렬')) return; + + // 정렬 기준에 따라 결과가 달라야 하나, 순위가 같을 수 있어 하나라도 다르면 통과 + const areDifferent = resultByViewDiff.some( + (userByViewDiff, idx) => + userByViewDiff.id !== resultByLikeDiff[idx].id || userByViewDiff.id !== resultByPostDiff[idx].id, + ); + + // 데이터 상태에 따라 결과가 같을 수도 있어 조건부 검증 + if (areDifferent) { + // eslint-disable-next-line jest/no-conditional-expect + expect(areDifferent).toBe(true); + } + }); + + it('limit 파라미터가 결과 개수를 제한해야 한다', async () => { + const limit5Result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, 5); + const limit10Result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, 10); + + if (!isEnoughData(limit10Result, 10, '사용자 리더보드 limit 파라미터')) return; + + expect(limit5Result.length).toBe(5); + expect(limit10Result.length).toBe(10); + }); + + it('dateRange 파라미터를 통한 날짜 범위가 적용되어야 한다', async () => { + const range3Result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, 3, DEFAULT_PARAMS.LIMIT); + const range30Result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, 30, DEFAULT_PARAMS.LIMIT); + + if (!isEnoughData(range3Result, 2, '사용자 리더보드 dateRange 파라미터')) return; + + // 3일 범위 결과와 30일 범위 결과가 달라야 하나, 순위가 같을 수 있어 하나라도 다르면 통과 + const areDifferent = range3Result.some((userBy3Days, idx) => userBy3Days.id !== range30Result[idx].id); + + // 데이터 상태에 따라 결과가 같을 수도 있어 조건부 검증 + if (areDifferent) { + // eslint-disable-next-line jest/no-conditional-expect + expect(areDifferent).toBe(true); + } + }); + + it('email이 null인 사용자는 제외되어야 한다', async () => { + const result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, 30); + + if (!isEnoughData(result, 1, '사용자 리더보드 email null 제외')) return; + + result.forEach((user) => { + expect(user.email).not.toBeNull(); + }); + }); + }); + + describe('getPostLeaderboard', () => { + it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { + const result = await repo.getPostLeaderboard( + DEFAULT_PARAMS.POST_SORT, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + expect(Array.isArray(result)).toBe(true); + + if (!isEnoughData(result, 1, '게시물 리더보드 반환값')) return; + + result.forEach((leaderboardPost) => { + expect(typeof leaderboardPost.id).toEqual('string'); + expect(typeof leaderboardPost.title).toEqual('string'); + expect(typeof leaderboardPost.slug).toEqual('string'); + expect(typeof leaderboardPost.total_views).toEqual('number'); + expect(typeof leaderboardPost.total_likes).toEqual('number'); + expect(typeof leaderboardPost.view_diff).toEqual('number'); + expect(typeof leaderboardPost.like_diff).toEqual('number'); + expect(typeof leaderboardPost.released_at).toEqual('object'); + }); + }); + + it('통계와 관련된 필드는 음수가 아니어야 한다', async () => { + const result = await repo.getPostLeaderboard( + DEFAULT_PARAMS.POST_SORT, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(result, 1, '게시물 리더보드 반환값')) return; + + result.forEach((leaderboardPost) => { + expect(leaderboardPost.total_views).toBeGreaterThanOrEqual(0); + expect(leaderboardPost.total_likes).toBeGreaterThanOrEqual(0); + expect(leaderboardPost.view_diff).toBeGreaterThanOrEqual(0); + expect(leaderboardPost.like_diff).toBeGreaterThanOrEqual(0); + }); + }); + + describe.each([ + { sort: 'viewCount', field: 'view_diff' }, + { sort: 'likeCount', field: 'like_diff' }, + ])('sort 파라미터에 따라 내림차순 정렬되어야 한다', ({ sort, field }) => { + it(`sort가 ${sort}인 경우 ${field} 필드를 기준으로 정렬해야 한다`, async () => { + const result = await repo.getPostLeaderboard( + sort as PostLeaderboardSortType, + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(result, 2, `게시물 리더보드 정렬 (${sort})`)) return; + + const isSorted = result.every((leaderboardPost, idx) => { + if (idx === 0) return true; + return leaderboardPost[field] <= result[idx - 1][field]; + }); + + expect(isSorted).toBe(true); + }); + }); + + it('다양한 정렬 기준으로 결과를 반환해야 한다', async () => { + const resultByViewDiff = await repo.getPostLeaderboard( + 'viewCount', + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + const resultByLikeDiff = await repo.getPostLeaderboard( + 'likeCount', + DEFAULT_PARAMS.DATE_RANGE, + DEFAULT_PARAMS.LIMIT, + ); + + if (!isEnoughData(resultByViewDiff, 2, '게시물 리더보드 정렬')) return; + + // 정렬 기준에 따라 결과가 달라야 하나, 순위가 같을 수 있어 하나라도 다르면 통과 + const areDifferent = resultByViewDiff.some( + (postByViewDiff, idx) => postByViewDiff.id !== resultByLikeDiff[idx].id, + ); + + // 데이터 상태에 따라 결과가 같을 수도 있어 조건부 검증 + if (areDifferent) { + // eslint-disable-next-line jest/no-conditional-expect + expect(areDifferent).toBe(true); + } + }); + + it('limit 파라미터가 결과 개수를 제한해야 한다', async () => { + const limit5Result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, DEFAULT_PARAMS.DATE_RANGE, 5); + const limit10Result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, DEFAULT_PARAMS.DATE_RANGE, 10); + + if (!isEnoughData(limit10Result, 10, '게시물 리더보드 limit 파라미터')) return; + + expect(limit5Result.length).toBe(5); + expect(limit10Result.length).toBe(10); + }); + + it('dateRange 파라미터를 통한 날짜 범위가 적용되어야 한다', async () => { + const range3Result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, 3, DEFAULT_PARAMS.LIMIT); + const range30Result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, 30, DEFAULT_PARAMS.LIMIT); + + if (!isEnoughData(range3Result, 2, '게시물 리더보드 dateRange 파라미터')) return; + + // 3일 범위 결과와 30일 범위 결과가 달라야 하나, 순위가 같을 수 있어 하나라도 다르면 통과 + const areDifferent = range3Result.some((postBy3Days, idx) => postBy3Days.id !== range30Result[idx].id); + + // 데이터 상태에 따라 결과가 같을 수도 있어 조건부 검증 + if (areDifferent) { + // eslint-disable-next-line jest/no-conditional-expect + expect(areDifferent).toBe(true); + } + }); + }); +}); + +function isEnoughData(result: unknown[], limit: number, testName: string): boolean { + if (result.length < limit) { + logger.info(`충분한 데이터가 없어 ${testName} 테스트를 건너뜁니다.`); + return false; + } + return true; +} From 863b1d075fbc602faea7935ee5ae3ba8a8f2eeb7 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 30 Apr 2025 10:38:20 +0900 Subject: [PATCH 4/9] =?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 --- .../__test__/leaderboard.repo.integration.test.ts | 8 ++------ src/repositories/leaderboard.repository.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts index 07e2980..045888e 100644 --- a/src/repositories/__test__/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -72,14 +72,10 @@ describe('LeaderboardRepository 통합 테스트', () => { afterAll(async () => { try { - // 모든 쿼리 완료 대기 - await new Promise((resolve) => setTimeout(resolve, 500)); + jest.clearAllMocks(); // 풀 완전 종료 - if (testPool) { - // 강제 종료: 모든 활성 쿼리와 연결 중지 - await testPool.end(); - } + await testPool.end(); logger.info('LeaderboardRepository 통합 테스트 DB 연결 종료'); } catch (error) { diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 5a92cda..cfac5de 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -52,8 +52,8 @@ export class LeaderboardRepository { p.released_at, COALESCE(ts.today_view, 0)::int AS total_views, COALESCE(ts.today_like, 0)::int AS total_likes, - COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))::int AS view_diff, - COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))::int AS like_diff + (COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)))::int AS view_diff, + (COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)))::int 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 From 355c7c3baf0ee00d7f6612074e0c947e4445bc95 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 30 Apr 2025 10:39:21 +0900 Subject: [PATCH 5/9] =?UTF-8?q?modify:=20CI=20=ED=86=B5=EA=B3=BC=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=95=84=EC=9B=83=EC=9D=84=2020=EC=B4=88=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/__test__/leaderboard.repo.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts index 045888e..0334097 100644 --- a/src/repositories/__test__/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -7,6 +7,8 @@ import { PostLeaderboardSortType, UserLeaderboardSortType } from '@/types'; dotenv.config(); +jest.setTimeout(20000); // 각 케이스당 20초 타임아웃 설정 + /** * LeaderboardRepository 통합 테스트 * From eba00ee987376d150885e857ae8de6d350350208 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 2 May 2025 01:58:04 +0900 Subject: [PATCH 6/9] =?UTF-8?q?revert:=20=EC=BF=BC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20int=20=EC=BA=90=EC=8A=A4=ED=8C=85=20?= =?UTF-8?q?=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 | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index cfac5de..e051fd5 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -15,12 +15,12 @@ export class LeaderboardRepository { SELECT u.id AS id, u.email AS email, - COALESCE(SUM(ts.today_view), 0)::int AS total_views, - COALESCE(SUM(ts.today_like), 0)::int AS total_likes, - COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END)::int AS total_posts, - SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)))::int AS view_diff, - SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)))::int 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)::int AS post_diff + 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 @@ -50,10 +50,10 @@ export class LeaderboardRepository { p.title, p.slug, p.released_at, - COALESCE(ts.today_view, 0)::int AS total_views, - COALESCE(ts.today_like, 0)::int AS total_likes, - (COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)))::int AS view_diff, - (COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)))::int AS like_diff + 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 LEFT JOIN today_stats ts ON ts.post_id = p.id LEFT JOIN start_stats ss ON ss.post_id = p.id From c3cf5787ae6883009bfb12645317709a9e0cc25a Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 2 May 2025 02:12:56 +0900 Subject: [PATCH 7/9] =?UTF-8?q?modify:=20number=EB=A1=9C=EC=9D=98=20?= =?UTF-8?q?=ED=98=95=20=EB=B3=80=ED=99=98=EC=9D=84=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../leaderboard.repo.integration.test.ts | 58 +++++++++---------- .../__test__/leaderboard.service.test.ts | 40 ++++++------- src/services/leaderboard.service.ts | 40 ++++++------- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts index 0334097..3c0dcd4 100644 --- a/src/repositories/__test__/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -86,7 +86,7 @@ describe('LeaderboardRepository 통합 테스트', () => { }); describe('getUserLeaderboard', () => { - it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { + it('사용자 통계 배열로 이루어진 리더보드를 반환해야 하며, 통계 값은 음수가 아니어야 한다', async () => { const result = await repo.getUserLeaderboard( DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, @@ -98,14 +98,14 @@ describe('LeaderboardRepository 통합 테스트', () => { if (!isEnoughData(result, 1, '사용자 리더보드 반환값')) return; result.forEach((leaderboardUser) => { - expect(typeof leaderboardUser.id).toEqual('string'); - expect(typeof leaderboardUser.email).toEqual('string'); - expect(typeof leaderboardUser.total_views).toEqual('number'); - expect(typeof leaderboardUser.total_likes).toEqual('number'); - expect(typeof leaderboardUser.total_posts).toEqual('number'); - expect(typeof leaderboardUser.view_diff).toEqual('number'); - expect(typeof leaderboardUser.like_diff).toEqual('number'); - expect(typeof leaderboardUser.post_diff).toEqual('number'); + expect(leaderboardUser).toHaveProperty('id'); + expect(leaderboardUser).toHaveProperty('email'); + expect(leaderboardUser).toHaveProperty('total_views'); + expect(leaderboardUser).toHaveProperty('total_likes'); + expect(leaderboardUser).toHaveProperty('total_posts'); + expect(leaderboardUser).toHaveProperty('view_diff'); + expect(leaderboardUser).toHaveProperty('like_diff'); + expect(leaderboardUser).toHaveProperty('post_diff'); }); }); @@ -119,12 +119,12 @@ describe('LeaderboardRepository 통합 테스트', () => { if (!isEnoughData(result, 1, '사용자 리더보드 반환값')) return; result.forEach((leaderboardUser) => { - expect(leaderboardUser.total_views).toBeGreaterThanOrEqual(0); - expect(leaderboardUser.total_likes).toBeGreaterThanOrEqual(0); - expect(leaderboardUser.total_posts).toBeGreaterThanOrEqual(0); - expect(leaderboardUser.view_diff).toBeGreaterThanOrEqual(0); - expect(leaderboardUser.like_diff).toBeGreaterThanOrEqual(0); - expect(leaderboardUser.post_diff).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.total_views)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.total_likes)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.total_posts)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.view_diff)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.like_diff)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardUser.post_diff)).toBeGreaterThanOrEqual(0); }); }); @@ -144,7 +144,7 @@ describe('LeaderboardRepository 통합 테스트', () => { const isSorted = result.every((leaderboardUser, idx) => { if (idx === 0) return true; - return leaderboardUser[field] <= result[idx - 1][field]; + return Number(leaderboardUser[field]) <= Number(result[idx - 1][field]); }); expect(isSorted).toBe(true); @@ -233,14 +233,14 @@ describe('LeaderboardRepository 통합 테스트', () => { if (!isEnoughData(result, 1, '게시물 리더보드 반환값')) return; result.forEach((leaderboardPost) => { - expect(typeof leaderboardPost.id).toEqual('string'); - expect(typeof leaderboardPost.title).toEqual('string'); - expect(typeof leaderboardPost.slug).toEqual('string'); - expect(typeof leaderboardPost.total_views).toEqual('number'); - expect(typeof leaderboardPost.total_likes).toEqual('number'); - expect(typeof leaderboardPost.view_diff).toEqual('number'); - expect(typeof leaderboardPost.like_diff).toEqual('number'); - expect(typeof leaderboardPost.released_at).toEqual('object'); + expect(leaderboardPost).toHaveProperty('id'); + expect(leaderboardPost).toHaveProperty('title'); + expect(leaderboardPost).toHaveProperty('slug'); + expect(leaderboardPost).toHaveProperty('total_views'); + expect(leaderboardPost).toHaveProperty('total_likes'); + expect(leaderboardPost).toHaveProperty('view_diff'); + expect(leaderboardPost).toHaveProperty('like_diff'); + expect(leaderboardPost).toHaveProperty('released_at'); }); }); @@ -254,10 +254,10 @@ describe('LeaderboardRepository 통합 테스트', () => { if (!isEnoughData(result, 1, '게시물 리더보드 반환값')) return; result.forEach((leaderboardPost) => { - expect(leaderboardPost.total_views).toBeGreaterThanOrEqual(0); - expect(leaderboardPost.total_likes).toBeGreaterThanOrEqual(0); - expect(leaderboardPost.view_diff).toBeGreaterThanOrEqual(0); - expect(leaderboardPost.like_diff).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardPost.total_views)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardPost.total_likes)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardPost.view_diff)).toBeGreaterThanOrEqual(0); + expect(Number(leaderboardPost.like_diff)).toBeGreaterThanOrEqual(0); }); }); @@ -276,7 +276,7 @@ describe('LeaderboardRepository 통합 테스트', () => { const isSorted = result.every((leaderboardPost, idx) => { if (idx === 0) return true; - return leaderboardPost[field] <= result[idx - 1][field]; + return Number(leaderboardPost[field]) <= Number(result[idx - 1][field]); }); expect(isSorted).toBe(true); diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index b99338e..8fde1a2 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -30,22 +30,22 @@ describe('LeaderboardService', () => { { id: '1', email: 'test@test.com', - total_views: 100, - total_likes: 50, - total_posts: 1, - view_diff: 20, - like_diff: 10, - post_diff: 1, + 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, + total_views: '200', + total_likes: '100', + total_posts: '2', + view_diff: '10', + like_diff: '5', + post_diff: '1', }, ]; @@ -121,20 +121,20 @@ describe('LeaderboardService', () => { id: '1', title: 'test', slug: 'test-slug', - total_views: 100, - total_likes: 50, - view_diff: 20, - like_diff: 10, + 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, + total_views: '200', + total_likes: '100', + view_diff: '10', + like_diff: '5', released_at: '2025-01-02', }, ]; diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index f0b1e50..a44b44a 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -42,12 +42,12 @@ export class LeaderboardService { 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, + totalViews: Number(user.total_views), + totalLikes: Number(user.total_likes), + totalPosts: Number(user.total_posts), + viewDiff: Number(user.view_diff), + likeDiff: Number(user.like_diff), + postDiff: Number(user.post_diff), })); return { users }; @@ -58,10 +58,10 @@ export class LeaderboardService { 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, + totalViews: Number(post.total_views), + totalLikes: Number(post.total_likes), + viewDiff: Number(post.view_diff), + likeDiff: Number(post.like_diff), releasedAt: post.released_at, })); @@ -73,20 +73,20 @@ interface RawPostResult { id: string; title: string; slug: string; - total_views: number; - total_likes: number; - view_diff: number; - like_diff: number; + total_views: string; + total_likes: string; + view_diff: string; + like_diff: string; released_at: string; } interface RawUserResult { id: string; email: string; - total_views: number; - total_likes: number; - total_posts: number; - view_diff: number; - like_diff: number; - post_diff: number; + total_views: string; + total_likes: string; + total_posts: string; + view_diff: string; + like_diff: string; + post_diff: string; } From 0abed19613568c836e92e96399bedaa7a9278df9 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 2 May 2025 02:21:49 +0900 Subject: [PATCH 8/9] =?UTF-8?q?modify:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/__test__/leaderboard.repo.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts index 3c0dcd4..22aa82b 100644 --- a/src/repositories/__test__/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -86,7 +86,7 @@ describe('LeaderboardRepository 통합 테스트', () => { }); describe('getUserLeaderboard', () => { - it('사용자 통계 배열로 이루어진 리더보드를 반환해야 하며, 통계 값은 음수가 아니어야 한다', async () => { + it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { const result = await repo.getUserLeaderboard( DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, From c8a4bcc68f2907edf33c8afcabe904870a838683 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Fri, 2 May 2025 02:42:12 +0900 Subject: [PATCH 9/9] =?UTF-8?q?modify:=20id=EA=B0=92=20=EC=98=A4=EB=A6=84?= =?UTF-8?q?=EC=B0=A8=EC=88=9C=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4=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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index e051fd5..c978825 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -27,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 ${this.SORT_COL_MAPPING[sort]} DESC + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id LIMIT $2; `; const result = await this.pool.query(query, [dateRange, limit]); @@ -58,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 ${this.SORT_COL_MAPPING[sort]} DESC + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id LIMIT $2; `; const result = await this.pool.query(query, [dateRange, limit]);