Skip to content

Commit 8375920

Browse files
authored
[25.03.20 / TASK-132] Feature - view growth order (#24)
* feature: 레포 계층에만 viewGrowth 기반 조회 메서드 추가 * feature: 테스트 케이스 강화
1 parent 6ba4531 commit 8375920

File tree

5 files changed

+292
-8
lines changed

5 files changed

+292
-8
lines changed

src/repositories/__test__/post.repo.integration.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,87 @@ describe('PostRepository 통합 테스트', () => {
262262
});
263263
});
264264

265+
/**
266+
* findPostsByUserIdWithGrowthMetrics 테스트
267+
*/
268+
describe('findPostsByUserIdWithGrowthMetrics', () => {
269+
it('트래픽 성장률 데이터를 포함한 게시물 목록을 조회할 수 있어야 한다', async () => {
270+
const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID);
271+
272+
expect(result).toBeDefined();
273+
expect(result).toHaveProperty('posts');
274+
expect(result).toHaveProperty('nextCursor');
275+
expect(Array.isArray(result.posts)).toBe(true);
276+
expect(result.posts[0]).toHaveProperty('view_growth');
277+
expect(result.posts[0]).toHaveProperty('like_growth');
278+
});
279+
280+
it('트래픽 성장률을 기준으로 내림차순 정렬해야 한다', async () => {
281+
const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID);
282+
283+
// 결과가 2개 이상인 경우만 의미 있는 검증 가능
284+
if (result.posts.length < 2) {
285+
logger.info('트래픽 성장률 정렬 테스트를 위한 충분한 데이터가 없습니다.');
286+
return;
287+
}
288+
289+
// 내림차순 정렬 확인
290+
const isSortedByGrowth = result.posts.every((post, index) => {
291+
if (index === 0) return true;
292+
return post.view_growth <= result.posts[index - 1].view_growth;
293+
});
294+
expect(isSortedByGrowth).toBe(true);
295+
});
296+
297+
it('오름차순 정렬이 제대로 동작해야 한다', async () => {
298+
const result = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID, undefined, true);
299+
300+
expect(result).toBeDefined();
301+
expect(Array.isArray(result.posts)).toBe(true);
302+
303+
// 결과가 2개 이상인 경우에만 검증
304+
if (result.posts.length >= 2) {
305+
const isSortedAsc = result.posts.every((post, index) => {
306+
if (index === 0) return true;
307+
return post.view_growth >= result.posts[index - 1].view_growth;
308+
});
309+
310+
// eslint-disable-next-line jest/no-conditional-expect
311+
expect(isSortedAsc).toBe(true);
312+
}
313+
});
314+
315+
it('페이지네이션을 위한 nextCursor를 제공해야 한다', async () => {
316+
// 먼저 제한된 수의 결과를 가져옴
317+
const limitedResult = await repo.findPostsByUserIdWithGrowthMetrics(TEST_DATA.USER_ID, undefined, false, 1);
318+
319+
// 최소 2개 이상의 게시물이 있으면 nextCursor가 있어야 함
320+
const totalCount = await repo.getTotalPostCounts(TEST_DATA.USER_ID);
321+
322+
if (totalCount <= 1 || limitedResult.posts.length !== 1) {
323+
logger.info('트래픽 성장률 페이지네이션 테스트를 위한 충분한 데이터가 없습니다.');
324+
return;
325+
}
326+
327+
expect(limitedResult.nextCursor).toBeTruthy();
328+
329+
// nextCursor를 사용한 두 번째 쿼리
330+
const secondPage = await repo.findPostsByUserIdWithGrowthMetrics(
331+
TEST_DATA.USER_ID,
332+
limitedResult.nextCursor || undefined
333+
);
334+
335+
expect(secondPage.posts).toBeDefined();
336+
expect(Array.isArray(secondPage.posts)).toBe(true);
337+
338+
// 첫 페이지와 두 번째 페이지의 항목은 중복되지 않아야 함
339+
const page1Ids = limitedResult.posts.map(post => post.id);
340+
const page2Ids = secondPage.posts.map(post => post.id);
341+
342+
const hasDuplicates = page1Ids.some(id => page2Ids.includes(id));
343+
expect(hasDuplicates).toBe(false);
344+
});
345+
});
265346

266347
/**
267348
* getTotalPostCounts 테스트

src/repositories/__test__/post.repo.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,107 @@ describe('PostRepository', () => {
5858
});
5959
});
6060

61+
describe('findPostsByUserIdWithGrowthMetrics', () => {
62+
it('트래픽 성장률 데이터를 포함한 게시글 목록을 반환해야 한다', async () => {
63+
const mockPosts = [
64+
{
65+
id: 1,
66+
post_released_at: '2025-03-01T00:00:00Z',
67+
daily_view_count: 30,
68+
yesterday_daily_view_count: 10,
69+
view_growth: 20,
70+
like_growth: 5
71+
},
72+
{
73+
id: 2,
74+
post_released_at: '2025-03-02T00:00:00Z',
75+
daily_view_count: 25,
76+
yesterday_daily_view_count: 15,
77+
view_growth: 10,
78+
like_growth: 3
79+
},
80+
];
81+
82+
mockPool.query.mockResolvedValue({
83+
rows: mockPosts,
84+
rowCount: mockPosts.length,
85+
command: '',
86+
oid: 0,
87+
fields: [],
88+
} as QueryResult);
89+
90+
const result = await repo.findPostsByUserIdWithGrowthMetrics(1);
91+
92+
expect(result.posts).toEqual(mockPosts);
93+
expect(result).toHaveProperty('nextCursor');
94+
expect(result.posts[0]).toHaveProperty('view_growth');
95+
expect(result.posts[0]).toHaveProperty('like_growth');
96+
});
97+
98+
it('트래픽 성장률을 기준으로 내림차순 정렬해야 한다', async () => {
99+
const mockPosts = [
100+
{
101+
id: 1,
102+
post_released_at: '2025-03-01T00:00:00Z',
103+
daily_view_count: 30,
104+
yesterday_daily_view_count: 10,
105+
view_growth: 20
106+
},
107+
{
108+
id: 2,
109+
post_released_at: '2025-03-02T00:00:00Z',
110+
daily_view_count: 25,
111+
yesterday_daily_view_count: 15,
112+
view_growth: 10
113+
},
114+
];
115+
116+
mockPool.query.mockResolvedValue({
117+
rows: mockPosts,
118+
rowCount: mockPosts.length,
119+
command: '',
120+
oid: 0,
121+
fields: [],
122+
} as QueryResult);
123+
124+
const result = await repo.findPostsByUserIdWithGrowthMetrics(1, undefined, false);
125+
expect(result.posts).toEqual(mockPosts);
126+
expect(result.posts[0].view_growth).toBeGreaterThan(result.posts[1].view_growth);
127+
});
128+
129+
it('커서 기반 페이지네이션이 트래픽 성장률 기준으로 작동해야 한다', async () => {
130+
const mockPosts = [
131+
{
132+
id: 3,
133+
post_released_at: '2025-03-03T00:00:00Z',
134+
daily_view_count: 20,
135+
yesterday_daily_view_count: 15,
136+
view_growth: 5
137+
},
138+
];
139+
140+
mockPool.query.mockResolvedValue({
141+
rows: mockPosts,
142+
rowCount: mockPosts.length,
143+
command: '',
144+
oid: 0,
145+
fields: [],
146+
} as QueryResult);
147+
148+
const result = await repo.findPostsByUserIdWithGrowthMetrics(1, "10,2", false);
149+
expect(result.posts).toEqual(mockPosts);
150+
expect(mockPool.query).toHaveBeenCalledWith(
151+
expect.stringContaining("(COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) < $2"),
152+
expect.arrayContaining([1, "10", "2", expect.anything()])
153+
);
154+
});
155+
156+
it('에러 발생 시 DBError를 던져야 한다', async () => {
157+
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
158+
await expect(repo.findPostsByUserIdWithGrowthMetrics(1)).rejects.toThrow(DBError);
159+
});
160+
});
161+
61162
describe('getTotalPostCounts', () => {
62163
it('사용자의 총 게시글 수를 반환해야 한다', async () => {
63164
mockPool.query.mockResolvedValue({

src/repositories/post.repository.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { DBError } from '@/exception';
55
export class PostRepository {
66
constructor(private pool: Pool) { }
77

8-
async findPostsByUserId(userId: number, cursor?: string, sort?: string, isAsc?: boolean, limit: number = 15) {
8+
async findPostsByUserId(
9+
userId: number,
10+
cursor?: string,
11+
sort?: string,
12+
isAsc: boolean = false,
13+
limit: number = 15
14+
) {
915
try {
1016
// 1) 정렬 컬럼 매핑
1117
let sortCol = 'p.released_at';
@@ -62,10 +68,7 @@ export class PostRepository {
6268
pds.date
6369
FROM posts_post p
6470
LEFT JOIN (
65-
SELECT post_id,
66-
daily_view_count,
67-
daily_like_count,
68-
date
71+
SELECT post_id, daily_view_count, daily_like_count, date
6972
FROM posts_postdailystatistics
7073
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date
7174
) pds ON p.id = pds.post_id
@@ -116,6 +119,98 @@ export class PostRepository {
116119
}
117120
}
118121

122+
// findPostsByUserId 와 동일
123+
// view_growth, like_growth 컬럼 추가 연산
124+
async findPostsByUserIdWithGrowthMetrics(
125+
userId: number,
126+
cursor?: string,
127+
isAsc: boolean = false,
128+
limit: number = 15
129+
) {
130+
try {
131+
const selectFields = `
132+
p.id,
133+
p.title,
134+
p.slug,
135+
p.created_at AS post_created_at,
136+
p.released_at AS post_released_at,
137+
COALESCE(pds.daily_view_count, 0) AS daily_view_count,
138+
COALESCE(pds.daily_like_count, 0) AS daily_like_count,
139+
COALESCE(yesterday_stats.daily_view_count, 0) AS yesterday_daily_view_count,
140+
COALESCE(yesterday_stats.daily_like_count, 0) AS yesterday_daily_like_count,
141+
pds.date,
142+
(COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) AS view_growth,
143+
(COALESCE(pds.daily_like_count, 0) - COALESCE(yesterday_stats.daily_like_count, 0)) AS like_growth
144+
`;
145+
146+
const direction = isAsc ? 'ASC' : 'DESC';
147+
const orderByExpression = `view_growth ${direction}, p.id ${direction}`;
148+
149+
// 커서 처리
150+
let cursorCondition = '';
151+
let params: unknown[] = [];
152+
153+
if (cursor) {
154+
const [cursorSortValue, cursorId] = cursor.split(',');
155+
156+
cursorCondition = `
157+
AND (
158+
(COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) ${isAsc ? '>' : '<'} $2
159+
OR (
160+
(COALESCE(pds.daily_view_count, 0) - COALESCE(yesterday_stats.daily_view_count, 0)) = $2
161+
AND p.id ${isAsc ? '>' : '<'} $3
162+
)
163+
)
164+
`;
165+
166+
params = [userId, cursorSortValue, cursorId, limit];
167+
} else {
168+
params = [userId, limit];
169+
}
170+
171+
const query = `
172+
SELECT ${selectFields}
173+
FROM posts_post p
174+
LEFT JOIN (
175+
SELECT post_id, daily_view_count, daily_like_count, date
176+
FROM posts_postdailystatistics
177+
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date
178+
) pds ON p.id = pds.post_id
179+
LEFT JOIN (
180+
SELECT post_id, daily_view_count, daily_like_count, date
181+
FROM posts_postdailystatistics
182+
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date
183+
) yesterday_stats ON p.id = yesterday_stats.post_id
184+
WHERE p.user_id = $1
185+
AND (pds.post_id IS NOT NULL OR yesterday_stats.post_id IS NOT NULL)
186+
${cursorCondition}
187+
ORDER BY ${orderByExpression}
188+
LIMIT ${cursor ? '$4' : '$2'}
189+
`;
190+
191+
const posts = await this.pool.query(query, params);
192+
193+
if (posts.rows.length === 0) {
194+
return {
195+
posts: [],
196+
nextCursor: null,
197+
};
198+
}
199+
200+
// 다음 커서 생성
201+
const lastPost = posts.rows[posts.rows.length - 1];
202+
const nextCursor = `${lastPost.view_growth},${lastPost.id}`;
203+
204+
return {
205+
posts: posts.rows,
206+
nextCursor,
207+
};
208+
} catch (error) {
209+
logger.error('Post Repo findPostsByUserIdWithGrowthMetrics error: ', error);
210+
throw new DBError('트래픽 성장률 기준 post 조회 중 문제가 발생했습니다.');
211+
}
212+
}
213+
119214
async getTotalPostCounts(id: number) {
120215
try {
121216
const query = 'SELECT COUNT(*) FROM "posts_post" WHERE user_id = $1';

src/services/post.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ export class PostService {
77

88
async getAllposts(userId: number, cursor?: string, sort: string = '', isAsc?: boolean, limit: number = 15) {
99
try {
10-
const result = await this.postRepo.findPostsByUserId(userId, cursor, sort, isAsc, limit);
10+
let result = null;
11+
if (sort === "viewGrowth") {
12+
result = await this.postRepo.findPostsByUserIdWithGrowthMetrics(userId, cursor, isAsc, limit);
13+
}
14+
else {
15+
result = await this.postRepo.findPostsByUserId(userId, cursor, sort, isAsc, limit);
16+
}
1117

1218
const transformedPosts = result.posts.map((post) => ({
1319
id: post.id,

src/types/dto/requests/getAllPostsQuery.type.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator';
77
* schemas:
88
* PostSortType:
99
* type: string
10-
* enum: ['', 'dailyViewCount', 'dailyLikeCount']
10+
* enum: ['', 'dailyViewCount', 'dailyLikeCount', 'viewGrowth']
1111
* description: |
1212
* 포스트 정렬 기준
1313
* * '' - 작성일
1414
* * 'dailyViewCount' - 조회수
1515
* * 'dailyLikeCount' - 좋아요수
16+
* * 'viewGrowth' - 조회수 증가량
1617
* default: ''
1718
*/
18-
export type PostSortType = '' | 'dailyViewCount' | 'dailyLikeCount';
19+
export type PostSortType = '' | 'dailyViewCount' | 'dailyLikeCount' | 'viewGrowth';
1920

2021
export interface GetAllPostsQuery {
2122
cursor?: string;

0 commit comments

Comments
 (0)