Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/ticket-server/src/redis/redis.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('RedisService', () => {
sadd: jest.fn(),
sismember: jest.fn(),
mget: jest.fn(),
msetnx: jest.fn(),
disconnect: jest.fn(),
};

Expand Down Expand Up @@ -101,4 +102,18 @@ describe('RedisService', () => {
expect(jest.mocked(redisClient.mget!)).not.toHaveBeenCalled();
});
});

describe('msetnx', () => {
it('모든 키가 설정되면 true를 반환해야 한다', async () => {
jest.mocked(redisClient.msetnx!).mockResolvedValue(1);
const result = await service.msetnx({ k1: 'v1', k2: 'v2' });
expect(result).toBe(true);
});

it('하나의 키라도 존재하여 설정되지 않으면 false를 반환해야 한다', async () => {
jest.mocked(redisClient.msetnx!).mockResolvedValue(0);
const result = await service.msetnx({ k1: 'v1', k2: 'v2' });
expect(result).toBe(false);
});
});
});
5 changes: 5 additions & 0 deletions backend/ticket-server/src/redis/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export class RedisService implements OnModuleDestroy {
return result === 'OK';
}

async msetnx(kv: Record<string, string>): Promise<boolean> {
const result = await this.ticketClient.msetnx(...Object.entries(kv).flat());
return Number(result) === 1;
}

async set(key: string, value: string): Promise<string> {
return this.ticketClient.set(key, value);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber } from 'class-validator';

export class CreateReservationRequestDto {
@ApiProperty({ description: '공연 회차 ID', example: 1 })
@IsNumber()
@IsNotEmpty()
session_id: number;
import { IsArray, IsNotEmpty, IsNumber, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class Seat {
@ApiProperty({ description: '공연장 구역 ID', example: 1 })
@IsNumber()
@IsNotEmpty()
block_id: number;

@ApiProperty({ description: '좌석 행 번호', example: 1 })
@IsNumber()
@IsNotEmpty()
row: number;

@ApiProperty({ description: '좌석 열 번호', example: 5 })
@IsNumber()
@IsNotEmpty()
col: number;
}

export class CreateReservationRequestDto {
@ApiProperty({ description: '공연 회차 ID', example: 1 })
@IsNumber()
@IsNotEmpty()
session_id: number;

@ApiProperty({
description: '예약할 좌석 목록',
type: [Seat],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => Seat)
seats: Seat[];
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';

class Seat {
@ApiProperty({ description: '공연장 구역 ID', example: 1 })
block_id: number;

@ApiProperty({ description: '좌석 행 번호', example: 1 })
row: number;

@ApiProperty({ description: '좌석 열 번호', example: 5 })
col: number;
}

export class CreateReservationResponseDto {
@ApiProperty({
description: '해당 회차에서의 예약 순번 (랭킹)',
example: 123,
})
rank: number;

@ApiProperty({
description: '예약된 좌석 목록',
type: [Seat],
})
seats: Seat[];
}
91 changes: 82 additions & 9 deletions backend/ticket-server/src/reservation/reservation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import { ReservationService } from './reservation.service';
import { RedisService } from '../redis/redis.service';

Expand All @@ -20,6 +24,7 @@ describe('ReservationService', () => {
sismember: jest.fn(),
setNx: jest.fn(),
setNxWithTtl: jest.fn(),
msetnx: jest.fn(),
incr: jest.fn(),
publishToQueue: jest.fn(),
del: jest.fn(),
Expand Down Expand Up @@ -69,9 +74,20 @@ describe('ReservationService', () => {
});

describe('reserve', () => {
const dto = { session_id: 1, block_id: 10, row: 0, col: 0 };
const dto = {
session_id: 1,
seats: [{ block_id: 10, row: 0, col: 0 }],
};
const userId = 'user-1';

it('유저 락 획득에 실패하면 ConflictException을 던져야 한다', async () => {
redisService.setNxWithTtl.mockResolvedValue(false);

await expect(service.reserve(dto, userId)).rejects.toThrow(
ConflictException,
);
});

it('티켓팅이 오픈되지 않았으면 ForbiddenException을 던져야 한다', async () => {
redisService.setNxWithTtl.mockResolvedValue(true);
redisService.get.mockResolvedValue('false');
Expand All @@ -83,7 +99,7 @@ describe('ReservationService', () => {

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

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

const invalidDto = { ...dto, row: 5 };
const invalidDto = {
session_id: 1,
seats: [{ block_id: 10, row: 5, col: 0 }],
};
await expect(service.reserve(invalidDto, userId)).rejects.toThrow(
BadRequestException,
);
});

it('이미 예약된 좌석이면 BadRequestException을 던져야 한다', async () => {
it('요청에 중복된 좌석이 있으면 BadRequestException을 던져야 한다', async () => {
const mockBlockData = JSON.stringify({ rowSize: 5, colSize: 5 });
redisService.setNxWithTtl.mockResolvedValue(true);
redisService.get.mockImplementation((key) => {
if (key === 'is_ticketing_open') return Promise.resolve('true');
if (key === 'block:10') return Promise.resolve(mockBlockData);
return Promise.resolve(null);
});
redisService.sismember.mockResolvedValue(true);

const duplicateDto = {
session_id: 1,
seats: [
{ block_id: 10, row: 1, col: 1 },
{ block_id: 10, row: 1, col: 1 },
],
};
await expect(service.reserve(duplicateDto, userId)).rejects.toThrow(
BadRequestException,
);
});

it('일부 좌석이 이미 예약되어 있으면 BadRequestException을 던져야 한다', async () => {
const mockBlockData = JSON.stringify({ rowSize: 2, colSize: 2 });
redisService.setNxWithTtl.mockResolvedValue(true);
redisService.get.mockImplementation((key) => {
Expand All @@ -116,7 +157,7 @@ describe('ReservationService', () => {
return Promise.resolve(null);
});
redisService.sismember.mockResolvedValue(true);
redisService.setNx.mockResolvedValue(false);
redisService.msetnx.mockResolvedValue(0);

await expect(service.reserve(dto, userId)).rejects.toThrow(
BadRequestException,
Expand All @@ -132,15 +173,47 @@ describe('ReservationService', () => {
return Promise.resolve(null);
});
redisService.sismember.mockResolvedValue(true);
redisService.setNx.mockResolvedValue(true);
redisService.incr.mockResolvedValue(5); // Rank 5 발급
redisService.msetnx.mockResolvedValue(1);
redisService.incr.mockResolvedValue(5);
redisService.publishToQueue.mockResolvedValue(undefined);

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

expect(result.rank).toBe(5);
expect(redisService.setNx).toHaveBeenCalled();
expect(result.seats).toEqual(dto.seats);
expect(redisService.msetnx).toHaveBeenCalled();
expect(redisService.incr).toHaveBeenCalledWith('rank:session:1');
});

it('여러 블록의 좌석을 동시에 예약할 수 있어야 한다', async () => {
redisService.setNxWithTtl.mockResolvedValue(true);
redisService.get.mockImplementation((key) => {
if (key === 'is_ticketing_open') return Promise.resolve('true');
if (key === 'block:10')
return Promise.resolve(JSON.stringify({ rowSize: 5, colSize: 5 }));
if (key === 'block:11')
return Promise.resolve(JSON.stringify({ rowSize: 5, colSize: 5 }));
return Promise.resolve(null);
});
redisService.sismember.mockResolvedValue(true);
redisService.msetnx.mockResolvedValue(1);
redisService.incr.mockResolvedValue(10);

const multiBlockDto = {
session_id: 1,
seats: [
{ block_id: 10, row: 1, col: 1 },
{ block_id: 11, row: 2, col: 2 },
],
};

const result = await service.reserve(multiBlockDto, userId);

expect(result.rank).toBe(10);
expect(redisService.msetnx).toHaveBeenCalledWith({
'reservation:session:1:block:10:row:1:col:1': userId,
'reservation:session:1:block:11:row:2:col:2': userId,
});
});
});
});
Loading