Skip to content

Commit 3a5f6e9

Browse files
authored
[TASK-178 / 25.06.27] Feature - 센트리 슬랙 웹훅 연동
2 parents bf4afa2 + 7db5b02 commit 3a5f6e9

File tree

6 files changed

+512
-0
lines changed

6 files changed

+512
-0
lines changed
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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+
});

src/controllers/webhook.controller.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { NextFunction, Request, RequestHandler, Response } from 'express';
2+
import { EmptyResponseDto, SentryWebhookData } from '@/types';
3+
import logger from '@/configs/logger.config';
4+
import { sendSlackMessage } from '@/modules/slack/slack.notifier';
5+
6+
export class WebhookController {
7+
private readonly STATUS_EMOJI = {
8+
'unresolved': '🔴',
9+
'resolved': '✅',
10+
'ignored': '🔇',
11+
} as const;
12+
13+
handleSentryWebhook: RequestHandler = async (
14+
req: Request,
15+
res: Response,
16+
next: NextFunction,
17+
): Promise<void> => {
18+
try {
19+
20+
if (req.body?.action !== "created") {
21+
const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null);
22+
res.status(400).json(response);
23+
return;
24+
}
25+
26+
const sentryData: SentryWebhookData = req.body;
27+
28+
const slackMessage = this.formatSentryMessage(sentryData);
29+
await sendSlackMessage(slackMessage);
30+
31+
const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null);
32+
res.status(200).json(response);
33+
} catch (error) {
34+
logger.error('Sentry 웹훅 처리 실패:', error);
35+
next(error);
36+
}
37+
};
38+
39+
private formatSentryMessage(sentryData: SentryWebhookData): string {
40+
const { data: { issue } } = sentryData;
41+
42+
if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다');
43+
44+
const { status, title: issueTitle, culprit, permalink, id } = issue;
45+
const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI];
46+
47+
// URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성
48+
const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${id}/`;
49+
50+
let message = `🚨 *새로운 오류가 발생하였습니다*\n\n`;
51+
message += `${statusEmoji} *제목:* ${issueTitle}\n\n`;
52+
message += `📍 *위치:* ${culprit}\n\n`;
53+
message += `🔗 *상세 보기:* ${detailUrl}`;
54+
55+
return message;
56+
}
57+
}

src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PostRouter from './post.router';
44
import NotiRouter from './noti.router';
55
import LeaderboardRouter from './leaderboard.router';
66
import TotalStatsRouter from './totalStats.router';
7+
import WebhookRouter from './webhook.router';
78

89
const router: Router = express.Router();
910

@@ -16,5 +17,6 @@ router.use('/', PostRouter);
1617
router.use('/', NotiRouter);
1718
router.use('/', LeaderboardRouter);
1819
router.use('/', TotalStatsRouter);
20+
router.use('/', WebhookRouter);
1921

2022
export default router;

0 commit comments

Comments
 (0)