1+ import 'reflect-metadata' ;
2+ import { Request , Response } from 'express' ;
3+ import { WebhookController } from '@/controllers/webhook.controller' ;
4+ import { sendSlackMessage } from '@/modules/slack/slack.notifier' ;
5+ import { SentryWebhookData } from '@/types' ;
6+
7+ // Mock dependencies
8+ jest . mock ( '@/modules/slack/slack.notifier' ) ;
9+
10+ // logger 모킹
11+ jest . mock ( '@/configs/logger.config' , ( ) => ( {
12+ error : jest . fn ( ) ,
13+ info : jest . fn ( ) ,
14+ } ) ) ;
15+
16+ describe ( 'WebhookController' , ( ) => {
17+ let webhookController : WebhookController ;
18+ let mockRequest : Partial < Request > ;
19+ let mockResponse : Partial < Response > ;
20+ let nextFunction : jest . Mock ;
21+ let mockSendSlackMessage : jest . MockedFunction < typeof sendSlackMessage > ;
22+
23+ beforeEach ( ( ) => {
24+ // WebhookController 인스턴스 생성
25+ webhookController = new WebhookController ( ) ;
26+
27+ // Request, Response, NextFunction 모킹
28+ mockRequest = {
29+ body : { } ,
30+ headers : { } ,
31+ } ;
32+
33+ mockResponse = {
34+ json : jest . fn ( ) . mockReturnThis ( ) ,
35+ status : jest . fn ( ) . mockReturnThis ( ) ,
36+ } ;
37+
38+ nextFunction = jest . fn ( ) ;
39+ mockSendSlackMessage = sendSlackMessage as jest . MockedFunction < typeof sendSlackMessage > ;
40+ } ) ;
41+
42+ afterEach ( ( ) => {
43+ jest . clearAllMocks ( ) ;
44+ } ) ;
45+
46+ describe ( 'handleSentryWebhook' , ( ) => {
47+ const mockSentryData : SentryWebhookData = {
48+ action : 'created' ,
49+ data : {
50+ issue : {
51+ id : 'test-issue-123' ,
52+ title : '테스트 오류입니다' ,
53+ culprit : 'TestFile.js:10' ,
54+ status : 'unresolved' ,
55+ count : 5 ,
56+ userCount : 3 ,
57+ firstSeen : '2024-01-01T12:00:00.000Z' ,
58+ permalink : 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/' ,
59+ project : {
60+ id : 'project-123' ,
61+ name : 'Velog Dashboard' ,
62+ slug : 'velog-dashboard'
63+ }
64+ }
65+ }
66+ } ;
67+
68+ it ( '유효한 Sentry 웹훅 데이터로 처리에 성공해야 한다' , async ( ) => {
69+ mockRequest . body = mockSentryData ;
70+ mockSendSlackMessage . mockResolvedValue ( ) ;
71+
72+ await webhookController . handleSentryWebhook (
73+ mockRequest as Request ,
74+ mockResponse as Response ,
75+ nextFunction
76+ ) ;
77+
78+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
79+ expect . stringContaining ( '🚨 *새로운 오류가 발생했습니다*' )
80+ ) ;
81+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
82+ expect . stringContaining ( '🔴 *제목:* 테스트 오류입니다' )
83+ ) ;
84+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
85+ expect . stringContaining ( '📍 *위치:* TestFile.js:10' )
86+ ) ;
87+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
88+ expect . stringContaining ( '🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/' )
89+ ) ;
90+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 200 ) ;
91+ expect ( mockResponse . json ) . toHaveBeenCalledWith ( {
92+ success : true ,
93+ message : 'Sentry 웹훅 처리에 성공하였습니다.' ,
94+ data : { } ,
95+ error : null
96+ } ) ;
97+ } ) ;
98+
99+ it ( 'permalink가 없는 경우 기본 URL 패턴을 사용해야 한다' , async ( ) => {
100+ const dataWithoutPermalink = {
101+ ...mockSentryData ,
102+ data : {
103+ ...mockSentryData . data ,
104+ issue : {
105+ ...mockSentryData . data . issue ,
106+ permalink : undefined
107+ }
108+ }
109+ } ;
110+ mockRequest . body = dataWithoutPermalink ;
111+ mockSendSlackMessage . mockResolvedValue ( ) ;
112+
113+ await webhookController . handleSentryWebhook (
114+ mockRequest as Request ,
115+ mockResponse as Response ,
116+ nextFunction
117+ ) ;
118+
119+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
120+ expect . stringContaining ( '🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/' )
121+ ) ;
122+ } ) ;
123+
124+ it ( 'resolved 액션에 대해 올바른 메시지를 생성해야 한다' , async ( ) => {
125+ const resolvedData = {
126+ ...mockSentryData ,
127+ action : 'resolved' as const ,
128+ data : {
129+ ...mockSentryData . data ,
130+ issue : {
131+ ...mockSentryData . data . issue ,
132+ status : 'resolved' as const
133+ }
134+ }
135+ } ;
136+ mockRequest . body = resolvedData ;
137+ mockSendSlackMessage . mockResolvedValue ( ) ;
138+
139+ await webhookController . handleSentryWebhook (
140+ mockRequest as Request ,
141+ mockResponse as Response ,
142+ nextFunction
143+ ) ;
144+
145+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
146+ expect . stringContaining ( '🚨 *오류가 해결되었습니다*' )
147+ ) ;
148+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
149+ expect . stringContaining ( '✅ *제목:*' )
150+ ) ;
151+ } ) ;
152+
153+ it ( 'ignored 액션에 대해 올바른 메시지를 생성해야 한다' , async ( ) => {
154+ const ignoredData = {
155+ ...mockSentryData ,
156+ action : 'ignored' as const ,
157+ data : {
158+ ...mockSentryData . data ,
159+ issue : {
160+ ...mockSentryData . data . issue ,
161+ status : 'ignored' as const
162+ }
163+ }
164+ } ;
165+ mockRequest . body = ignoredData ;
166+ mockSendSlackMessage . mockResolvedValue ( ) ;
167+
168+ await webhookController . handleSentryWebhook (
169+ mockRequest as Request ,
170+ mockResponse as Response ,
171+ nextFunction
172+ ) ;
173+
174+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
175+ expect . stringContaining ( '🚨 *오류가 무시되었습니다*' )
176+ ) ;
177+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
178+ expect . stringContaining ( '🔇 *제목:*' )
179+ ) ;
180+ } ) ;
181+
182+ it ( '알 수 없는 액션에 대해 기본 메시지를 생성해야 한다' , async ( ) => {
183+ const unknownActionData = {
184+ ...mockSentryData ,
185+ action : 'unknown_action' as 'created'
186+ } ;
187+ mockRequest . body = unknownActionData ;
188+ mockSendSlackMessage . mockResolvedValue ( ) ;
189+
190+ await webhookController . handleSentryWebhook (
191+ mockRequest as Request ,
192+ mockResponse as Response ,
193+ nextFunction
194+ ) ;
195+
196+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
197+ expect . stringContaining ( '오류 이벤트: unknown_action' )
198+ ) ;
199+ } ) ;
200+
201+ it ( '알 수 없는 상태에 대해 기본 이모지를 사용해야 한다' , async ( ) => {
202+ const unknownStatusData = {
203+ ...mockSentryData ,
204+ data : {
205+ ...mockSentryData . data ,
206+ issue : {
207+ ...mockSentryData . data . issue ,
208+ status : 'unknown_status' as 'unresolved'
209+ }
210+ }
211+ } ;
212+ mockRequest . body = unknownStatusData ;
213+ mockSendSlackMessage . mockResolvedValue ( ) ;
214+
215+ await webhookController . handleSentryWebhook (
216+ mockRequest as Request ,
217+ mockResponse as Response ,
218+ nextFunction
219+ ) ;
220+
221+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
222+ expect . stringContaining ( '❓ *제목:*' )
223+ ) ;
224+ } ) ;
225+
226+ it ( 'Slack 메시지 전송 실패 시 에러를 전달해야 한다' , async ( ) => {
227+ mockRequest . body = mockSentryData ;
228+ const slackError = new Error ( 'Slack 전송 실패' ) ;
229+ mockSendSlackMessage . mockRejectedValue ( slackError ) ;
230+
231+ await webhookController . handleSentryWebhook (
232+ mockRequest as Request ,
233+ mockResponse as Response ,
234+ nextFunction
235+ ) ;
236+
237+ expect ( nextFunction ) . toHaveBeenCalledWith ( slackError ) ;
238+ expect ( mockResponse . json ) . not . toHaveBeenCalled ( ) ;
239+ } ) ;
240+
241+ it ( '빈 body로 요청 시에도 처리해야 한다' , async ( ) => {
242+ mockRequest . body = { } ;
243+ mockSendSlackMessage . mockResolvedValue ( ) ;
244+
245+ await webhookController . handleSentryWebhook (
246+ mockRequest as Request ,
247+ mockResponse as Response ,
248+ nextFunction
249+ ) ;
250+
251+ // undefined 값들에 대해서도 처리되어야 함
252+ expect ( mockSendSlackMessage ) . toHaveBeenCalled ( ) ;
253+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 200 ) ;
254+ } ) ;
255+
256+ it ( '필수 필드가 없는 경우에도 처리해야 한다' , async ( ) => {
257+ const incompleteData = {
258+ action : 'created' ,
259+ data : {
260+ issue : {
261+ id : 'test-123'
262+ // title, culprit 등 누락
263+ }
264+ }
265+ } ;
266+ mockRequest . body = incompleteData ;
267+ mockSendSlackMessage . mockResolvedValue ( ) ;
268+
269+ await webhookController . handleSentryWebhook (
270+ mockRequest as Request ,
271+ mockResponse as Response ,
272+ nextFunction
273+ ) ;
274+
275+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith (
276+ expect . stringContaining ( '🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-123/' )
277+ ) ;
278+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 200 ) ;
279+ } ) ;
280+ } ) ;
281+
282+ describe ( 'formatSentryMessage (private method integration test)' , ( ) => {
283+ it ( '완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다' , async ( ) => {
284+ const completeData : SentryWebhookData = {
285+ action : 'created' ,
286+ data : {
287+ issue : {
288+ id : 'issue-456' ,
289+ title : 'TypeError: Cannot read property of undefined' ,
290+ culprit : 'components/UserProfile.tsx:25' ,
291+ status : 'unresolved' ,
292+ count : 12 ,
293+ userCount : 8 ,
294+ firstSeen : '2024-01-15T14:30:00.000Z' ,
295+ permalink : 'https://velog-dashboardv2.sentry.io/issues/issue-456/' ,
296+ project : {
297+ id : 'proj-789' ,
298+ name : 'Velog Dashboard V2' ,
299+ slug : 'velog-dashboard-v2'
300+ }
301+ }
302+ }
303+ } ;
304+
305+ mockRequest . body = completeData ;
306+ mockSendSlackMessage . mockResolvedValue ( ) ;
307+
308+ await webhookController . handleSentryWebhook (
309+ mockRequest as Request ,
310+ mockResponse as Response ,
311+ nextFunction
312+ ) ;
313+
314+ const expectedMessage = `🚨 *새로운 오류가 발생했습니다*
315+
316+ 🔴 *제목:* TypeError: Cannot read property of undefined
317+
318+ 📍 *위치:* components/UserProfile.tsx:25
319+
320+ 🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/issue-456/` ;
321+
322+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith ( expectedMessage ) ;
323+ } ) ;
324+ } ) ;
325+ } ) ;
0 commit comments