Skip to content

Commit 6d3b0ec

Browse files
committed
feat: qrcode app 컨트롤러, 서비스, 레포지토리, 타입 추가, 관련 API 구현
1 parent 5f14e86 commit 6d3b0ec

File tree

11 files changed

+325
-0
lines changed

11 files changed

+325
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"reflect-metadata": "^0.2.2",
3636
"swagger-jsdoc": "^6.2.8",
3737
"swagger-ui-express": "^5.0.1",
38+
"uuid": "^11.1.0",
3839
"winston": "^3.17.0",
3940
"winston-daily-rotate-file": "^5.0.0"
4041
},

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/controllers/qr.controller.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { NextFunction, Request, RequestHandler, Response } from 'express';
2+
import logger from '@/configs/logger.config';
3+
import { QRLoginTokenService } from "@/services/qr.service";
4+
import { QRLoginTokenResponseDto } from "@/types/dto/responses/qrResponse.type";
5+
6+
export class QRLoginController {
7+
constructor(private qrService: QRLoginTokenService) {}
8+
9+
createToken: RequestHandler = async (
10+
req: Request,
11+
res: Response<QRLoginTokenResponseDto>,
12+
next: NextFunction,
13+
) => {
14+
try {
15+
const user = req.user;
16+
const ip = req.ip ?? '';
17+
const userAgent = req.headers['user-agent'] || '';
18+
19+
const token = await this.qrService.create(user.id, ip, userAgent);
20+
21+
const response = new QRLoginTokenResponseDto(
22+
true,
23+
'QR 토큰 생성 완료',
24+
{ token: token },
25+
null
26+
);
27+
res.status(200).json(response);
28+
} catch (error) {
29+
logger.error('생성 실패:', error);
30+
next(error);
31+
}
32+
};
33+
34+
getToken: RequestHandler = async (req, res, next) => {
35+
try {
36+
const token = req.query.token as string;
37+
38+
if (!token) {
39+
res.status(400).json({ success: false, message: '토큰이 필요합니다.' });
40+
}
41+
42+
const found = await this.qrService.getByToken(token);
43+
44+
if (!found) {
45+
res.status(404).json({ success: false, message: '유효하지 않거나 만료된 토큰입니다.' });
46+
}
47+
48+
res.status(200).json({ success: true, message: '유효한 QR 토큰입니다.', token: found });
49+
} catch (error) {
50+
logger.error('QR 토큰 조회 실패', error);
51+
next(error);
52+
}
53+
};
54+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { QRLoginTokenRepository } from '@/repositories/qr.repository';
2+
import { DBError } from '@/exception';
3+
import { Pool } from 'pg';
4+
5+
const mockPool: Partial<Pool> = {
6+
query: jest.fn()
7+
};
8+
9+
describe('QRLoginTokenRepository', () => {
10+
let repo: QRLoginTokenRepository;
11+
12+
beforeEach(() => {
13+
repo = new QRLoginTokenRepository(mockPool as Pool);
14+
});
15+
16+
afterEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
it('should insert QR login token', async () => {
21+
(mockPool.query as jest.Mock).mockResolvedValueOnce(undefined);
22+
23+
await expect(
24+
repo.createQRLoginToken('token', 1, 'ip', 'agent')
25+
).resolves.not.toThrow();
26+
27+
expect(mockPool.query).toHaveBeenCalled();
28+
});
29+
30+
it('should throw DBError on insert failure', async () => {
31+
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));
32+
33+
await expect(repo.createQRLoginToken('token', 1, 'ip', 'agent'))
34+
.rejects.toThrow(DBError);
35+
});
36+
37+
it('should return token if found', async () => {
38+
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ token: 'token' }] });
39+
40+
const result = await repo.findQRLoginToken('token');
41+
expect(result).toEqual({ token: 'token' });
42+
});
43+
44+
it('should return null if token not found', async () => {
45+
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
46+
47+
const result = await repo.findQRLoginToken('token');
48+
expect(result).toBeNull();
49+
});
50+
51+
it('should throw DBError on select failure', async () => {
52+
(mockPool.query as jest.Mock).mockRejectedValueOnce(new Error('fail'));
53+
54+
await expect(repo.findQRLoginToken('token')).rejects.toThrow(DBError);
55+
});
56+
});

src/repositories/qr.repository.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Pool } from 'pg';
2+
import logger from '@/configs/logger.config';
3+
import { DBError } from '@/exception';
4+
import { QRLoginToken } from '@/types/models/QRLoginToken.type';
5+
6+
export class QRLoginTokenRepository {
7+
constructor(private pool: Pool) { }
8+
9+
async createQRLoginToken(token: String, userId: number, ip: string, userAgent: string): Promise<void> {
10+
try {
11+
const query = `
12+
INSERT INTO qr_login_tokens (token, user_id, created_at, expires_at, is_used, ip_address, user_agent)
13+
VALUES ($1, $2, NOW(), NOW() + INTERVAL '5 minutes', false, $3, $4);
14+
`;
15+
await this.pool.query(query, [token, userId, ip, userAgent]);
16+
} catch (error) {
17+
logger.error('QRLoginToken Repo Create Error : ', error);
18+
throw new DBError('QR 코드 토큰 생성 중 문제가 발생했습니다.');
19+
}
20+
}
21+
22+
async findQRLoginToken(token: string): Promise<QRLoginToken | null> {
23+
try {
24+
const query = `
25+
SELECT *
26+
FROM qr_login_tokens
27+
WHERE token = $1 AND is_used = false AND expires_at > NOW();
28+
`;
29+
const result = await this.pool.query(query, [token]);
30+
return result.rows[0] ?? null;
31+
} catch (error) {
32+
logger.error('QRLoginToken Repo find QR Code Error : ', error);
33+
throw new DBError('QR 코드 토큰 조회 중 문제가 발생했습니다.');
34+
}
35+
}
36+
}

src/routes/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express, { Router } from 'express';
22
import UserRouter from './user.router';
33
import PostRouter from './post.router';
44
import NotiRouter from './noti.router';
5+
import QrRouter from './qr.router';
56

67
const router: Router = express.Router();
78

@@ -12,4 +13,6 @@ router.use('/ping', (req, res) => {
1213
router.use('/', UserRouter);
1314
router.use('/', PostRouter);
1415
router.use('/', NotiRouter);
16+
router.use('/', QrRouter);
17+
1518
export default router;

src/routes/qr.router.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import express, { Router } from 'express';
2+
import pool from '@/configs/db.config';
3+
4+
import { authMiddleware } from '@/middlewares/auth.middleware';
5+
import { QRLoginTokenRepository } from '@/repositories/qr.repository';
6+
import { QRLoginTokenService } from '@/services/qr.service';
7+
import { QRLoginController } from '@/controllers/qr.controller';
8+
9+
const router: Router = express.Router();
10+
11+
const qrRepository = new QRLoginTokenRepository(pool);
12+
const qrService = new QRLoginTokenService(qrRepository);
13+
const qrController = new QRLoginController(qrService);
14+
15+
/**
16+
* @swagger
17+
* /api/qr-login:
18+
* post:
19+
* summary: QR 로그인 토큰 생성
20+
* tags: [QRLogin]
21+
* security:
22+
* - bearerAuth: []
23+
* responses:
24+
* 201:
25+
* description: QR 로그인 토큰 생성 성공
26+
*/
27+
router.post('/qr-login', authMiddleware.login, qrController.createToken);
28+
29+
/**
30+
* @swagger
31+
* /api/qr-login:
32+
* get:
33+
* summary: QR 로그인 토큰 조회
34+
* tags: [QRLogin]
35+
* parameters:
36+
* - in: query
37+
* name: token
38+
* required: true
39+
* schema:
40+
* type: string
41+
* description: 조회할 QR 토큰
42+
* responses:
43+
* 200:
44+
* description: 유효한 토큰
45+
* 404:
46+
* description: 토큰 없음 or 만료
47+
*/
48+
router.get('/qr-login', qrController.getToken);
49+
50+
export default router;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { QRLoginTokenService } from '@/services/qr.service';
2+
import { QRLoginTokenRepository } from '@/repositories/qr.repository';
3+
import { DBError } from '@/exception';
4+
import { QRLoginToken } from '@/types/models/QRLoginToken.type';
5+
6+
jest.mock('@/repositories/qr.repository');
7+
8+
describe('QRLoginTokenService', () => {
9+
let service: QRLoginTokenService;
10+
let repo: jest.Mocked<QRLoginTokenRepository>;
11+
12+
beforeEach(() => {
13+
const repoInstance = new QRLoginTokenRepository({} as any)
14+
repo = repoInstance as jest.Mocked<QRLoginTokenRepository>;
15+
service = new QRLoginTokenService(repo);
16+
});
17+
18+
afterEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
describe('create', () => {
23+
it('QR 토큰을 생성하고 반환해야 한다', async () => {
24+
const userId = 1;
25+
const ip = '127.0.0.1';
26+
const userAgent = 'Chrome';
27+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
28+
29+
const token = await service.create(userId, ip, userAgent);
30+
31+
expect(token).toMatch(uuidRegex);
32+
expect(repo.createQRLoginToken).toHaveBeenCalledWith(token, userId, ip, userAgent);
33+
});
34+
35+
it('QR 토큰 생성 중 오류 발생 시 예외 발생', async () => {
36+
const userId = 1;
37+
const ip = '127.0.0.1';
38+
const userAgent = 'Mozilla';
39+
repo.createQRLoginToken.mockRejectedValueOnce(new DBError('생성 실패'));
40+
41+
await expect(service.create(userId, ip, userAgent)).rejects.toThrow('생성 실패');
42+
expect(repo.createQRLoginToken).toHaveBeenCalled();
43+
});
44+
});
45+
46+
describe('getByToken', () => {
47+
const mockToken = 'sample-token';
48+
const mockQRToken: QRLoginToken = {
49+
token: mockToken,
50+
user: 1,
51+
created_at: new Date(),
52+
expires_at: new Date(Date.now() + 1000 * 60 * 5),
53+
is_used: false,
54+
ip_address: '127.0.0.1',
55+
user_agent: 'Chrome',
56+
};
57+
58+
it('유효한 토큰 조회 시 QRLoginToken 반환', async () => {
59+
repo.findQRLoginToken.mockResolvedValue(mockQRToken);
60+
61+
const result = await service.getByToken(mockToken);
62+
63+
expect(result).toEqual(mockQRToken);
64+
expect(repo.findQRLoginToken).toHaveBeenCalledWith(mockToken);
65+
});
66+
67+
it('토큰이 없을 경우 null 반환', async () => {
68+
repo.findQRLoginToken.mockResolvedValue(null);
69+
70+
const result = await service.getByToken(mockToken);
71+
72+
expect(result).toBeNull();
73+
expect(repo.findQRLoginToken).toHaveBeenCalledWith(mockToken);
74+
});
75+
76+
it('토큰 조회 중 오류 발생 시 예외 발생', async () => {
77+
repo.findQRLoginToken.mockRejectedValueOnce(new DBError('조회 실패'));
78+
79+
await expect(service.getByToken(mockToken)).rejects.toThrow('조회 실패');
80+
expect(repo.findQRLoginToken).toHaveBeenCalledWith(mockToken);
81+
});
82+
});
83+
});

src/services/qr.service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { QRLoginTokenRepository } from "@/repositories/qr.repository";
2+
import { QRLoginToken } from "@/types/models/QRLoginToken.type";
3+
import { randomUUID } from "crypto";
4+
5+
export class QRLoginTokenService {
6+
constructor(private qrRepo: QRLoginTokenRepository) {}
7+
8+
async create(userId: number, ip: string, userAgent: string): Promise<string> {
9+
const token = randomUUID();
10+
await this.qrRepo.createQRLoginToken(token, userId, ip, userAgent);
11+
return token;
12+
}
13+
14+
async getByToken(token: string): Promise<QRLoginToken | null> {
15+
return await this.qrRepo.findQRLoginToken(token);
16+
}
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type';
2+
3+
interface QRLoginTokenResponseData {
4+
token: string;
5+
}
6+
7+
export class QRLoginTokenResponseDto extends BaseResponseDto<QRLoginTokenResponseData> { }

0 commit comments

Comments
 (0)