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+ email : 'test@example.com'
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