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
+
6
+ // Mock dependencies
7
+ jest . mock ( '@/modules/slack/slack.notifier' ) ;
8
+
9
+ // logger 모킹
10
+ jest . mock ( '@/configs/logger.config' , ( ) => ( {
11
+ error : jest . fn ( ) ,
12
+ info : jest . fn ( ) ,
13
+ } ) ) ;
14
+
15
+ describe ( 'WebhookController' , ( ) => {
16
+ let webhookController : WebhookController ;
17
+ let mockRequest : Partial < Request > ;
18
+ let mockResponse : Partial < Response > ;
19
+ let nextFunction : jest . Mock ;
20
+ let mockSendSlackMessage : jest . MockedFunction < typeof sendSlackMessage > ;
21
+
22
+ beforeEach ( ( ) => {
23
+ // WebhookController 인스턴스 생성
24
+ webhookController = new WebhookController ( ) ;
25
+
26
+ // Request, Response, NextFunction 모킹
27
+ mockRequest = {
28
+ body : { } ,
29
+ headers : { } ,
30
+ } ;
31
+
32
+ mockResponse = {
33
+ json : jest . fn ( ) . mockReturnThis ( ) ,
34
+ status : jest . fn ( ) . mockReturnThis ( ) ,
35
+ } ;
36
+
37
+ nextFunction = jest . fn ( ) ;
38
+ mockSendSlackMessage = sendSlackMessage as jest . MockedFunction < typeof sendSlackMessage > ;
39
+ } ) ;
40
+
41
+ afterEach ( ( ) => {
42
+ jest . clearAllMocks ( ) ;
43
+ } ) ;
44
+
45
+ describe ( 'handleSentryWebhook' , ( ) => {
46
+ // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용
47
+ const mockSentryData = {
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 ( 'Slack 메시지 전송 실패 시 에러를 전달해야 한다' , async ( ) => {
125
+ mockRequest . body = mockSentryData ;
126
+ const slackError = new Error ( 'Slack 전송 실패' ) ;
127
+ mockSendSlackMessage . mockRejectedValue ( slackError ) ;
128
+
129
+ await webhookController . handleSentryWebhook (
130
+ mockRequest as Request ,
131
+ mockResponse as Response ,
132
+ nextFunction
133
+ ) ;
134
+
135
+ expect ( nextFunction ) . toHaveBeenCalledWith ( slackError ) ;
136
+ expect ( mockResponse . json ) . not . toHaveBeenCalled ( ) ;
137
+ } ) ;
138
+
139
+ // Invalid Body 케이스 테스트들
140
+ it ( 'action이 created가 아닌 경우 400 에러를 반환해야 한다' , async ( ) => {
141
+ mockRequest . body = { action : 'resolved' } ;
142
+
143
+ await webhookController . handleSentryWebhook (
144
+ mockRequest as Request ,
145
+ mockResponse as Response ,
146
+ nextFunction
147
+ ) ;
148
+
149
+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 400 ) ;
150
+ expect ( mockResponse . json ) . toHaveBeenCalledWith ( {
151
+ success : true ,
152
+ message : 'Sentry 웹훅 처리에 실패했습니다' ,
153
+ data : { } ,
154
+ error : null
155
+ } ) ;
156
+ expect ( nextFunction ) . not . toHaveBeenCalled ( ) ;
157
+ } ) ;
158
+
159
+ it ( '빈 body인 경우 400 에러를 반환해야 한다' , async ( ) => {
160
+ mockRequest . body = { } ;
161
+
162
+ await webhookController . handleSentryWebhook (
163
+ mockRequest as Request ,
164
+ mockResponse as Response ,
165
+ nextFunction
166
+ ) ;
167
+
168
+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 400 ) ;
169
+ expect ( mockResponse . json ) . toHaveBeenCalledWith ( {
170
+ success : true ,
171
+ message : 'Sentry 웹훅 처리에 실패했습니다' ,
172
+ data : { } ,
173
+ error : null
174
+ } ) ;
175
+ } ) ;
176
+
177
+ it ( 'action이 없는 경우 400 에러를 반환해야 한다' , async ( ) => {
178
+ mockRequest . body = { data : { issue : { } } } ;
179
+
180
+ await webhookController . handleSentryWebhook (
181
+ mockRequest as Request ,
182
+ mockResponse as Response ,
183
+ nextFunction
184
+ ) ;
185
+
186
+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 400 ) ;
187
+ expect ( mockResponse . json ) . toHaveBeenCalledWith ( {
188
+ success : true ,
189
+ message : 'Sentry 웹훅 처리에 실패했습니다' ,
190
+ data : { } ,
191
+ error : null
192
+ } ) ;
193
+ } ) ;
194
+
195
+ it ( '전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다' , async ( ) => {
196
+ mockRequest . body = {
197
+ username : 'test' ,
198
+ password : '123456' ,
199
+
200
+ } ;
201
+
202
+ await webhookController . handleSentryWebhook (
203
+ mockRequest as Request ,
204
+ mockResponse as Response ,
205
+ nextFunction
206
+ ) ;
207
+
208
+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 400 ) ;
209
+ expect ( mockResponse . json ) . toHaveBeenCalledWith ( {
210
+ success : true ,
211
+ message : 'Sentry 웹훅 처리에 실패했습니다' ,
212
+ data : { } ,
213
+ error : null
214
+ } ) ;
215
+ } ) ;
216
+
217
+ it ( 'action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다' , async ( ) => {
218
+ mockRequest . body = {
219
+ action : 'created' ,
220
+ data : {
221
+ issue : {
222
+ // 필수 필드들이 누락됨
223
+ }
224
+ }
225
+ } ;
226
+
227
+ await webhookController . handleSentryWebhook (
228
+ mockRequest as Request ,
229
+ mockResponse as Response ,
230
+ nextFunction
231
+ ) ;
232
+
233
+ expect ( nextFunction ) . toHaveBeenCalledWith (
234
+ expect . objectContaining ( {
235
+ message : 'Sentry 웹훅 데이터가 올바르지 않습니다'
236
+ } )
237
+ ) ;
238
+ expect ( mockResponse . json ) . not . toHaveBeenCalled ( ) ;
239
+ } ) ;
240
+
241
+ it ( 'action은 created이지만 data가 없는 경우 에러를 전달해야 한다' , async ( ) => {
242
+ mockRequest . body = { action : 'created' } ;
243
+
244
+ await webhookController . handleSentryWebhook (
245
+ mockRequest as Request ,
246
+ mockResponse as Response ,
247
+ nextFunction
248
+ ) ;
249
+
250
+ expect ( nextFunction ) . toHaveBeenCalled ( ) ;
251
+ expect ( mockResponse . json ) . not . toHaveBeenCalled ( ) ;
252
+ } ) ;
253
+
254
+ it ( '잘못된 타입의 body인 경우 400 에러를 반환해야 한다' , async ( ) => {
255
+ mockRequest . body = 'invalid string body' ;
256
+
257
+ await webhookController . handleSentryWebhook (
258
+ mockRequest as Request ,
259
+ mockResponse as Response ,
260
+ nextFunction
261
+ ) ;
262
+
263
+ expect ( mockResponse . status ) . toHaveBeenCalledWith ( 400 ) ;
264
+ } ) ;
265
+ } ) ;
266
+
267
+ describe ( 'formatSentryMessage (private method integration test)' , ( ) => {
268
+ it ( '완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다' , async ( ) => {
269
+ // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용
270
+ const completeData = {
271
+ action : 'created' ,
272
+ data : {
273
+ issue : {
274
+ id : 'issue-456' ,
275
+ title : 'TypeError: Cannot read property of undefined' ,
276
+ culprit : 'components/UserProfile.tsx:25' ,
277
+ status : 'unresolved' ,
278
+ count : "12" ,
279
+ userCount : 8 ,
280
+ firstSeen : '2024-01-15T14:30:00.000Z' ,
281
+ permalink : 'https://velog-dashboardv2.sentry.io/issues/issue-456/' ,
282
+ project : {
283
+ id : 'proj-789' ,
284
+ name : 'Velog Dashboard V2' ,
285
+ slug : 'velog-dashboard-v2'
286
+ }
287
+ }
288
+ }
289
+ } ;
290
+
291
+ mockRequest . body = completeData ;
292
+ mockSendSlackMessage . mockResolvedValue ( ) ;
293
+
294
+ await webhookController . handleSentryWebhook (
295
+ mockRequest as Request ,
296
+ mockResponse as Response ,
297
+ nextFunction
298
+ ) ;
299
+
300
+ const expectedMessage = `🚨 *새로운 오류가 발생하였습니다*
301
+
302
+ 🔴 *제목:* TypeError: Cannot read property of undefined
303
+
304
+ 📍 *위치:* components/UserProfile.tsx:25
305
+
306
+ 🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/issue-456/` ;
307
+
308
+ expect ( mockSendSlackMessage ) . toHaveBeenCalledWith ( expectedMessage ) ;
309
+ } ) ;
310
+ } ) ;
311
+ } ) ;
0 commit comments