diff --git a/src/repositories/__test__/post.repo.integration.test.ts b/src/repositories/__test__/post.repo.integration.test.ts index f0d3cd8..4aaf767 100644 --- a/src/repositories/__test__/post.repo.integration.test.ts +++ b/src/repositories/__test__/post.repo.integration.test.ts @@ -262,6 +262,87 @@ describe('PostRepository 통합 테스트', () => { }); }); + /** + * findPostsByUserIdWithGrowthMetrics 테스트 + */ + describe('findPostsByUserIdWithGrowthMetrics', () => { + it('트래픽 성장률 데이터를 포함한 게시물 목록을 조회할 수 있어야 한다', async () => { + const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('posts'); + expect(result).toHaveProperty('nextCursor'); + expect(Array.isArray(result.posts)).toBe(true); + expect(result.posts[0]).toHaveProperty('view_growth'); + expect(result.posts[0]).toHaveProperty('like_growth'); + }); + + it('트래픽 성장률을 기준으로 내림차순 정렬해야 한다', async () => { + const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID); + + // 결과가 2개 이상인 경우만 의미 있는 검증 가능 + if (result.posts.length < 2) { + logger.info('트래픽 성장률 정렬 테스트를 위한 충분한 데이터가 없습니다.'); + return; + } + + // 내림차순 정렬 확인 + const isSortedByGrowth = result.posts.every((post, index) => { + if (index === 0) return true; + return post.view_growth <= result.posts[index - 1].view_growth; + }); + expect(isSortedByGrowth).toBe(true); + }); + + it('오름차순 정렬이 제대로 동작해야 한다', async () => { + const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID, undefined, true); + + expect(result).toBeDefined(); + expect(Array.isArray(result.posts)).toBe(true); + + // 결과가 2개 이상인 경우에만 검증 + if (result.posts.length >= 2) { + const isSortedAsc = result.posts.every((post, index) => { + if (index === 0) return true; + return post.view_growth >= result.posts[index - 1].view_growth; + }); + + // eslint-disable-next-line jest/no-conditional-expect + expect(isSortedAsc).toBe(true); + } + }); + + it('페이지네이션을 위한 nextCursor를 제공해야 한다', async () => { + // 먼저 제한된 수의 결과를 가져옴 + const limitedResult = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID, undefined, false, 1); + + // 최소 2개 이상의 게시물이 있으면 nextCursor가 있어야 함 + const totalCount = await repo.getTotalPostCounts(TEST_DATA.USER_ID); + + if (totalCount <= 1 || limitedResult.posts.length !== 1) { + logger.info('트래픽 성장률 페이지네이션 테스트를 위한 충분한 데이터가 없습니다.'); + return; + } + + expect(limitedResult.nextCursor).toBeTruthy(); + + // nextCursor를 사용한 두 번째 쿼리 + const secondPage = await repo.findPostsByUserIdWithGrowthMetrics( + TEST_DATA.USER_ID, + limitedResult.nextCursor || undefined + ); + + expect(secondPage.posts).toBeDefined(); + expect(Array.isArray(secondPage.posts)).toBe(true); + + // 첫 페이지와 두 번째 페이지의 항목은 중복되지 않아야 함 + const page1Ids = limitedResult.posts.map(post => post.id); + const page2Ids = secondPage.posts.map(post => post.id); + + const hasDuplicates = page1Ids.some(id => page2Ids.includes(id)); + expect(hasDuplicates).toBe(false); + }); + }); /** * getTotalPostCounts 테스트 diff --git a/src/repositories/__test__/post.repo.test.ts b/src/repositories/__test__/post.repo.test.ts index 3ded9ec..0bcc0a7 100644 --- a/src/repositories/__test__/post.repo.test.ts +++ b/src/repositories/__test__/post.repo.test.ts @@ -58,6 +58,107 @@ describe('PostRepository', () => { }); }); + describe('findPostsByUserIdWithGrowthMetrics', () => { + it('트래픽 성장률 데이터를 포함한 게시글 목록을 반환해야 한다', async () => { + const mockPosts = [ + { + id: 1, + post_released_at: '2025-03-01T00:00:00Z', + daily_view_count: 30, + yesterday_daily_view_count: 10, + view_growth: 20, + like_growth: 5 + }, + { + id: 2, + post_released_at: '2025-03-02T00:00:00Z', + daily_view_count: 25, + yesterday_daily_view_count: 15, + view_growth: 10, + like_growth: 3 + }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockPosts, + rowCount: mockPosts.length, + command: '', + oid: 0, + fields: [], + } as QueryResult); + + const result = await repo.findPostsByUserIdWithGrowthMetrics(1); + + expect(result.posts).toEqual(mockPosts); + expect(result).toHaveProperty('nextCursor'); + expect(result.posts[0]).toHaveProperty('view_growth'); + expect(result.posts[0]).toHaveProperty('like_growth'); + }); + + it('트래픽 성장률을 기준으로 내림차순 정렬해야 한다', async () => { + const mockPosts = [ + { + id: 1, + post_released_at: '2025-03-01T00:00:00Z', + daily_view_count: 30, + yesterday_daily_view_count: 10, + view_growth: 20 + }, + { + id: 2, + post_released_at: '2025-03-02T00:00:00Z', + daily_view_count: 25, + yesterday_daily_view_count: 15, + view_growth: 10 + }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockPosts, + rowCount: mockPosts.length, + command: '', + oid: 0, + fields: [], + } as QueryResult); + + const result = await repo.findPostsByUserIdWithGrowthMetrics(1, undefined, false); + expect(result.posts).toEqual(mockPosts); + expect(result.posts[0].view_growth).toBeGreaterThan(result.posts[1].view_growth); + }); + + it('커서 기반 페이지네이션이 트래픽 성장률 기준으로 작동해야 한다', async () => { + const mockPosts = [ + { + id: 3, + post_released_at: '2025-03-03T00:00:00Z', + daily_view_count: 20, + yesterday_daily_view_count: 15, + view_growth: 5 + }, + ]; + + mockPool.query.mockResolvedValue({ + rows: mockPosts, + rowCount: mockPosts.length, + command: '', + oid: 0, + fields: [], + } as QueryResult); + + const result = await repo.findPostsByUserIdWithGrowthMetrics(1, "10,2", false); + expect(result.posts).toEqual(mockPosts); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining("(COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) < $2"), + expect.arrayContaining([1, "10", "2", expect.anything()]) + ); + }); + + it('에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + await expect(repo.findPostsByUserIdWithGrowthMetrics(1)).rejects.toThrow(DBError); + }); + }); + describe('getTotalPostCounts', () => { it('사용자의 총 게시글 수를 반환해야 한다', async () => { mockPool.query.mockResolvedValue({ diff --git a/src/repositories/post.repository.ts b/src/repositories/post.repository.ts index da33460..367723d 100644 --- a/src/repositories/post.repository.ts +++ b/src/repositories/post.repository.ts @@ -5,7 +5,13 @@ import { DBError } from '@/exception'; export class PostRepository { constructor(private pool: Pool) { } - async findPostsByUserId(userId: number, cursor?: string, sort?: string, isAsc?: boolean, limit: number = 15) { + async findPostsByUserId( + userId: number, + cursor?: string, + sort?: string, + isAsc: boolean = false, + limit: number = 15 + ) { try { // 1) 정렬 컬럼 매핑 let sortCol = 'p.released_at'; @@ -62,10 +68,7 @@ export class PostRepository { pds.date FROM posts_post p LEFT JOIN ( - SELECT post_id, - daily_view_count, - daily_like_count, - date + SELECT post_id, daily_view_count, daily_like_count, date FROM posts_postdailystatistics WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date ) pds ON p.id = pds.post_id @@ -116,6 +119,98 @@ export class PostRepository { } } + // findPostsByUserId 와 동일 + // view_growth, like_growth 컬럼 추가 연산 + async findPostsByUserIdWithGrowthMetrics( + userId: number, + cursor?: string, + isAsc: boolean = false, + limit: number = 15 + ) { + try { + const selectFields = ` + p.id, + p.title, + p.slug, + p.created_at AS post_created_at, + p.released_at AS post_released_at, + COALESCE(pds.daily_view_count, 0) AS daily_view_count, + COALESCE(pds.daily_like_count, 0) AS daily_like_count, + COALESCE(yesterday_stats.daily_view_count, 0) AS yesterday_daily_view_count, + COALESCE(yesterday_stats.daily_like_count, 0) AS yesterday_daily_like_count, + pds.date, + (COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) AS view_growth, + (COALESCE(pds.daily_like_count, 0) - COALESCE(yesterday_stats.daily_like_count, 0)) AS like_growth + `; + + const direction = isAsc ? 'ASC' : 'DESC'; + const orderByExpression = `view_growth ${direction}, p.id ${direction}`; + + // 커서 처리 + let cursorCondition = ''; + let params: unknown[] = []; + + if (cursor) { + const [cursorSortValue, cursorId] = cursor.split(','); + + cursorCondition = ` + AND ( + (COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) ${isAsc ? '>' : '<'} $2 + OR ( + (COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) = $2 + AND p.id ${isAsc ? '>' : '<'} $3 + ) + ) + `; + + params = [userId, cursorSortValue, cursorId, limit]; + } else { + params = [userId, limit]; + } + + const query = ` + SELECT ${selectFields} + FROM posts_post p + LEFT JOIN ( + SELECT post_id, daily_view_count, daily_like_count, date + FROM posts_postdailystatistics + WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date + ) pds ON p.id = pds.post_id + LEFT JOIN ( + SELECT post_id, daily_view_count, daily_like_count, date + FROM posts_postdailystatistics + WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date + ) yesterday_stats ON p.id = yesterday_stats.post_id + WHERE p.user_id = $1 + AND (pds.post_id IS NOT NULL OR yesterday_stats.post_id IS NOT NULL) + ${cursorCondition} + ORDER BY ${orderByExpression} + LIMIT ${cursor ? '$4' : '$2'} + `; + + const posts = await this.pool.query(query, params); + + if (posts.rows.length === 0) { + return { + posts: [], + nextCursor: null, + }; + } + + // 다음 커서 생성 + const lastPost = posts.rows[posts.rows.length - 1]; + const nextCursor = `${lastPost.view_growth},${lastPost.id}`; + + return { + posts: posts.rows, + nextCursor, + }; + } catch (error) { + logger.error('Post Repo findPostsByUserIdWithGrowthMetrics error: ', error); + throw new DBError('트래픽 성장률 기준 post 조회 중 문제가 발생했습니다.'); + } + } + async getTotalPostCounts(id: number) { try { const query = 'SELECT COUNT(*) FROM "posts_post" WHERE user_id = $1'; diff --git a/src/services/post.service.ts b/src/services/post.service.ts index 099b428..d60bd1c 100644 --- a/src/services/post.service.ts +++ b/src/services/post.service.ts @@ -7,7 +7,13 @@ export class PostService { async getAllposts(userId: number, cursor?: string, sort: string = '', isAsc?: boolean, limit: number = 15) { try { - const result = await this.postRepo.findPostsByUserId(userId, cursor, sort, isAsc, limit); + let result = null; + if (sort === "viewGrowth") { + result = await this.postRepo.findPostsByUserIdWithGrowthMetrics(userId, cursor, isAsc, limit); + } + else { + result = await this.postRepo.findPostsByUserId(userId, cursor, sort, isAsc, limit); + } const transformedPosts = result.posts.map((post) => ({ id: post.id, diff --git a/src/types/dto/requests/getAllPostsQuery.type.ts b/src/types/dto/requests/getAllPostsQuery.type.ts index 9fefc4d..25262f9 100644 --- a/src/types/dto/requests/getAllPostsQuery.type.ts +++ b/src/types/dto/requests/getAllPostsQuery.type.ts @@ -7,15 +7,16 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator'; * schemas: * PostSortType: * type: string - * enum: ['', 'dailyViewCount', 'dailyLikeCount'] + * enum: ['', 'dailyViewCount', 'dailyLikeCount', 'viewGrowth'] * description: | * 포스트 정렬 기준 * * '' - 작성일 * * 'dailyViewCount' - 조회수 * * 'dailyLikeCount' - 좋아요수 + * * 'viewGrowth' - 조회수 증가량 * default: '' */ -export type PostSortType = '' | 'dailyViewCount' | 'dailyLikeCount'; +export type PostSortType = '' | 'dailyViewCount' | 'dailyLikeCount' | 'viewGrowth'; export interface GetAllPostsQuery { cursor?: string;