Skip to content

Commit 16819d7

Browse files
authored
[25.02.08 / TASK-109 / TASK-94] Feature - 샘플 유저 로그인 로직 추가, 신규 유저 slack noti 추가 (#17)
* feature: 샘플 유저 로그인 로직 추가, 신규 유저 slack noti 추가 * modify: error check 강화
1 parent 20b6391 commit 16819d7

File tree

9 files changed

+216
-15
lines changed

9 files changed

+216
-15
lines changed

.env.sample

+3
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ POSTGRES_USER=vd2
2020
POSTGRES_PASSWORD=vd2
2121
POSTGRES_HOST=localhost
2222
POSTGRES_PORT=5432
23+
24+
# ETC
25+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services

src/controllers/user.controller.ts

+31-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import logger from '@/configs/logger.config';
33
import { EmptyResponseDto, LoginResponseDto, UserWithTokenDto } from '@/types';
44
import { UserService } from '@/services/user.service';
55
export class UserController {
6-
constructor(private userService: UserService) {}
6+
constructor(private userService: UserService) { }
77

88
private cookieOption(): CookieOptions {
99
const isProd = process.env.NODE_ENV === 'production';
@@ -51,6 +51,36 @@ export class UserController {
5151
}
5252
};
5353

54+
sampleLogin: RequestHandler = async (req: Request, res: Response<LoginResponseDto>, next: NextFunction): Promise<void> => {
55+
try {
56+
const sampleUser = await this.userService.findSampleUser();
57+
58+
res.clearCookie('access_token');
59+
res.clearCookie('refresh_token');
60+
61+
res.cookie('access_token', sampleUser.decryptedAccessToken, this.cookieOption());
62+
res.cookie('refresh_token', sampleUser.decryptedRefreshToken, this.cookieOption());
63+
64+
req.user = sampleUser.user;
65+
66+
const response = new LoginResponseDto(
67+
true,
68+
'로그인에 성공하였습니다.',
69+
{
70+
id: sampleUser.user.id,
71+
username: "테스트 유저",
72+
profile: { "thumbnail": "https://velog.io/favicon.ico" }
73+
},
74+
null,
75+
);
76+
77+
res.status(200).json(response);
78+
} catch (error) {
79+
logger.error('로그인 실패 : ', error);
80+
next(error);
81+
}
82+
}
83+
5484
logout: RequestHandler = async (req: Request, res: Response<EmptyResponseDto>) => {
5585
res.clearCookie('access_token');
5686
res.clearCookie('refresh_token');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import axios from 'axios';
2+
import dotenv from 'dotenv';
3+
4+
// 환경 변수 로드 (.env 파일)
5+
dotenv.config();
6+
7+
// 테스트 환경에서 SLACK_WEBHOOK_URL이 설정되어 있지 않으면 기본값 설정
8+
process.env.SLACK_WEBHOOK_URL =
9+
process.env.SLACK_WEBHOOK_URL || 'https://dummy-slack-webhook-url.com';
10+
11+
// axios 모듈을 mock 처리
12+
jest.mock('axios');
13+
const mockedAxios = axios as jest.Mocked<typeof axios>;
14+
15+
// 테스트 대상 모듈을 import 합니다.
16+
// 주의: 모듈을 import하기 전에 process.env.SLACK_WEBHOOK_URL을 설정해야 합니다.
17+
import { sendSlackMessage } from '@/modules/slack/slack.notifier';
18+
19+
describe('sendSlackMessage', () => {
20+
// 각 테스트 실행 전 mock 호출 기록 초기화
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
test('정상적인 메시지 전송 - axios.post가 올바른 파라미터로 호출되어야 한다', async () => {
26+
// Arrange: axios.post가 성공적으로 응답하도록 설정합니다.
27+
const fakeResponse = { data: 'ok' };
28+
mockedAxios.post.mockResolvedValue(fakeResponse);
29+
30+
const testMessage = 'Test Slack message';
31+
32+
// Act: sendSlackMessage 함수를 호출합니다.
33+
await sendSlackMessage(testMessage);
34+
35+
// Assert: axios.post가 올바른 URL, payload, header로 호출되었는지 검증합니다.
36+
expect(mockedAxios.post).toHaveBeenCalledWith(
37+
process.env.SLACK_WEBHOOK_URL,
38+
{ text: testMessage },
39+
{ headers: { 'Content-Type': 'application/json' } }
40+
);
41+
});
42+
43+
test('axios.post 호출 중 에러가 발생하면 sendSlackMessage가 예외를 throw 해야 한다', async () => {
44+
// Arrange: axios.post가 에러를 발생시키도록 설정합니다.
45+
const errorMessage = 'Network Error';
46+
mockedAxios.post.mockRejectedValue(new Error(errorMessage));
47+
48+
// Act & Assert: sendSlackMessage 호출 시 에러가 발생하는지 확인합니다.
49+
await expect(sendSlackMessage('Test error')).rejects.toThrow(errorMessage);
50+
});
51+
});

src/modules/slack/slack.notifier.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import axios from 'axios';
2+
import dotenv from 'dotenv';
3+
4+
// 환경 변수 로드 (.env 파일)
5+
dotenv.config();
6+
7+
// 환경 변수 체크
8+
if (!process.env.SLACK_WEBHOOK_URL) {
9+
throw new Error('SLACK_WEBHOOK_URL is not defined in environment variables.');
10+
}
11+
const SLACK_WEBHOOK_URL: string = process.env.SLACK_WEBHOOK_URL;
12+
13+
interface SlackPayload {
14+
text: string;
15+
}
16+
17+
/**
18+
* Slack으로 메시지를 전송합니다.
19+
* @param message 전송할 메시지 텍스트
20+
*/
21+
export async function sendSlackMessage(message: string): Promise<void> {
22+
const payload: SlackPayload = { text: message };
23+
const response = await axios.post(SLACK_WEBHOOK_URL, payload, {
24+
headers: { 'Content-Type': 'application/json' },
25+
});
26+
console.log(response);
27+
}

src/repositories/user.repository.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { User } from '@/types';
44
import { DBError } from '@/exception';
55

66
export class UserRepository {
7-
constructor(private readonly pool: Pool) {}
7+
constructor(private readonly pool: Pool) { }
88

99
async findByUserVelogUUID(uuid: string): Promise<User> {
1010
try {
@@ -17,6 +17,21 @@ export class UserRepository {
1717
}
1818
}
1919

20+
async findSampleUser(): Promise<User> {
21+
try {
22+
const query = `
23+
SELECT * FROM "users_user"
24+
WHERE velog_uuid = '8f561807-8304-4006-84a5-ee3fa8b46d23';
25+
`;
26+
27+
const result = await this.pool.query(query);
28+
return result.rows[0];
29+
} catch (error) {
30+
logger.error('User Repo findSampleUser Error : ', error);
31+
throw new DBError('샘플 유저 조회 중 문제가 발생했습니다.');
32+
}
33+
}
34+
2035
async updateTokens(uuid: string, encryptedAccessToken: string, encryptedRefreshToken: string): Promise<User> {
2136
try {
2237
const query = `

src/routes/user.router.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const userController = new UserController(userService);
2222
* summary: 사용자 로그인
2323
* security: []
2424
* requestBody:
25-
* required: false
25+
* required: true
2626
* content:
2727
* application/json:
2828
* schema:
@@ -49,6 +49,38 @@ const userController = new UserController(userService);
4949
router.post('/login', authMiddleware.login, userController.login);
5050
// router.post('/login', authMiddleware.login, validateRequestDto(VelogUserLoginDto, 'user'), userController.login);
5151

52+
/**
53+
* @swagger
54+
* /login-sample:
55+
* post:
56+
* tags:
57+
* - User
58+
* summary: 샘플 사용자 로그인
59+
* security: []
60+
* requestBody:
61+
* required: false
62+
* content:
63+
* application/json:
64+
* schema:
65+
* type: object
66+
* description: 비어있는 request body
67+
* responses:
68+
* '200':
69+
* description: 성공
70+
* headers:
71+
* Set-Cookie:
72+
* schema:
73+
* type: string
74+
* description: 인증 쿠키
75+
* content:
76+
* application/json:
77+
* schema:
78+
* $ref: '#/components/schemas/LoginResponseDto'
79+
* '500':
80+
* description: 서버 오류 / 데이터 베이스 조회 오류
81+
*/
82+
router.post('/login-sample', userController.sampleLogin);
83+
5284
/**
5385
* @swagger
5486
* /logout:

src/services/user.service.ts

+47-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import logger from '@/configs/logger.config';
2-
import { TokenError } from '@/exception/';
2+
import { NotFoundError, TokenError } from '@/exception/';
33
import { getKeyByGroup } from '@/utils/key.util';
44
import AESEncryption from '@/modules/token_encryption/aes_encryption';
5+
import { sendSlackMessage } from '@/modules/slack/slack.notifier';
56
import { UserRepository } from '@/repositories/user.repository';
6-
import { UserWithTokenDto, User } from '@/types';
7+
import { UserWithTokenDto, User, SampleUser } from '@/types';
78
import { generateRandomGroupId } from '@/utils/generateGroupId.util';
89

910
export class UserService {
10-
constructor(private userRepo: UserRepository) {}
11+
constructor(private userRepo: UserRepository) { }
1112

1213
private encryptTokens(groupId: number, accessToken: string, refreshToken: string) {
1314
const key = getKeyByGroup(groupId);
@@ -28,12 +29,24 @@ export class UserService {
2829
}
2930
}
3031

31-
// 토큰 복호화 처리
32-
// private decryptTokens(refreshToken: string) {
33-
// return {
34-
// decryptedRefreshToken: this.aesEncryption.decrypt(refreshToken),
35-
// };
36-
// }
32+
private decryptTokens(groupId: number, accessToken: string, refreshToken: string) {
33+
const key = getKeyByGroup(groupId);
34+
if (!key) {
35+
logger.error('그룹 키 조회 중 실패');
36+
throw new TokenError('올바르지 않은 그룹 ID로 인해 암호화 키를 찾을 수 없습니다.');
37+
}
38+
try {
39+
const aes = new AESEncryption(key);
40+
41+
return {
42+
decryptedAccessToken: aes.decrypt(accessToken),
43+
decryptedRefreshToken: aes.decrypt(refreshToken),
44+
};
45+
} catch (error) {
46+
logger.error('User Service decryptTokens error : ', error);
47+
throw new TokenError('토큰 복호화 처리에 실패하였습니다.');
48+
}
49+
}
3750

3851
async handleUserTokensByVelogUUID(userData: UserWithTokenDto) {
3952
const { id, email, accessToken, refreshToken } = userData;
@@ -71,16 +84,39 @@ export class UserService {
7184
return await this.userRepo.findByUserVelogUUID(uuid);
7285
}
7386

87+
async findSampleUser(): Promise<SampleUser> {
88+
const user = await this.userRepo.findSampleUser();
89+
if (!user) {
90+
throw new NotFoundError('샘플 유저 정보를 찾을 수 없습니다.');
91+
}
92+
93+
const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens(
94+
user.group_id,
95+
user.access_token,
96+
user.refresh_token
97+
);
98+
99+
return { user, decryptedAccessToken, decryptedRefreshToken };
100+
}
101+
74102
async createUser(userData: UserWithTokenDto) {
75103
const groupId = generateRandomGroupId();
76-
77-
return await this.userRepo.createUser(
104+
const newUser = await this.userRepo.createUser(
78105
userData.id,
79106
userData.email,
80107
userData.accessToken,
81108
userData.refreshToken,
82109
groupId,
83110
);
111+
112+
// 신규 유저 웹훅 알림
113+
try {
114+
await sendSlackMessage(`새로운 유저 등록: ${userData.id}, ${userData.email}`);
115+
} catch (error) {
116+
// Slack 알림 실패는 사용자 생성에 영향을 주지 않도록
117+
logger.error('Slack 알림 전송 실패:', error);
118+
}
119+
return newUser;
84120
}
85121

86122
async updateUserTokens(userData: UserWithTokenDto) {

src/types/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { User } from '@/types/models/User.type';
1+
export type { User, SampleUser } from '@/types/models/User.type';
22
export type { Post } from '@/types/models/Post.type';
33
export type { PostDailyStatistics } from '@/types/models/PostDailyStatistics.type';
44
export type { PostStatistics } from '@/types/models/PostStatistics.type';

src/types/models/User.type.ts

+7
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ export interface User {
99
created_at: Date;
1010
updated_at: Date;
1111
}
12+
13+
14+
export interface SampleUser {
15+
user: User;
16+
decryptedAccessToken: string;
17+
decryptedRefreshToken: string;
18+
}

0 commit comments

Comments
 (0)