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