Skip to content

[25.03.20 / TASK-132] Feature - view growth order #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 21, 2025
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
81 changes: 81 additions & 0 deletions src/repositories/__test__/post.repo.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ν…ŒμŠ€νŠΈ
Expand Down
101 changes: 101 additions & 0 deletions src/repositories/__test__/post.repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
105 changes: 100 additions & 5 deletions src/repositories/post.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
8 changes: 7 additions & 1 deletion src/services/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/types/dto/requests/getAllPostsQuery.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down