Skip to content

Commit 80f9f3c

Browse files
Merge pull request #85 from boostcampwm2025/backend-82
[BE] 예약 로직 변경. (N개 동시 진행. Response 변경.)
2 parents d97b3b6 + 0ee76c2 commit 80f9f3c

File tree

6 files changed

+233
-62
lines changed

6 files changed

+233
-62
lines changed

backend/ticket-server/src/redis/redis.service.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('RedisService', () => {
1818
sadd: jest.fn(),
1919
sismember: jest.fn(),
2020
mget: jest.fn(),
21+
msetnx: jest.fn(),
2122
disconnect: jest.fn(),
2223
};
2324

@@ -101,4 +102,18 @@ describe('RedisService', () => {
101102
expect(jest.mocked(redisClient.mget!)).not.toHaveBeenCalled();
102103
});
103104
});
105+
106+
describe('msetnx', () => {
107+
it('모든 키가 설정되면 true를 반환해야 한다', async () => {
108+
jest.mocked(redisClient.msetnx!).mockResolvedValue(1);
109+
const result = await service.msetnx({ k1: 'v1', k2: 'v2' });
110+
expect(result).toBe(true);
111+
});
112+
113+
it('하나의 키라도 존재하여 설정되지 않으면 false를 반환해야 한다', async () => {
114+
jest.mocked(redisClient.msetnx!).mockResolvedValue(0);
115+
const result = await service.msetnx({ k1: 'v1', k2: 'v2' });
116+
expect(result).toBe(false);
117+
});
118+
});
104119
});

backend/ticket-server/src/redis/redis.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export class RedisService implements OnModuleDestroy {
3030
return result === 'OK';
3131
}
3232

33+
async msetnx(kv: Record<string, string>): Promise<boolean> {
34+
const result = await this.ticketClient.msetnx(...Object.entries(kv).flat());
35+
return Number(result) === 1;
36+
}
37+
3338
async set(key: string, value: string): Promise<string> {
3439
return this.ticketClient.set(key, value);
3540
}
Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { IsNotEmpty, IsNumber } from 'class-validator';
3-
4-
export class CreateReservationRequestDto {
5-
@ApiProperty({ description: '공연 회차 ID', example: 1 })
6-
@IsNumber()
7-
@IsNotEmpty()
8-
session_id: number;
2+
import { IsArray, IsNotEmpty, IsNumber, ValidateNested } from 'class-validator';
3+
import { Type } from 'class-transformer';
94

5+
class Seat {
106
@ApiProperty({ description: '공연장 구역 ID', example: 1 })
117
@IsNumber()
12-
@IsNotEmpty()
138
block_id: number;
149

1510
@ApiProperty({ description: '좌석 행 번호', example: 1 })
1611
@IsNumber()
17-
@IsNotEmpty()
1812
row: number;
1913

2014
@ApiProperty({ description: '좌석 열 번호', example: 5 })
2115
@IsNumber()
22-
@IsNotEmpty()
2316
col: number;
2417
}
18+
19+
export class CreateReservationRequestDto {
20+
@ApiProperty({ description: '공연 회차 ID', example: 1 })
21+
@IsNumber()
22+
@IsNotEmpty()
23+
session_id: number;
24+
25+
@ApiProperty({
26+
description: '예약할 좌석 목록',
27+
type: [Seat],
28+
})
29+
@IsArray()
30+
@ValidateNested({ each: true })
31+
@Type(() => Seat)
32+
seats: Seat[];
33+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
11
import { ApiProperty } from '@nestjs/swagger';
22

3+
class Seat {
4+
@ApiProperty({ description: '공연장 구역 ID', example: 1 })
5+
block_id: number;
6+
7+
@ApiProperty({ description: '좌석 행 번호', example: 1 })
8+
row: number;
9+
10+
@ApiProperty({ description: '좌석 열 번호', example: 5 })
11+
col: number;
12+
}
13+
314
export class CreateReservationResponseDto {
415
@ApiProperty({
516
description: '해당 회차에서의 예약 순번 (랭킹)',
617
example: 123,
718
})
819
rank: number;
20+
21+
@ApiProperty({
22+
description: '예약된 좌석 목록',
23+
type: [Seat],
24+
})
25+
seats: Seat[];
926
}

backend/ticket-server/src/reservation/reservation.service.spec.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/* eslint-disable @typescript-eslint/unbound-method */
22
import { Test, TestingModule } from '@nestjs/testing';
3-
import { BadRequestException, ForbiddenException } from '@nestjs/common';
3+
import {
4+
BadRequestException,
5+
ForbiddenException,
6+
ConflictException,
7+
} from '@nestjs/common';
48
import { ReservationService } from './reservation.service';
59
import { RedisService } from '../redis/redis.service';
610

@@ -20,6 +24,7 @@ describe('ReservationService', () => {
2024
sismember: jest.fn(),
2125
setNx: jest.fn(),
2226
setNxWithTtl: jest.fn(),
27+
msetnx: jest.fn(),
2328
incr: jest.fn(),
2429
publishToQueue: jest.fn(),
2530
del: jest.fn(),
@@ -69,9 +74,20 @@ describe('ReservationService', () => {
6974
});
7075

7176
describe('reserve', () => {
72-
const dto = { session_id: 1, block_id: 10, row: 0, col: 0 };
77+
const dto = {
78+
session_id: 1,
79+
seats: [{ block_id: 10, row: 0, col: 0 }],
80+
};
7381
const userId = 'user-1';
7482

83+
it('유저 락 획득에 실패하면 ConflictException을 던져야 한다', async () => {
84+
redisService.setNxWithTtl.mockResolvedValue(false);
85+
86+
await expect(service.reserve(dto, userId)).rejects.toThrow(
87+
ConflictException,
88+
);
89+
});
90+
7591
it('티켓팅이 오픈되지 않았으면 ForbiddenException을 던져야 한다', async () => {
7692
redisService.setNxWithTtl.mockResolvedValue(true);
7793
redisService.get.mockResolvedValue('false');
@@ -83,7 +99,7 @@ describe('ReservationService', () => {
8399

84100
it('유효하지 않은 블록이면 BadRequestException을 던져야 한다', async () => {
85101
redisService.setNxWithTtl.mockResolvedValue(true);
86-
redisService.get.mockResolvedValue('true'); // ticketing open
102+
redisService.get.mockResolvedValue('true');
87103
redisService.sismember.mockResolvedValue(false);
88104

89105
await expect(service.reserve(dto, userId)).rejects.toThrow(
@@ -101,13 +117,38 @@ describe('ReservationService', () => {
101117
});
102118
redisService.sismember.mockResolvedValue(true);
103119

104-
const invalidDto = { ...dto, row: 5 };
120+
const invalidDto = {
121+
session_id: 1,
122+
seats: [{ block_id: 10, row: 5, col: 0 }],
123+
};
105124
await expect(service.reserve(invalidDto, userId)).rejects.toThrow(
106125
BadRequestException,
107126
);
108127
});
109128

110-
it('이미 예약된 좌석이면 BadRequestException을 던져야 한다', async () => {
129+
it('요청에 중복된 좌석이 있으면 BadRequestException을 던져야 한다', async () => {
130+
const mockBlockData = JSON.stringify({ rowSize: 5, colSize: 5 });
131+
redisService.setNxWithTtl.mockResolvedValue(true);
132+
redisService.get.mockImplementation((key) => {
133+
if (key === 'is_ticketing_open') return Promise.resolve('true');
134+
if (key === 'block:10') return Promise.resolve(mockBlockData);
135+
return Promise.resolve(null);
136+
});
137+
redisService.sismember.mockResolvedValue(true);
138+
139+
const duplicateDto = {
140+
session_id: 1,
141+
seats: [
142+
{ block_id: 10, row: 1, col: 1 },
143+
{ block_id: 10, row: 1, col: 1 },
144+
],
145+
};
146+
await expect(service.reserve(duplicateDto, userId)).rejects.toThrow(
147+
BadRequestException,
148+
);
149+
});
150+
151+
it('일부 좌석이 이미 예약되어 있으면 BadRequestException을 던져야 한다', async () => {
111152
const mockBlockData = JSON.stringify({ rowSize: 2, colSize: 2 });
112153
redisService.setNxWithTtl.mockResolvedValue(true);
113154
redisService.get.mockImplementation((key) => {
@@ -116,7 +157,7 @@ describe('ReservationService', () => {
116157
return Promise.resolve(null);
117158
});
118159
redisService.sismember.mockResolvedValue(true);
119-
redisService.setNx.mockResolvedValue(false);
160+
redisService.msetnx.mockResolvedValue(0);
120161

121162
await expect(service.reserve(dto, userId)).rejects.toThrow(
122163
BadRequestException,
@@ -132,15 +173,47 @@ describe('ReservationService', () => {
132173
return Promise.resolve(null);
133174
});
134175
redisService.sismember.mockResolvedValue(true);
135-
redisService.setNx.mockResolvedValue(true);
136-
redisService.incr.mockResolvedValue(5); // Rank 5 발급
176+
redisService.msetnx.mockResolvedValue(1);
177+
redisService.incr.mockResolvedValue(5);
137178
redisService.publishToQueue.mockResolvedValue(undefined);
138179

139180
const result = await service.reserve(dto, userId);
140181

141182
expect(result.rank).toBe(5);
142-
expect(redisService.setNx).toHaveBeenCalled();
183+
expect(result.seats).toEqual(dto.seats);
184+
expect(redisService.msetnx).toHaveBeenCalled();
143185
expect(redisService.incr).toHaveBeenCalledWith('rank:session:1');
144186
});
187+
188+
it('여러 블록의 좌석을 동시에 예약할 수 있어야 한다', async () => {
189+
redisService.setNxWithTtl.mockResolvedValue(true);
190+
redisService.get.mockImplementation((key) => {
191+
if (key === 'is_ticketing_open') return Promise.resolve('true');
192+
if (key === 'block:10')
193+
return Promise.resolve(JSON.stringify({ rowSize: 5, colSize: 5 }));
194+
if (key === 'block:11')
195+
return Promise.resolve(JSON.stringify({ rowSize: 5, colSize: 5 }));
196+
return Promise.resolve(null);
197+
});
198+
redisService.sismember.mockResolvedValue(true);
199+
redisService.msetnx.mockResolvedValue(1);
200+
redisService.incr.mockResolvedValue(10);
201+
202+
const multiBlockDto = {
203+
session_id: 1,
204+
seats: [
205+
{ block_id: 10, row: 1, col: 1 },
206+
{ block_id: 11, row: 2, col: 2 },
207+
],
208+
};
209+
210+
const result = await service.reserve(multiBlockDto, userId);
211+
212+
expect(result.rank).toBe(10);
213+
expect(redisService.msetnx).toHaveBeenCalledWith({
214+
'reservation:session:1:block:10:row:1:col:1': userId,
215+
'reservation:session:1:block:11:row:2:col:2': userId,
216+
});
217+
});
145218
});
146219
});

0 commit comments

Comments
 (0)