diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts new file mode 100644 index 0000000..cec602a --- /dev/null +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -0,0 +1,311 @@ +import 'reflect-metadata'; +import { Request, Response } from 'express'; +import { WebhookController } from '@/controllers/webhook.controller'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; + +// Mock dependencies +jest.mock('@/modules/slack/slack.notifier'); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('WebhookController', () => { + let webhookController: WebhookController; + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + let mockSendSlackMessage: jest.MockedFunction; + + beforeEach(() => { + // WebhookController 인스턴스 생성 + webhookController = new WebhookController(); + + // Request, Response, NextFunction 모킹 + mockRequest = { + body: {}, + headers: {}, + }; + + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + nextFunction = jest.fn(); + mockSendSlackMessage = sendSlackMessage as jest.MockedFunction; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleSentryWebhook', () => { + // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용 + const mockSentryData = { + action: 'created', + data: { + issue: { + id: 'test-issue-123', + title: '테스트 오류입니다', + culprit: 'TestFile.js:10', + status: 'unresolved', + count: "5", + userCount: 3, + firstSeen: '2024-01-01T12:00:00.000Z', + permalink: 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/', + project: { + id: 'project-123', + name: 'Velog Dashboard', + slug: 'velog-dashboard' + } + } + } + }; + + it('유효한 Sentry 웹훅 데이터로 처리에 성공해야 한다', async () => { + mockRequest.body = mockSentryData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *새로운 오류가 발생하였습니다*') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔴 *제목:* 테스트 오류입니다') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('📍 *위치:* TestFile.js:10') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/') + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 성공하였습니다.', + data: {}, + error: null + }); + }); + + it('permalink가 없는 경우 기본 URL 패턴을 사용해야 한다', async () => { + const dataWithoutPermalink = { + ...mockSentryData, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + permalink: undefined + } + } + }; + mockRequest.body = dataWithoutPermalink; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/') + ); + }); + + it('Slack 메시지 전송 실패 시 에러를 전달해야 한다', async () => { + mockRequest.body = mockSentryData; + const slackError = new Error('Slack 전송 실패'); + mockSendSlackMessage.mockRejectedValue(slackError); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith(slackError); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + // Invalid Body 케이스 테스트들 + it('action이 created가 아닌 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { action: 'resolved' }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('빈 body인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = {}; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('action이 없는 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { data: { issue: {} } }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { + username: 'test', + password: '123456', + email: 'test@example.com' + }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다', async () => { + mockRequest.body = { + action: 'created', + data: { + issue: { + // 필수 필드들이 누락됨 + } + } + }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 데이터가 올바르지 않습니다' + }) + ); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('action은 created이지만 data가 없는 경우 에러를 전달해야 한다', async () => { + mockRequest.body = { action: 'created' }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('잘못된 타입의 body인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = 'invalid string body'; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); + }); + + describe('formatSentryMessage (private method integration test)', () => { + it('완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다', async () => { + // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용 + const completeData = { + action: 'created', + data: { + issue: { + id: 'issue-456', + title: 'TypeError: Cannot read property of undefined', + culprit: 'components/UserProfile.tsx:25', + status: 'unresolved', + count: "12", + userCount: 8, + firstSeen: '2024-01-15T14:30:00.000Z', + permalink: 'https://velog-dashboardv2.sentry.io/issues/issue-456/', + project: { + id: 'proj-789', + name: 'Velog Dashboard V2', + slug: 'velog-dashboard-v2' + } + } + } + }; + + mockRequest.body = completeData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + const expectedMessage = `🚨 *새로운 오류가 발생하였습니다* + +🔴 *제목:* TypeError: Cannot read property of undefined + +📍 *위치:* components/UserProfile.tsx:25 + +🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/issue-456/`; + + expect(mockSendSlackMessage).toHaveBeenCalledWith(expectedMessage); + }); + }); +}); \ No newline at end of file diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts new file mode 100644 index 0000000..e76693f --- /dev/null +++ b/src/controllers/webhook.controller.ts @@ -0,0 +1,57 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { EmptyResponseDto, SentryWebhookData } from '@/types'; +import logger from '@/configs/logger.config'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; + +export class WebhookController { + private readonly STATUS_EMOJI = { + 'unresolved': '🔴', + 'resolved': '✅', + 'ignored': '🔇', + } as const; + + handleSentryWebhook: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + + if (req.body?.action !== "created") { + const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); + res.status(400).json(response); + return; + } + + const sentryData: SentryWebhookData = req.body; + + const slackMessage = this.formatSentryMessage(sentryData); + await sendSlackMessage(slackMessage); + + const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null); + res.status(200).json(response); + } catch (error) { + logger.error('Sentry 웹훅 처리 실패:', error); + next(error); + } + }; + + private formatSentryMessage(sentryData: SentryWebhookData): string { + const { data: { issue } } = sentryData; + + if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다'); + + const { status, title: issueTitle, culprit, permalink, id } = issue; + const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI]; + + // URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성 + const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${id}/`; + + let message = `🚨 *새로운 오류가 발생하였습니다*\n\n`; + message += `${statusEmoji} *제목:* ${issueTitle}\n\n`; + message += `📍 *위치:* ${culprit}\n\n`; + message += `🔗 *상세 보기:* ${detailUrl}`; + + return message; + } +} \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 0406af2..3b87fac 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,7 @@ import PostRouter from './post.router'; import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; import TotalStatsRouter from './totalStats.router'; +import WebhookRouter from './webhook.router'; const router: Router = express.Router(); @@ -16,5 +17,6 @@ router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); router.use('/', TotalStatsRouter); +router.use('/', WebhookRouter); export default router; diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts new file mode 100644 index 0000000..42ec9be --- /dev/null +++ b/src/routes/webhook.router.ts @@ -0,0 +1,52 @@ +import express, { Router } from 'express'; +import { WebhookController } from '@/controllers/webhook.controller'; + +const router: Router = express.Router(); + +// 컨트롤러 인스턴스 생성 +const webhookController = new WebhookController(); + +/** + * @swagger + * /webhook/sentry: + * post: + * summary: Sentry webhook 처리 + * description: Sentry에서 전송되는 webhook 이벤트를 처리하고 Slack으로 알림을 전송합니다. + * tags: [Webhook] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * description: Sentry 액션 타입 + * enum: [created, resolved, unresolved, ignored] + * data: + * type: object + * properties: + * issue: + * type: object + * description: Sentry 이슈 정보 + * actor: + * type: object + * description: 액션을 수행한 사용자 정보 + * responses: + * 200: + * description: Webhook 처리 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Webhook 처리 완료" + * 500: + * description: 서버 오류 + */ +router.post('/webhook/sentry', webhookController.handleSentryWebhook); + +export default router; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 7a247d8..ba888fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,5 +38,15 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; +// Sentry 관련 +export type { + SentryIssueStatus, +} from '@/types/models/Sentry.type'; +export type { + SentryProject, + SentryIssue, + SentryWebhookData, +} from '@/types/models/Sentry.type'; + // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts new file mode 100644 index 0000000..04ef4ff --- /dev/null +++ b/src/types/models/Sentry.type.ts @@ -0,0 +1,80 @@ +// 모든 상세 타입의 정보는 해당 문서 페이지에서 확인하실 수 있습니다. +// Sentry AI 왈 내용이 문서와 실제 전송되는 값들이 조금씩 다를 수 있다고 하는데, 전체적인 구조와 각 값의 타입은 동일하다고 하네요 +// 참고: https://docs.sentry.io/organization/integrations/integration-platform/webhooks/issues/#statusdetails + +export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; +export type SentryIssueSubStatus = "archived_until_escalating" | "archived_until_condition_met" | "archived_forever" | "escalating" | "ongoing" | "regressed" | "new" + +export interface SentryProject { + id: string; + name: string; + slug: string; + platform?: string; +} + +export interface SentryMetadata { + filename: string; + type: string; + value: string; +} + +export interface SentryIssueStatusDetails { + inRelease: string; + inNextRelease: boolean; + inCommit: string; + ignoreCount: number; + ignoreWindow: number; +} + +export interface SentryIssue { + url?: string; + web_url?: string; + project_url?: string; + id: string; + shareId?: string | null; + shortId: string; + title: string; + culprit: string; + permalink?: string | null; + logger?: string | null; + level: string; + status: SentryIssueStatus; + statusDetails?: SentryIssueStatusDetails; + substatus: SentryIssueSubStatus; + isPublic: boolean; + platform: string; + project: SentryProject; + type: string; + metadata?: SentryMetadata; + numComments: number; + assignedTo?: string | null; + isBookmarked: boolean; + isSubscribed: boolean; + subscriptionDetails?: string | null; + hasSeen: boolean; + annotations: []; + issueType: string; + issueCategory: string; + priority: string; + priorityLockedAt?: string | null; + seerFixabilityScore?: string | null; + seerAutofixLastTriggered?: string | null; + isUnhandled: boolean; + count: string; + userCount: number; + firstSeen: string; + lastSeen: string; +} + +export interface SentryWebhookData { + action: 'created'; + installation: { uuid: string }; + data: { + issue: SentryIssue; + }; + actor: { + type: string; + id: string; + name: string; + }; +} \ No newline at end of file