diff --git a/src/controllers/post.controller.ts b/src/controllers/post.controller.ts index a92de32..8d69280 100644 --- a/src/controllers/post.controller.ts +++ b/src/controllers/post.controller.ts @@ -11,7 +11,7 @@ import { } from '@/types'; export class PostController { - constructor(private postService: PostService) {} + constructor(private postService: PostService) { } getAllPosts: RequestHandler = async ( req: Request, @@ -70,10 +70,9 @@ export class PostController { ) => { try { const postId = Number(req.params.postId); - const { start, end } = req.query; + const { start, end } = req.query; const post = await this.postService.getPostByPostId(postId, start, end); - const response = new PostResponseDto(true, '단건 post 조회에 성공하였습니다.', { post }, null); res.status(200).json(response); diff --git a/src/modules/slack/slack.notifier.ts b/src/modules/slack/slack.notifier.ts index c7b0332..f1601e4 100644 --- a/src/modules/slack/slack.notifier.ts +++ b/src/modules/slack/slack.notifier.ts @@ -8,6 +8,8 @@ dotenv.config(); if (!process.env.SLACK_WEBHOOK_URL) { throw new Error('SLACK_WEBHOOK_URL is not defined in environment variables.'); } + +// eslint-disable-next-line @typescript-eslint/naming-convention const SLACK_WEBHOOK_URL: string = process.env.SLACK_WEBHOOK_URL; interface SlackPayload { @@ -20,8 +22,7 @@ interface SlackPayload { */ export async function sendSlackMessage(message: string): Promise { const payload: SlackPayload = { text: message }; - const response = await axios.post(SLACK_WEBHOOK_URL, payload, { + await axios.post(SLACK_WEBHOOK_URL, payload, { headers: { 'Content-Type': 'application/json' }, }); - console.log(response); } \ No newline at end of file diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/leaderboard.repo.integration.test.ts index 22aa82b..50e9b7d 100644 --- a/src/repositories/__test__/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/leaderboard.repo.integration.test.ts @@ -1,3 +1,9 @@ +/** + * 주의: 이 통합 테스트는 현재 시간에 의존적입니다. + * getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로 + * 날짜 문자열을 생성하므로, 테스트 실행 시간에 따라 결과가 달라질 수 있습니다. + */ + import logger from '@/configs/logger.config'; import dotenv from 'dotenv'; import pg from 'pg'; diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 3e41a37..35fc4f9 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -82,8 +82,8 @@ describe('LeaderboardRepository', () => { await repo.getUserLeaderboard('viewCount', 30, mockLimit); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('LIMIT $2'), - expect.arrayContaining([30, mockLimit]), + expect.stringContaining('LIMIT $1'), + expect.arrayContaining([mockLimit]), ); }); @@ -93,8 +93,8 @@ describe('LeaderboardRepository', () => { await repo.getUserLeaderboard('viewCount', mockDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('make_interval(days := $1::int)'), - expect.arrayContaining([mockDateRange, expect.anything()]), + expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인 + [expect.any(Number)] // limit ); }); @@ -157,8 +157,8 @@ describe('LeaderboardRepository', () => { await repo.getPostLeaderboard('viewCount', 30, mockLimit); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('LIMIT $2'), - expect.arrayContaining([30, mockLimit]), + expect.stringContaining('LIMIT $1'), + expect.arrayContaining([mockLimit]), ); }); @@ -168,8 +168,8 @@ describe('LeaderboardRepository', () => { await repo.getPostLeaderboard('viewCount', mockDateRange, 10); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('make_interval(days := $1::int)'), - expect.arrayContaining([mockDateRange, expect.anything()]), + expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인 + [expect.any(Number)] // limit ); }); diff --git a/src/repositories/__test__/post.repo.integration.test.ts b/src/repositories/__test__/post.repo.integration.test.ts index 4aaf767..3c643ce 100644 --- a/src/repositories/__test__/post.repo.integration.test.ts +++ b/src/repositories/__test__/post.repo.integration.test.ts @@ -3,6 +3,7 @@ import { Pool } from 'pg'; import pg from 'pg'; import { PostRepository } from '../post.repository'; import logger from '@/configs/logger.config'; +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; dotenv.config(); @@ -407,7 +408,9 @@ describe('PostRepository 통합 테스트', () => { */ describe('findPostByPostId', () => { it('게시물 ID로 통계 데이터를 조회할 수 있어야 한다', async () => { - const result = await repo.findPostByPostId(TEST_DATA.POST_ID); + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); + const result = await repo.findPostByPostId(TEST_DATA.POST_ID, sevenDayAgoKST, endKST); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); @@ -437,7 +440,9 @@ describe('PostRepository 통합 테스트', () => { }); it('날짜 오름차순으로 정렬된 결과를 반환해야 한다', async () => { - const result = await repo.findPostByPostId(TEST_DATA.POST_ID); + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); + const result = await repo.findPostByPostId(TEST_DATA.POST_ID, sevenDayAgoKST, endKST); // 2개 이상의 결과가 있는 경우에만 정렬 검증 if (result.length >= 2) { @@ -459,8 +464,10 @@ describe('PostRepository 통합 테스트', () => { }); it('존재하지 않는 게시물 ID에 대해 빈 배열을 반환해야 한다', async () => { + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); const nonExistentPostId = 9999999; - const result = await repo.findPostByPostId(nonExistentPostId); + const result = await repo.findPostByPostId(nonExistentPostId, sevenDayAgoKST, endKST); expect(result).toBeDefined(); expect(Array.isArray(result)).toBe(true); @@ -468,7 +475,9 @@ describe('PostRepository 통합 테스트', () => { }); it('날짜 형식이 올바르게 변환되어야 한다', async () => { - const result = await repo.findPostByPostId(TEST_DATA.POST_ID); + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); + const result = await repo.findPostByPostId(TEST_DATA.POST_ID, sevenDayAgoKST, endKST); if (result.length <= 0) { logger.info('존재하지 않는 게시물 ID에 대해 빈 배열을 테스트를 위한 충분한 데이터가 없습니다.'); @@ -490,7 +499,9 @@ describe('PostRepository 통합 테스트', () => { }); it('일일 조회수와 좋아요 수가 숫자 타입이어야 한다', async () => { - const result = await repo.findPostByPostId(TEST_DATA.POST_ID); + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); + const result = await repo.findPostByPostId(TEST_DATA.POST_ID, sevenDayAgoKST, endKST); if (result.length <= 0) { logger.info('일일 조회수와 좋아요 수가 숫자 타입인지 테스트를 위한 충분한 데이터가 없습니다.'); diff --git a/src/repositories/__test__/post.repo.test.ts b/src/repositories/__test__/post.repo.test.ts index 180f4c9..5d13ead 100644 --- a/src/repositories/__test__/post.repo.test.ts +++ b/src/repositories/__test__/post.repo.test.ts @@ -240,7 +240,7 @@ describe('PostRepository', () => { mockPool.query.mockResolvedValue(createMockQueryResult(mockStats)); - const result = await repo.findPostByPostId(1); + const result = await repo.findPostByPostId(1, '2025-05-01', '2025-05-08'); expect(result).toEqual(mockStats); }); }); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index c978825..210f521 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -2,13 +2,15 @@ import logger from '@/configs/logger.config'; import { Pool } from 'pg'; import { DBError } from '@/exception'; import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index'; +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class LeaderboardRepository { constructor(private pool: Pool) {} async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) { try { - const cteQuery = this.buildLeaderboardCteQuery(); + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + const cteQuery = this.buildLeaderboardCteQuery(dateRange); const query = ` ${cteQuery} @@ -20,7 +22,7 @@ export class LeaderboardRepository { 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 + 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 @@ -28,9 +30,9 @@ export class LeaderboardRepository { WHERE u.email IS NOT NULL GROUP BY u.id, u.email ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id - LIMIT $2; + LIMIT $1; `; - const result = await this.pool.query(query, [dateRange, limit]); + const result = await this.pool.query(query, [limit]); return result.rows; } catch (error) { @@ -41,7 +43,7 @@ export class LeaderboardRepository { async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { try { - const cteQuery = this.buildLeaderboardCteQuery(); + const cteQuery = this.buildLeaderboardCteQuery(dateRange); const query = ` ${cteQuery} @@ -59,9 +61,9 @@ export class LeaderboardRepository { LEFT JOIN start_stats ss ON ss.post_id = p.id WHERE p.is_active = true ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id - LIMIT $2; + LIMIT $1; `; - const result = await this.pool.query(query, [dateRange, limit]); + const result = await this.pool.query(query, [limit]); return result.rows; } catch (error) { @@ -71,7 +73,11 @@ export class LeaderboardRepository { } // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 - private buildLeaderboardCteQuery() { + private buildLeaderboardCteQuery(dateRange: number) { + const nowDateKST = getCurrentKSTDateString(); + // 과거 날짜 계산 (dateRange일 전) + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + return ` WITH today_stats AS ( @@ -80,7 +86,7 @@ export class LeaderboardRepository { daily_view_count AS today_view, daily_like_count AS today_like FROM posts_postdailystatistics - WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= (NOW() AT TIME ZONE 'UTC')::date + WHERE date <= '${nowDateKST}' ORDER BY post_id, date DESC ), start_stats AS ( @@ -89,7 +95,7 @@ export class LeaderboardRepository { daily_view_count AS start_view, daily_like_count AS start_like FROM posts_postdailystatistics - WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ((NOW() AT TIME ZONE 'UTC')::date - make_interval(days := $1::int)) + WHERE date >= '${pastDateKST}' ORDER BY post_id, date ASC ) `; diff --git a/src/repositories/post.repository.ts b/src/repositories/post.repository.ts index 2273766..2e02504 100644 --- a/src/repositories/post.repository.ts +++ b/src/repositories/post.repository.ts @@ -1,6 +1,7 @@ import { Pool } from 'pg'; import logger from '@/configs/logger.config'; import { DBError } from '@/exception'; +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class PostRepository { constructor(private pool: Pool) { } @@ -12,6 +13,10 @@ export class PostRepository { isAsc: boolean = false, limit: number = 15 ) { + const nowDateKST = getCurrentKSTDateString(); + const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60); + const yesterDateKST = getKSTDateStringWithOffset(-24 * 60); + try { // 1) 정렬 컬럼 매핑 let sortCol = 'p.released_at'; @@ -70,12 +75,12 @@ export class PostRepository { 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 + WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}' ) 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 + WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}' ) yesterday_stats ON p.id = yesterday_stats.post_id WHERE p.user_id = $1 AND p.is_active = TRUE @@ -128,6 +133,10 @@ export class PostRepository { isAsc: boolean = false, limit: number = 15 ) { + const nowDateKST = getCurrentKSTDateString(); + const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60); + const yesterDateKST = getKSTDateStringWithOffset(-24 * 60); + try { const selectFields = ` p.id, @@ -170,25 +179,25 @@ export class PostRepository { } 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 p.is_active = TRUE - AND (pds.post_id IS NOT NULL OR yesterday_stats.post_id IS NOT NULL) - ${cursorCondition} - ORDER BY ${orderByExpression} - LIMIT ${cursor ? '$4' : '$2'} - `; + SELECT ${selectFields} + FROM posts_post p + LEFT JOIN ( + SELECT post_id, daily_view_count, daily_like_count, date + FROM posts_postdailystatistics + WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}' + ) pds ON p.id = pds.post_id + LEFT JOIN ( + SELECT post_id, daily_view_count, daily_like_count, date + FROM posts_postdailystatistics + WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}' + ) yesterday_stats ON p.id = yesterday_stats.post_id + WHERE p.user_id = $1 + AND p.is_active = TRUE + 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); @@ -225,7 +234,10 @@ export class PostRepository { } async getYesterdayAndTodayViewLikeStats(userId: number) { - // ! pds.updated_at 은 FE 화면을 위해 억지로 9h 시간 더한 값임 주의 + const nowDateKST = getCurrentKSTDateString(); + const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60); + const yesterDateKST = getKSTDateStringWithOffset(-24 * 60); + try { const query = ` SELECT @@ -233,17 +245,17 @@ export class PostRepository { COALESCE(SUM(pds.daily_like_count), 0) AS daily_like_count, COALESCE(SUM(yesterday_stats.daily_view_count), 0) AS yesterday_views, COALESCE(SUM(yesterday_stats.daily_like_count), 0) AS yesterday_likes, - (MAX(pds.updated_at AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC') AS last_updated_date + MAX(pds.updated_at) AS last_updated_date FROM posts_post p LEFT JOIN ( SELECT post_id, daily_view_count, daily_like_count, updated_at FROM posts_postdailystatistics - WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date + WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}' ) pds ON p.id = pds.post_id LEFT JOIN ( SELECT post_id, daily_view_count, daily_like_count FROM posts_postdailystatistics - WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date + WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}' ) yesterday_stats ON p.id = yesterday_stats.post_id WHERE p.user_id = $1 AND p.is_active = TRUE @@ -258,38 +270,34 @@ export class PostRepository { } } - async findPostByPostId(postId: number, start?: string, end?: string) { - try { - // 기본 쿼리 부분 - const baseQuery = ` - SELECT - (pds.date AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC' AS date, - pds.daily_view_count, - pds.daily_like_count - FROM posts_postdailystatistics pds - WHERE pds.post_id = $1 - `; - - // 날짜 필터링 조건 구성 - const dateFilterQuery = (start && end) - ? ` - AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ($2 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date - AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= ($3 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date - ` - : ''; - - // 정렬 조건 추가 - const orderByQuery = `ORDER BY pds.date ASC`; - - // 최종 쿼리 조합 - const fullQuery = [baseQuery, dateFilterQuery, orderByQuery].join(' '); - - // 파라미터 배열 구성 - const queryParams: Array = [postId]; - if (start && end) queryParams.push(start, end); + /** + * 특정 게시물의 주어진 날짜 범위 내 일별 통계를 조회합니다. + * + * @param postId - 통계를 조회할 게시물의 ID. + * @param start - 조회 범위의 시작 날짜(포함), 'YYYY-MM-DD' 형식. + * @param end - 조회 범위의 종료 날짜(포함), 'YYYY-MM-DD' 형식. + * @returns 주어진 날짜 범위 내 일별 통계 배열을 반환하는 Promise: + * - `date`: 통계 날짜. + * - `daily_view_count`: 해당 날짜의 조회수. + * - `daily_like_count`: 해당 날짜의 좋아요 수. + * @throws {DBError} 데이터베이스 조회 중 오류가 발생한 경우. + */ + async findPostByPostId(postId: number, start: string, end: string) { + const query = ` + SELECT + pds.date, + pds.daily_view_count, + pds.daily_like_count + FROM posts_postdailystatistics pds + WHERE pds.post_id = $1 + AND pds.date >= $2 + AND pds.date <= $3 + ORDER BY pds.date ASC + `; - // 쿼리 실행 - const result = await this.pool.query(fullQuery, queryParams); + try { + const values: Array = [postId, start, end]; + const result = await this.pool.query(query, values); return result.rows; } catch (error) { logger.error('Post Repo findPostByPostId error:', error); @@ -297,24 +305,36 @@ export class PostRepository { } } + /** + * 특정 게시물의 uuid 값 기반으로 날짜 범위 내 일별 통계를 조회합니다. 익스텐션에서 사용합니다. + * + * @param postUUUID - 통계를 조회할 게시물의 UUID. + * @param start - 조회 범위의 시작 날짜(포함), 'YYYY-MM-DD' 형식. + * @param end - 조회 범위의 종료 날짜(포함), 'YYYY-MM-DD' 형식. + * @returns 주어진 날짜 범위 내 일별 통계 배열을 반환하는 Promise: + * - `date`: 통계 날짜. + * - `daily_view_count`: 해당 날짜의 조회수. + * - `daily_like_count`: 해당 날짜의 좋아요 수. + * @throws {DBError} 데이터베이스 조회 중 오류가 발생한 경우. + */ async findPostByPostUUID(postUUUID: string, start: string, end: string) { - try { - const query = ` + // findPostByPostId 와 다르게 UUID 가 기준이라 join 필수 + const query = ` SELECT - (pds.date AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC' AS date, + pds.date, pds.daily_view_count, pds.daily_like_count FROM posts_post p JOIN posts_postdailystatistics pds ON p.id = pds.post_id WHERE p.post_uuid = $1 AND p.is_active = TRUE - AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ($2 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date - AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= ($3 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date + AND pds.date >= $2 + AND pds.date <= $3 ORDER BY pds.date ASC - `; + `; + try { const values = [postUUUID, start, end]; - const result = await this.pool.query(query, values); return result.rows; } catch (error) { diff --git a/src/routes/post.router.ts b/src/routes/post.router.ts index e37ed14..39a0e67 100644 --- a/src/routes/post.router.ts +++ b/src/routes/post.router.ts @@ -6,7 +6,7 @@ import { PostRepository } from '@/repositories/post.repository'; import { PostService } from '@/services/post.service'; import { PostController } from '@/controllers/post.controller'; import { validateRequestDto } from '@/middlewares/validation.middleware'; -import { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; +import { GetAllPostsQueryDto, GetPostQueryDto } from '@/types'; const router: Router = express.Router(); dotenv.config(); @@ -133,6 +133,11 @@ router.get('/post/velog/:postId', authMiddleware.verify, postController.getPostB * '500': * description: 서버 오류 / 데이터 베이스 조회 오류 */ -router.get('/post/:postId', authMiddleware.verify, postController.getPostByPostId); +router.get( + '/post/:postId', + authMiddleware.verify, + validateRequestDto(GetPostQueryDto, 'query'), + postController.getPostByPostId +); export default router; diff --git a/src/services/__test__/post.service.test.ts b/src/services/__test__/post.service.test.ts index 3aa4fed..5d9d580 100644 --- a/src/services/__test__/post.service.test.ts +++ b/src/services/__test__/post.service.test.ts @@ -317,65 +317,39 @@ describe('PostService', () => { }, ]; - it('게시물 ID로 상세 통계 조회', async () => { - // Arrange - postRepo.findPostByPostId.mockResolvedValue(mockPostStats); - - // Act - const result = await postService.getPostByPostId(1); - - // Assert - expect(result).toEqual(expectedTransformedStats); - expect(postRepo.findPostByPostId).toHaveBeenCalledWith(1, undefined, undefined); - }); - it('시작일과 종료일을 지정하여 상세 통계 조회', async () => { - // Arrange const start = '2025-03-01'; const end = '2025-03-10'; postRepo.findPostByPostId.mockResolvedValue(mockPostStats); - // Act const result = await postService.getPostByPostId(1, start, end); - - // Assert expect(result).toEqual(expectedTransformedStats); - expect(postRepo.findPostByPostId).toHaveBeenCalledWith(1, start, end); + expect(postRepo.findPostByPostId).toHaveBeenCalledWith(1, `${start} 00:00:00+09`, `${end} 00:00:00+09`); }); it('빈 통계 목록 처리', async () => { - // Arrange postRepo.findPostByPostId.mockResolvedValue([]); - // Act const result = await postService.getPostByPostId(1); - - // Assert expect(result).toEqual([]); }); it('쿼리 오류 발생 시 예외를 그대로 전파', async () => { - // Arrange const errorMessage = '게시물 조회 중 문제가 발생했습니다.'; postRepo.findPostByPostId.mockRejectedValue(new DBError(errorMessage)); - // Act & Assert await expect(postService.getPostByPostId(1)).rejects.toThrow(errorMessage); }); it('숫자가 아닌 ID를 전달해도 처리되어야 함', async () => { - // Arrange postRepo.findPostByPostId.mockResolvedValue(mockPostStats); - - // Act const result = await postService.getPostByPostId('abc' as unknown as number); - - // Assert expect(result).toEqual(expectedTransformedStats); // Repository에 ID가 'abc'로 전달됨 (내부적으로 변환하지 않음) - expect(postRepo.findPostByPostId).toHaveBeenCalledWith('abc', undefined, undefined); + expect(postRepo.findPostByPostId).toHaveBeenCalledWith('abc', "undefined 00:00:00+09", "undefined 00:00:00+09"); }); + }); describe('getPostByPostUUID', () => { @@ -416,39 +390,30 @@ describe('PostService', () => { }); it('게시물 UUID로 상세 통계 조회 (기본 7일 범위)', async () => { - // Arrange postRepo.findPostByPostUUID.mockResolvedValue(mockPostStats); - // Act const result = await postService.getPostByPostUUID('uuid-1234'); - // Assert expect(result).toEqual(expectedTransformedStats); // 7일 범위 설정 확인 (현재 날짜 2025-03-15 기준) expect(postRepo.findPostByPostUUID).toHaveBeenCalledWith( 'uuid-1234', - '2025-03-09', // 6일 전 - '2025-03-15' // 오늘 - ); + '2025-03-08 00:00:00+09', // 현재 날짜 (테스트에서 고정된 날짜) + '2025-03-15 00:00:00+09' // 현재 날짜 (테스트에서 고정된 날짜) + ); }); it('빈 통계 목록 처리', async () => { - // Arrange postRepo.findPostByPostUUID.mockResolvedValue([]); - - // Act const result = await postService.getPostByPostUUID('uuid-1234'); - // Assert expect(result).toEqual([]); }); it('쿼리 오류 발생 시 예외를 그대로 전파', async () => { - // Arrange const errorMessage = 'UUID로 게시물 조회 중 문제가 발생했습니다.'; postRepo.findPostByPostUUID.mockRejectedValue(new DBError(errorMessage)); - // Act & Assert await expect(postService.getPostByPostUUID('uuid-1234')).rejects.toThrow(errorMessage); }); diff --git a/src/services/post.service.ts b/src/services/post.service.ts index d60bd1c..2e8fb91 100644 --- a/src/services/post.service.ts +++ b/src/services/post.service.ts @@ -1,6 +1,7 @@ import logger from '@/configs/logger.config'; import { PostRepository } from '@/repositories/post.repository'; import { RawPostType } from '@/types'; +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class PostService { constructor(private postRepo: PostRepository) { } @@ -61,11 +62,13 @@ export class PostService { } async getPostByPostId(postId: number, start?: string, end?: string) { - try { - const posts = await this.postRepo.findPostByPostId(postId, start, end); + // start, end 가 yyyy-mm-dd 만 넘어옴, 이를 kst 형태로 바꿔줘야 함 + const startKST = `${start} 00:00:00+09`; + const endKST = `${end} 00:00:00+09`; + try { + const posts = await this.postRepo.findPostByPostId(postId, startKST, endKST); const transformedPosts = this.transformPosts(posts); - return transformedPosts; } catch (error) { logger.error('PostService getPost error : ', error); @@ -74,18 +77,13 @@ export class PostService { } async getPostByPostUUID(postUUUID: string) { - try { - const seoulNow = new Date(new Date().getTime() + 9 * 60 * 60 * 1000); - const sevenDaysAgo = new Date(seoulNow); - - const end = seoulNow.toISOString().split('T')[0]; - sevenDaysAgo.setDate(seoulNow.getDate() - 6); - const start = sevenDaysAgo.toISOString().split('T')[0]; - - const posts = await this.postRepo.findPostByPostUUID(postUUUID, start, end); + // 날짜가 넘어오지 않기에 기본적으로 7일로 세팅 + const sevenDayAgoKST = getKSTDateStringWithOffset(-24 * 60 * 7); + const endKST = getCurrentKSTDateString(); // now + try { + const posts = await this.postRepo.findPostByPostUUID(postUUUID, sevenDayAgoKST, endKST); const transformedPosts = this.transformPosts(posts); - return transformedPosts; } catch (error) { logger.error('PostService getPostByPostUUID error : ', error); diff --git a/src/types/dto/requests/getPostQuery.type.ts b/src/types/dto/requests/getPostQuery.type.ts index 778ed48..aff0e21 100644 --- a/src/types/dto/requests/getPostQuery.type.ts +++ b/src/types/dto/requests/getPostQuery.type.ts @@ -1,5 +1,4 @@ -import { Type } from 'class-transformer'; -import { IsDate, IsOptional } from 'class-validator'; +import { IsNotEmpty, IsString, Validate } from 'class-validator'; export interface PostParam extends Record { postId: string; @@ -21,26 +20,36 @@ export interface GetPostQuery { * type: string * format: date * description: 조회 시작 날짜 - * nullable: true + * nullable: false * end: * type: string * format: date * description: 조회 종료 날짜 - * nullable: true + * nullable: false */ export class GetPostQueryDto { - @IsOptional() - @IsDate() - @Type(() => Date) - start?: string; + @IsString() + @IsNotEmpty() + @Validate((value: string) => { + const date = new Date(value); + return !isNaN(date.getTime()); + }, { + message: '유효한 날짜 형식이 아닙니다. (예: YYYY-MM-DD)' + }) + start: string; - @IsOptional() - @IsDate() - @Type(() => Date) - end?: string; + @IsString() + @IsNotEmpty() + @Validate((value: string) => { + const date = new Date(value); + return !isNaN(date.getTime()); + }, { + message: '유효한 날짜 형식이 아닙니다. (예: YYYY-MM-DD)' + }) + end: string; constructor(start: string, end: string) { this.start = start; this.end = end; } -} +} \ No newline at end of file diff --git a/src/utils/__test__/date.util.test.ts b/src/utils/__test__/date.util.test.ts new file mode 100644 index 0000000..e3e0fd9 --- /dev/null +++ b/src/utils/__test__/date.util.test.ts @@ -0,0 +1,131 @@ +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '../date.util'; + +describe('Date Utilities', () => { + // 원본 Date 객체와 Date.now 함수 저장 + let originalDate: DateConstructor; + let originalDateNow: () => number; + + beforeAll(() => { + originalDate = global.Date; + originalDateNow = Date.now; + }); + + afterAll(() => { + // 테스트 종료 후 원래 객체로 복원 + global.Date = originalDate; + Date.now = originalDateNow; + }); + + afterEach(() => { + // 각 테스트 후 모킹 제거 및 원래 객체로 복원 + jest.restoreAllMocks(); + global.Date = originalDate; + Date.now = originalDateNow; + }); + + /** + * Date 객체를 KST 포맷 문자열로 변환하는 헬퍼 함수 + * @param date 변환할 Date 객체 + * @returns KST 포맷의 문자열 (YYYY-MM-DD HH:MM:SS+09) + */ + const formatKST = (date: Date): string => { + const kst = new Date(date.getTime() + 9 * 60 * 60 * 1000); + const year = kst.getUTCFullYear(); + const month = String(kst.getUTCMonth() + 1).padStart(2, '0'); + const day = String(kst.getUTCDate()).padStart(2, '0'); + // 시간을 00:00:00으로 고정 + return `${year}-${month}-${day} 00:00:00+09`; + }; + + + it('getCurrentKSTDateString이 KST 포맷의 문자열을 반환해야 한다', () => { + // 형식 검증 - HH:MM:SS가 항상 00:00:00 + const result = getCurrentKSTDateString(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} 00:00:00\+09$/); + + // 현재 날짜 기준 내용 검증 + const now = new Date(); + const expected = formatKST(now); + + // 전체 문자열을 비교 (시간은 항상 00:00:00) + expect(result).toBe(expected); + }); + + it('getKSTDateStringWithOffset이 KST 포맷의 문자열을 반환해야 한다', () => { + const result = getKSTDateStringWithOffset(30); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\+09$/); + }); + + it('getKSTDateStringWithOffset(0)은 getCurrentKSTDateString과 동일한 값을 반환해야 한다', () => { + // 시간을 고정하여 두 함수 호출 사이에 실제 시간이 변경되지 않도록 함 + const fixedDate = new Date(); + const fixed = fixedDate.getTime(); + Date.now = jest.fn(() => fixed); + jest.spyOn(global, 'Date').mockImplementation(() => fixedDate); + + const current = getCurrentKSTDateString(); + const offsetZero = getKSTDateStringWithOffset(0); + + expect(current).toBe(offsetZero); + }); + + it('getCurrentKSTDateString은 날짜가 변경될 때만 다른 값을 반환해야 한다', () => { + // 고정된 시간 설정 + const fixedTime = new originalDate(Date.UTC(2025, 4, 10, 6, 30, 0)); // 2025-05-10 06:30:00 UTC + + // 같은 날 5분 후 + const sameDay = new originalDate(fixedTime.getTime() + 5 * 60 * 1000); + + // 다음 날 (날짜가 변경됨) + const nextDay = new originalDate(fixedTime.getTime() + 24 * 60 * 60 * 1000); + + let callCount = 0; + + // Date 클래스 모킹 + class MockDate extends originalDate { + constructor(...args: ConstructorParameters) { + if (args.length > 0) { + super(...args); + } else { + // 호출 순서에 따라 다른 시간 반환 + if (callCount === 0) { + super(fixedTime.getTime()); + } else if (callCount === 1) { + super(sameDay.getTime()); + } else { + super(nextDay.getTime()); + } + callCount++; + } + } + } + + global.Date = MockDate as unknown as DateConstructor; + + const first = getCurrentKSTDateString(); + const second = getCurrentKSTDateString(); // 같은 날 + const third = getCurrentKSTDateString(); // 다음 날 + + expect(first).toBe(second); // 같은 날이므로 동일해야 함 + expect(first).not.toBe(third); // 다른 날이므로 달라야 함 + }); + + it('getKSTDateStringWithOffset(1440)은 정확히 하루 후의 날짜를 반환해야 한다', () => { + // 기준 시간과 하루 후 시간 설정 + const baseTime = new Date(); + const nextDay = new Date(baseTime.getTime() + 24 * 60 * 60 * 1000); + + // Date 생성자 모킹 + let callCount = 0; + jest.spyOn(global, 'Date').mockImplementation(function (this: Date, time?: number | string | Date): Date { + if (time !== undefined) return new originalDate(time); + // 첫 호출과 두 번째 호출에서 다른 시간 반환 + return callCount++ === 0 ? baseTime : nextDay; + } as unknown as (time?: number | string | Date) => Date); + + const result = getKSTDateStringWithOffset(1440); // 1440분 = 24시간 = 1일 + const expected = formatKST(nextDay); + + expect(result).toBe(expected); + }); +}); \ No newline at end of file diff --git a/src/utils/date.util.ts b/src/utils/date.util.ts new file mode 100644 index 0000000..75b5345 --- /dev/null +++ b/src/utils/date.util.ts @@ -0,0 +1,57 @@ +/** + * 현재 날짜의 시작 시간(00:00:00)을 한국 표준시(KST, UTC+9)의 포맷팅된 문자열로 반환합니다. + * + * @returns {string} 'YYYY-MM-DD 00:00:00+09' 형식의 한국 시간 문자열 + * @example + * // 현재 시간이 2025-05-10 15:30:25 KST일 경우 + * // 반환 예시: '2025-05-10 00:00:00+09' + * const todayStartKST = getCurrentKSTDateString(); + */ +export function getCurrentKSTDateString(): string { + const now = new Date(); + // KST = UTC + 9시간 + const kstDate = new Date(now.getTime() + 9 * 60 * 60 * 1000); + + const year = kstDate.getUTCFullYear(); + const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(kstDate.getUTCDate()).padStart(2, '0'); + + // 시간은 항상 00:00:00으로 고정 + return `${year}-${month}-${day} 00:00:00+09`; +} + +/** + * 현재 시간으로부터 지정된 분(minutes) 후의 날짜에 대한 시작 시간(00:00:00)을 + * 한국 표준시(KST, UTC+9)로 반환합니다. + * + * @param {number} minutes - 현재 시간에 더할 분(minutes) + * @returns {string} 'YYYY-MM-DD 00:00:00+09' 형식의 지정된 날짜의 시작 시간 문자열 + * @example + * // 현재 시간이 2025-05-10 15:30:25 KST일 경우 + * + * // 5분 후 날짜의 시작 시간 (같은 날이므로 동일) + * // 반환 예시: '2025-05-10 00:00:00+09' + * const sameDay = getKSTDateStringWithOffset(5); + * + * // 하루 후(1440분)의 날짜 시작 시간 + * // 반환 예시: '2025-05-11 00:00:00+09' + * const nextDay = getKSTDateStringWithOffset(1440); + * + * // 하루 전(-1440분)의 날짜 시작 시간 + * // 반환 예시: '2025-05-09 00:00:00+09' + * const previousDay = getKSTDateStringWithOffset(-1440); + */ +export function getKSTDateStringWithOffset(minutes: number): string { + const now = new Date(); + // 현재 시간에 분을 추가 + const futureTime = new Date(now.getTime() + minutes * 60 * 1000); + // KST = UTC + 9시간 + const kstDate = new Date(futureTime.getTime() + 9 * 60 * 60 * 1000); + + const year = kstDate.getUTCFullYear(); + const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(kstDate.getUTCDate()).padStart(2, '0'); + + // 시간은 항상 00:00:00으로 고정 + return `${year}-${month}-${day} 00:00:00+09`; +} \ No newline at end of file