Compare commits

...

25 Commits

Author SHA1 Message Date
329139b87c fix: dockerfile 오타 수정(gradle -> ./gradlew) 2025-08-04 14:56:03 +09:00
1172a685fc feat: forward-header 설정 추가 2025-08-04 14:55:35 +09:00
7a79ad68d6 remove: CORS 설정 제거 및 프론트엔드 API 엔드포인트 수정 2025-08-04 14:55:23 +09:00
b2ff133273 fix: JWT TTL 시간 오류 수정 2025-08-04 14:52:38 +09:00
71a106cde5 refactor: 스키마 auditing 변수 타입 수정(-> datetime(6)) 2025-08-04 14:51:44 +09:00
15b9ef4a08 refactor: CORS origin 경로 설정 추가 2025-08-03 20:48:34 +09:00
cfd35b875b feat: application-deploy에 Tracing 옵션 추가 2025-08-02 16:32:34 +09:00
a49d36ed34 feat: 베포 환경용 logback 설정 추가 2025-08-02 16:21:42 +09:00
992ac4232c feat: 배포 환경용 application-deploy 및 Dockerfile 추가 2025-08-02 15:58:35 +09:00
3f5af93817 feat: 2af09231 커밋 내용 추가 2025-08-02 15:57:32 +09:00
612bbfbddc test: 엔티티 변경 사항 테스트 반영 2025-08-02 15:56:31 +09:00
c0c2ef21c6 refactor: 엔티티 변경 사항 Service 반영 2025-08-02 15:55:56 +09:00
8a126344f0 refactor: 모든 엔티티에 BaseEntity 및 TSID 적용 2025-08-02 15:53:25 +09:00
769576a8d5 feat: Auditing 및 Persist 설정이 담긴 BaseEntity 추가 2025-08-02 15:52:44 +09:00
d2e2c9c888 refactor: PK 생성 방식 수정(Auto-Generated -> TSID) 2025-08-02 15:51:11 +09:00
2af0923189 refactor: MDCAwareSlowQueryListenerWithoutParams 에서 변환, 검증로직 클래스 분리 및 테스트 추가 2025-08-02 15:49:52 +09:00
d293b56b0f feat: 스키마 파일 추가 및 ddl-auto validate 수정 2025-08-02 15:49:25 +09:00
9e86222d5b refactor: 테스트 전용 설정 분리 2025-08-02 15:49:01 +09:00
e5002ae540 feat: mysql gradle 의존성 추가 2025-08-02 13:53:12 +09:00
9a38d8beb8 remove: 초기 데이터 삽입용 sql 제거 -> 코드로 삽입하도록 수정 예정 2025-08-02 13:52:49 +09:00
b986a20004 refactor: 테스트 및 로컬의 application.yaml 설정 수정 2025-08-02 13:52:26 +09:00
6db8730764 refactor: ReservationEntity의 not null 컬럼 지정 및 reservationStatus -> status 컬럼명 수정 2025-07-31 16:52:51 +09:00
d96c890dc0 feat: 배포 환경에서의 Slow Query 로깅을 위한 설정 추가 2025-07-31 16:49:08 +09:00
0c83798b3f feat: frontend 이미지 생성을 위한 Dockerfile 및 nginx 설정 추가 2025-07-30 15:45:35 +09:00
526598a24f fix: frontend에서의 빌드 오류 해결 2025-07-30 15:45:00 +09:00
64 changed files with 927 additions and 480 deletions

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootjar --no-daemon
FROM amazoncorretto:17
WORKDIR /app
EXPOSE 8080
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@ -39,7 +39,9 @@ dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6")

6
frontend/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.git
.DS_Store
npm-debug.log
dist
build

View File

@ -1 +1 @@
VITE_API_BASE_URL = "http://localhost:8080"
VITE_API_BASE_URL = '/api'

18
frontend/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
# Stage 1: Build the React app
FROM node:24 AS builder
WORKDIR /app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --frozen-lockfile
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
frontend/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -1,7 +1,7 @@
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
});

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import Navbar from './Navbar';
interface LayoutProps {

View File

@ -1,5 +1,4 @@
import { checkLogin } from '@_api/auth/authAPI';
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext';

View File

@ -1,13 +1,13 @@
import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react';
import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI';
import type { LoginRequest, LoginCheckResponse } from '@_api/auth/authTypes';
import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes';
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
interface AuthContextType {
loggedIn: boolean;
userName: string | null;
role: 'ADMIN' | 'MEMBER' | null;
loading: boolean; // Add loading state to type
login: (data: LoginRequest) => Promise<LoginCheckResponse>;
login: (data: LoginRequest) => Promise<LoginResponse>;
logout: () => Promise<void>;
checkLogin: () => Promise<void>;
}

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps {

View File

@ -8,4 +8,19 @@ export default defineConfig({
react(),
tsconfigPaths(),
],
server: {
proxy: {
'/api': {
// 실제 백엔드 서버 주소로 전달
target: 'http://localhost:8080',
// Origin 헤더를 target의 Origin으로 변경 (CORS 에러 방지)
changeOrigin: true,
// Ingress의 rewrite-target과 동일한 역할.
// '/api/themes' -> '/themes'로 경로를 재작성하여 백엔드에 전달
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})

View File

@ -3,7 +3,9 @@ package roomescape
import org.springframework.boot.Banner
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
@EnableJpaAuditing
@SpringBootApplication
class RoomescapeApplication

View File

@ -22,7 +22,7 @@ class JwtHandler(
fun createToken(memberId: Long): String {
val date = Date()
val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds)
val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000))
return Jwts.builder()
.claim(MEMBER_ID_CLAIM_KEY, memberId)

View File

@ -1,16 +0,0 @@
package roomescape.common.config
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class CorsConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type")
.maxAge(3600) // 1 hour
}
}

View File

@ -0,0 +1,17 @@
package roomescape.common.config
import com.github.f4b6a3.tsid.TsidFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class TsidConfig {
@Value("\${POD_NAME:app-0}")
private lateinit var podName: String
@Bean
fun tsidFactory(): TsidFactory = TsidFactory(podName.substringAfterLast("-").toInt())
}
fun TsidFactory.next(): Long = this.create().toLong()

View File

@ -0,0 +1,35 @@
package roomescape.common.entity
import jakarta.persistence.*
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.domain.Persistable
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import kotlin.jvm.Transient
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class BaseEntity(
@Column(updatable = false)
@CreatedDate
var createdAt: LocalDateTime? = null,
@LastModifiedDate
var lastModifiedAt: LocalDateTime? = null,
) : Persistable<Long> {
@Transient
private var isNewEntity: Boolean = true
@PostLoad
@PostPersist
fun markNotNew() {
isNewEntity = false
}
override fun isNew(): Boolean = isNewEntity
abstract override fun getId(): Long?
}

View File

@ -0,0 +1,41 @@
package roomescape.common.log
import net.ttddyy.dsproxy.ExecutionInfo
import net.ttddyy.dsproxy.QueryInfo
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.listener.logging.SLF4JQueryLoggingListener
import java.util.function.Predicate
class MDCAwareSlowQueryListenerWithoutParams : SLF4JQueryLoggingListener {
private val slowQueryPredicate: SlowQueryPredicate
private val sqlLogFormatter: SqlLogFormatter
constructor(logLevel: SLF4JLogLevel, thresholdMs: Long) {
this.logLevel = logLevel
this.slowQueryPredicate = SlowQueryPredicate(thresholdMs)
this.sqlLogFormatter = SqlLogFormatter()
}
override fun afterQuery(
execInfo: ExecutionInfo,
queryInfoList: List<QueryInfo>
) {
if (slowQueryPredicate.test(execInfo.elapsedTime)) {
super.afterQuery(execInfo, queryInfoList)
}
}
override fun writeLog(message: String) {
super.writeLog(sqlLogFormatter.maskParams(message))
}
}
class SqlLogFormatter {
fun maskParams(message: String) = message.replace(Regex("""(,?\s*)Params:\[.*?]"""), "")
}
class SlowQueryPredicate(
private val thresholdMs: Long
) : Predicate<Long> {
override fun test(t: Long): Boolean = (t >= thresholdMs)
}

View File

@ -0,0 +1,49 @@
package roomescape.common.log
import com.zaxxer.hikari.HikariDataSource
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.context.annotation.Profile
import javax.sql.DataSource
@Configuration
@Profile("deploy")
@EnableConfigurationProperties(SlowQueryProperties::class)
class ProxyDataSourceConfig {
@Bean
@Primary
fun dataSource(
@Qualifier("actualDataSource") actualDataSource: DataSource,
properties: SlowQueryProperties
): DataSource = ProxyDataSourceBuilder.create(actualDataSource)
.name(properties.loggerName)
.listener(
MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(properties.logLevel.uppercase()),
thresholdMs = properties.thresholdMs
)
)
.buildProxy()
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari")
fun actualDataSource(): DataSource = DataSourceBuilder.create()
.type(HikariDataSource::class.java)
.build()
}
@Profile("deploy")
@ConfigurationProperties(prefix = "slow-query")
data class SlowQueryProperties(
val loggerName: String,
val logLevel: String,
val thresholdMs: Long,
)

View File

@ -36,7 +36,7 @@ class RoomescapeLogMaskingConverter : MessageConverter() {
private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^,\\s]+)")
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)")
return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1]

View File

@ -1,9 +1,11 @@
package roomescape.member.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.member.exception.MemberErrorCode
import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity
@ -16,6 +18,7 @@ private val log = KotlinLogging.logger {}
@Service
@Transactional(readOnly = true)
class MemberService(
private val tsidFactory: TsidFactory,
private val memberRepository: MemberRepository,
) {
fun findMembers(): MemberRetrieveListResponse {
@ -46,6 +49,7 @@ class MemberService(
}
val member = MemberEntity(
_id = tsidFactory.next(),
name = request.name,
email = request.email,
password = request.password,

View File

@ -1,20 +1,30 @@
package roomescape.member.infrastructure.persistence
import jakarta.persistence.*
import roomescape.common.entity.BaseEntity
@Entity
@Table(name = "members")
class MemberEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "member_id")
private var _id: Long?,
@Column(name = "name", nullable = false)
var name: String,
@Column(name = "email", nullable = false)
var email: String,
@Column(name = "password", nullable = false)
var password: String,
@Column(name = "role", nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
var role: Role
) {
): BaseEntity() {
override fun getId(): Long? = _id
fun isAdmin(): Boolean = role == Role.ADMIN
}

View File

@ -1,8 +1,10 @@
package roomescape.payment.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentApproveResponse
@ -21,6 +23,7 @@ private val log = KotlinLogging.logger {}
@Service
class PaymentService(
private val tsidFactory: TsidFactory,
private val paymentRepository: PaymentRepository,
private val canceledPaymentRepository: CanceledPaymentRepository,
) {
@ -31,6 +34,7 @@ class PaymentService(
): PaymentCreateResponse {
log.debug { "[PaymentService.createPayment] 결제 정보 저장 시작: request=$approveResponse, reservationId=${reservation.id}" }
val payment = PaymentEntity(
_id = tsidFactory.next(),
orderId = approveResponse.orderId,
paymentKey = approveResponse.paymentKey,
totalAmount = approveResponse.totalAmount,
@ -62,6 +66,7 @@ class PaymentService(
", cancelInfo=$cancelInfo"
}
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelInfo.cancelReason,
cancelAmount = cancelInfo.cancelAmount,
@ -110,6 +115,7 @@ class PaymentService(
}
val canceledPayment = CanceledPaymentEntity(
_id = tsidFactory.next(),
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = payment.totalAmount,

View File

@ -1,18 +1,33 @@
package roomescape.payment.infrastructure.persistence
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity
import java.time.OffsetDateTime
@Entity
@Table(name = "canceled_payments")
class CanceledPaymentEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "canceled_payment_id")
private var _id: Long?,
@Column(name = "payment_key", nullable = false)
var paymentKey: String,
@Column(name = "cancel_reason", nullable = false)
var cancelReason: String,
@Column(name = "cancel_amount", nullable = false)
var cancelAmount: Long,
@Column(name = "approved_at", nullable = false)
var approvedAt: OffsetDateTime,
@Column(name = "canceled_at", nullable = false)
var canceledAt: OffsetDateTime,
)
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -1,6 +1,7 @@
package roomescape.payment.infrastructure.persistence
import jakarta.persistence.*
import roomescape.common.entity.BaseEntity
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import java.time.OffsetDateTime
@ -8,22 +9,24 @@ import java.time.OffsetDateTime
@Table(name = "payments")
class PaymentEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "payment_id")
private var _id: Long?,
@Column(nullable = false)
@Column(name = "order_id", nullable = false)
var orderId: String,
@Column(nullable = false)
@Column(name="payment_key", nullable = false)
var paymentKey: String,
@Column(nullable = false)
@Column(name="total_amount", nullable = false)
var totalAmount: Long,
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reservation_id", nullable = false)
var reservation: ReservationEntity,
@Column(nullable = false)
@Column(name="approved_at", nullable = false)
var approvedAt: OffsetDateTime
)
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -1,10 +1,12 @@
package roomescape.reservation.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.exception.ReservationErrorCode
@ -26,6 +28,7 @@ private val log = KotlinLogging.logger {}
@Service
@Transactional
class ReservationService(
private val tsidFactory: TsidFactory,
private val reservationRepository: ReservationRepository,
private val timeService: TimeService,
private val memberService: MemberService,
@ -79,7 +82,7 @@ class ReservationService(
createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED)
return reservationRepository.save(reservation)
.also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.reservationStatus}" } }
.also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } }
}
fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse {
@ -188,11 +191,12 @@ class ReservationService(
validateDateAndTime(date, time)
return ReservationEntity(
_id = tsidFactory.next(),
date = date,
time = time,
theme = theme,
member = member,
reservationStatus = status
status = status
)
}
@ -259,7 +263,7 @@ class ReservationService(
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" +
", currentStatus=${reservation.reservationStatus} memberId=$memberId"
", currentStatus=${reservation.status} memberId=$memberId"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}
@ -284,7 +288,7 @@ class ReservationService(
if (!reservation.isWaiting()) {
log.warn {
"[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" +
", status=${reservation.reservationStatus}"
", status=${reservation.status}"
}
throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
}

View File

@ -2,6 +2,7 @@ package roomescape.reservation.infrastructure.persistence
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.persistence.*
import roomescape.common.entity.BaseEntity
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
@ -11,9 +12,10 @@ import java.time.LocalDate
@Table(name = "reservations")
class ReservationEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "reservation_id")
private var _id: Long?,
@Column(name = "date", nullable = false)
var date: LocalDate,
@ManyToOne(fetch = FetchType.LAZY)
@ -29,10 +31,13 @@ class ReservationEntity(
var member: MemberEntity,
@Enumerated(value = EnumType.STRING)
var reservationStatus: ReservationStatus
) {
@Column(name = "status", nullable = false, length = 30)
var status: ReservationStatus,
): BaseEntity() {
override fun getId(): Long? = _id
@JsonIgnore
fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING
fun isWaiting(): Boolean = status == ReservationStatus.WAITING
@JsonIgnore
fun isReservedBy(memberId: Long): Boolean {

View File

@ -19,7 +19,7 @@ interface ReservationRepository
@Query(
"""
UPDATE ReservationEntity r
SET r.reservationStatus = :status
SET r.status = :status
WHERE r.id = :id
"""
)
@ -39,7 +39,7 @@ interface ReservationRepository
WHERE r.theme.id = r2.theme.id
AND r.time.id = r2.time.id
AND r.date = r2.date
AND r.reservationStatus != 'WAITING'
AND r.status != 'WAITING'
)
)
"""
@ -53,7 +53,7 @@ interface ReservationRepository
t.name,
r.date,
r.time.startAt,
r.reservationStatus,
r.status,
(SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2.id < r.id),
p.paymentKey,
p.totalAmount

View File

@ -36,11 +36,11 @@ class ReservationSearchSpecification(
fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.or(
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED
),
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
root.get<ReservationStatus>("status"),
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
)
@ -48,7 +48,7 @@ class ReservationSearchSpecification(
fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb ->
cb.equal(
root.get<ReservationStatus>("reservationStatus"),
root.get<ReservationStatus>("status"),
ReservationStatus.WAITING
)
}

View File

@ -54,7 +54,7 @@ fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = Reserv
member = this.member.toRetrieveResponse(),
time = this.time.toCreateResponse(),
theme = this.theme.toResponse(),
status = this.reservationStatus
status = this.status
)
data class ReservationRetrieveListResponse(

View File

@ -1,20 +1,26 @@
package roomescape.theme.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.*
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toResponse
import java.time.LocalDate
private val log = KotlinLogging.logger {}
@Service
class ThemeService(
private val tsidFactory: TsidFactory,
private val themeRepository: ThemeRepository,
) {
@Transactional(readOnly = true)
@ -60,7 +66,12 @@ class ThemeService(
throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
}
val theme: ThemeEntity = request.toEntity()
val theme = ThemeEntity(
_id = tsidFactory.next(),
name = request.name,
description = request.description,
thumbnail = request.thumbnail
)
return themeRepository.save(theme)
.also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } }
.toResponse()

View File

@ -1,15 +1,26 @@
package roomescape.theme.infrastructure.persistence
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity
@Entity
@Table(name = "themes")
class ThemeEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(name = "theme_id")
private var _id: Long?,
@Column(name = "name", nullable = false)
var name: String,
@Column(name = "description", nullable = false)
var description: String,
var thumbnail: String
)
@Column(name = "thumbnail", nullable = false)
var thumbnail: String,
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -21,12 +21,6 @@ data class ThemeCreateRequest(
val thumbnail: String
)
fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity(
name = this.name,
description = this.description,
thumbnail = this.thumbnail
)
data class ThemeRetrieveResponse(
val id: Long,
val name: String,

View File

@ -1,9 +1,11 @@
package roomescape.time.business
import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.config.next
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.time.exception.TimeErrorCode
@ -18,6 +20,7 @@ private val log = KotlinLogging.logger {}
@Service
class TimeService(
private val tsidFactory: TsidFactory,
private val timeRepository: TimeRepository,
private val reservationRepository: ReservationRepository,
) {
@ -50,7 +53,10 @@ class TimeService(
throw TimeException(TimeErrorCode.TIME_DUPLICATED)
}
val time: TimeEntity = request.toEntity()
val time = TimeEntity(
_id = tsidFactory.next(),
startAt = request.startAt
)
return timeRepository.save(time)
.also { log.info { "[TimeService.createTime] 시간 생성 완료: timeId=${it.id}" } }
.toCreateResponse()

View File

@ -1,13 +1,21 @@
package roomescape.time.infrastructure.persistence
import jakarta.persistence.*
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import roomescape.common.entity.BaseEntity
import java.time.LocalTime
@Entity
@Table(name = "times")
class TimeEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
var startAt: LocalTime
)
@Column(name = "time_id")
private var _id: Long?,
@Column(name = "start_at", nullable = false)
var startAt: LocalTime,
): BaseEntity() {
override fun getId(): Long? = _id
}

View File

@ -10,8 +10,6 @@ data class TimeCreateRequest(
val startAt: LocalTime
)
fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
data class TimeCreateResponse(
@Schema(description = "시간 식별자")

View File

@ -0,0 +1,42 @@
server:
forward-headers-strategy: framework
spring:
sql:
init:
schema-locations: classpath:schema/schema-mysql.sql
jpa:
defer-datasource-initialization: false
hibernate:
ddl-auto: validate
datasource:
hikari:
driver-class-name: ${DATASOURCE_DRIVER_CLASS_NAME}
jdbc-url: ${DATASOURCE_URL}
username: ${DATASOURCE_USERNAME}
password: ${DATASOURCE_PASSWORD}
security:
jwt:
token:
secret-key: ${JWT_SECRET_KEY}
ttl-seconds: ${JWT_TOKEN_TTL_SECONDS}
payment:
confirm-secret-key: ${TOSS_SECRET_KEY}
read-timeout: ${PAYMENT_CLIENT_READ_TIMEOUT}
connect-timeout: ${PAYMENT_CLIENT_CONNECT_TIMEOUT}
slow-query:
logger-name: ${SLOW_QUERY_LOGGER}
log-level: ${SLOW_QUERY_LOG_LEVEL}
threshold_ms: ${SLOW_QUERY_LOGGING_THRESHOLD}
management:
tracing:
sampling:
probability: ${TRACE_SAMPLING_PROBABILITY}
otlp:
tracing:
transport: ${OTLP_TRACING_PROTOCOL}
endpoint: ${OTLP_TRACING_ENDPOINT}

View File

@ -1,27 +1,29 @@
spring:
jpa:
show-sql: false
properties:
hibernate:
format_sql: true
ddl-auto: create-drop
defer-datasource-initialization: true
hibernate:
ddl-auto: validate
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:database
username: sa
password:
sql:
init:
schema-locations: classpath:schema/schema-h2.sql
security:
jwt:
token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
ttl-seconds: 1800000
ttl-seconds: 1800
payment:
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
@ -35,7 +37,7 @@ jdbc:
query:
enable-logging: true
log-level: DEBUG
logger-name: query-logger
logger-name: all-query-logger
multiline: true
includes: connection,query,keys,fetch

View File

@ -1,68 +0,0 @@
insert into times(start_at)
values ('15:00');
insert into times(start_at)
values ('16:00');
insert into times(start_at)
values ('17:00');
insert into times(start_at)
values ('18:00');
insert into themes(name, description, thumbnail)
values ('테스트1', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into themes(name, description, thumbnail)
values ('테스트2', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into themes(name, description, thumbnail)
values ('테스트3', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into themes(name, description, thumbnail)
values ('테스트4', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
insert into members(name, email, password, role)
values ('어드민', 'a@a.a', 'a', 'ADMIN');
insert into members(name, email, password, role)
values ('1호', '1@1.1', '1', 'MEMBER');
insert into members(name, email, password, role)
values ('2호', '2@2.2', '2', 'MEMBER');
insert into members(name, email, password, role)
values ('3호', '3@3.3', '3', 'MEMBER');
insert into members(name, email, password, role)
values ('4호', '4@4.4', '4', 'MEMBER');
-- 예약: 결제 완료
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (1, DATEADD('DAY', -1, CURRENT_DATE()) - 1, 1, 1, 'CONFIRMED');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (2, DATEADD('DAY', -2, CURRENT_DATE()) - 2, 3, 2, 'CONFIRMED');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (3, DATEADD('DAY', -3, CURRENT_DATE()), 2, 2, 'CONFIRMED');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (4, DATEADD('DAY', -4, CURRENT_DATE()), 1, 2, 'CONFIRMED');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (5, DATEADD('DAY', -5, CURRENT_DATE()), 1, 3, 'CONFIRMED');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (2, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'CONFIRMED');
-- 예약: 결제 대기
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (2, DATEADD('DAY', 8, CURRENT_DATE()), 2, 4, 'CONFIRMED_PAYMENT_REQUIRED');
-- 예약 대기
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (3, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (4, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
insert into reservations(member_id, date, time_id, theme_id, reservation_status)
values (5, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
-- 결제 정보
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-1', 'paymentKey-1', 10000, 1, CURRENT_DATE);
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-2', 'paymentKey-2', 20000, 2, CURRENT_DATE);
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-3', 'paymentKey-3', 30000, 3, CURRENT_DATE);
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-4', 'paymentKey-4', 40000, 4, CURRENT_DATE);
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-5', 'paymentKey-5', 50000, 5, CURRENT_DATE);
insert into payments(order_id, payment_key, total_amount, reservation_id, approved_at)
values ('orderId-6', 'paymentKey-6', 60000, 6, CURRENT_DATE);

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<included>
<conversionRule conversionWord="maskedMessage"
class="roomescape.common.log.RoomescapeLogMaskingConverter"/>
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<mdc/>
<pattern>
<pattern>
{
"message": "%maskedMessage"
}
</pattern>
</pattern>
<stackTrace>
<fieldName>stack_trace</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>5</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<rootCauseFirst>true</rootCauseFirst>
</throwableConverter>
</stackTrace>
</providers>
</encoder>
</appender>
<appender name="FILE_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/application-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>3</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<mdc/>
<pattern>
<pattern>
{
"message": "%maskedMessage"
}
</pattern>
</pattern>
<stackTrace>
<fieldName>stack_trace</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>5</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<rootCauseFirst>true</rootCauseFirst>
</throwableConverter>
</stackTrace>
</providers>
</encoder>
</appender>
<appender name="ASYNC_FILE_JSON" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE_JSON"/>
<queueSize>512</queueSize>
<discardingThreshold>0</discardingThreshold> <includeCallerData>false</includeCallerData>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE_JSON"/>
<appender-ref ref="ASYNC_FILE_JSON"/>
</root>
</included>

View File

@ -20,7 +20,7 @@
<appender-ref ref="CONSOLE"/>
</logger>
<logger name="query-logger" level="debug" additivity="false">
<logger name="all-query-logger" level="debug" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
</included>

View File

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<springProfile name="deploy">
<include resource="logback-deploy.xml"/>
</springProfile>
<springProfile name="local">
<include resource="logback-local.xml"/>
</springProfile>

View File

@ -0,0 +1,63 @@
create table if not exists members (
member_id bigint primary key,
email varchar(255) not null,
name varchar(255) not null,
password varchar(255) not null,
role varchar(20) not null,
created_at timestamp null,
last_modified_at timestamp null
);
create table if not exists themes (
theme_id bigint primary key,
description varchar(255) not null,
name varchar(255) not null,
thumbnail varchar(255) not null,
created_at timestamp null,
last_modified_at timestamp null
);
create table if not exists times (
time_id bigint primary key,
start_at time not null,
created_at timestamp null,
last_modified_at timestamp null
);
create table if not exists reservations (
reservation_id bigint primary key,
date date not null,
member_id bigint not null,
theme_id bigint not null,
time_id bigint not null,
status varchar(30) not null,
created_at timestamp null,
last_modified_at timestamp null,
constraint fk_reservations__themeId foreign key (theme_id) references themes (theme_id),
constraint fk_reservations__memberId foreign key (member_id) references members (member_id),
constraint fk_reservations__timeId foreign key (time_id) references times (time_id)
);
create table if not exists payments (
payment_id bigint primary key,
approved_at timestamp not null,
reservation_id bigint not null,
total_amount bigint not null,
order_id varchar(255) not null,
payment_key varchar(255) not null,
created_at timestamp null,
last_modified_at timestamp null,
constraint uk_payments__reservationId unique (reservation_id),
constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id)
);
create table if not exists canceled_payments (
canceled_payment_id bigint primary key,
payment_key varchar(255) not null,
cancel_reason varchar(255) not null,
cancel_amount bigint not null,
approved_at timestamp not null,
canceled_at timestamp not null,
created_at timestamp null,
last_modified_at timestamp null
);

View File

@ -0,0 +1,69 @@
create table if not exists members
(
member_id bigint primary key,
email varchar(255) not null,
name varchar(255) not null,
password varchar(255) not null,
role varchar(20) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null
);
create table if not exists themes
(
theme_id bigint primary key,
description varchar(255) not null,
name varchar(255) not null,
thumbnail varchar(255) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null
);
create table if not exists times
(
time_id bigint primary key,
start_at time(6) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null
);
create table if not exists reservations
(
reservation_id bigint primary key,
date date not null,
member_id bigint not null,
theme_id bigint not null,
time_id bigint not null,
status varchar(30) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null,
constraint fk_reservations__themeId foreign key (theme_id) references themes (theme_id),
constraint fk_reservations__memberId foreign key (member_id) references members (member_id),
constraint fk_reservations__timeId foreign key (time_id) references times (time_id)
);
create table if not exists payments
(
payment_id bigint primary key,
approved_at datetime(6) not null,
reservation_id bigint not null,
total_amount bigint not null,
order_id varchar(255) not null,
payment_key varchar(255) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null,
constraint uk_payments__reservationId unique (reservation_id),
constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id)
);
create table if not exists canceled_payments
(
canceled_payment_id bigint primary key,
payment_key varchar(255) not null,
cancel_reason varchar(255) not null,
cancel_amount bigint not null,
approved_at datetime(6) not null,
canceled_at datetime(6) not null,
created_at datetime(6) null,
last_modified_at datetime(6) null
);

View File

@ -15,10 +15,11 @@ import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.JwtFixture
import roomescape.util.MemberFixture
import roomescape.util.TsidFactory
class AuthServiceTest : BehaviorSpec({
val memberRepository: MemberRepository = mockk()
val memberService: MemberService = MemberService(memberRepository)
val memberService = MemberService(TsidFactory, memberRepository)
val jwtHandler: JwtHandler = JwtFixture.create()
val authService = AuthService(memberService, jwtHandler)

View File

@ -0,0 +1,26 @@
package roomescape.common.log
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
"SQL 메시지에서 Params 항목은 가린다." {
val message = """Query:["select * from members m where m.email=?"], Params:[(a@a.a)]"""
val expected = """Query:["select * from members m where m.email=?"]"""
val result = SqlLogFormatter().maskParams(message)
result shouldBe expected
}
"입력된 thresholdMs 보다 소요시간이 긴 쿼리를 기록한다." {
val slowQueryThreshold = 10L
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
assertSoftly(slowQueryPredicate) {
it.test(slowQueryThreshold) shouldBe true
it.test(slowQueryThreshold + 1) shouldBe true
it.test(slowQueryThreshold - 1) shouldBe false
}
}
})

View File

@ -14,13 +14,14 @@ import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.payment.web.PaymentCancelRequest
import roomescape.util.PaymentFixture
import roomescape.util.TsidFactory
import java.time.OffsetDateTime
class PaymentServiceTest : FunSpec({
val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentService = PaymentService(paymentRepository, canceledPaymentRepository)
val paymentService = PaymentService(TsidFactory, paymentRepository, canceledPaymentRepository)
context("createCanceledPaymentByReservationId") {
val reservationId = 1L

View File

@ -1,11 +1,13 @@
package roomescape.payment.infrastructure.client
import com.ninjasquad.springmockk.MockkBean
import io.kotest.assertions.assertSoftly
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
@ -20,6 +22,7 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
@RestClientTest(TossPaymentClient::class)
@MockkBean(JpaMetamodelMappingContext::class)
class TossPaymentClientTest(
@Autowired val client: TossPaymentClient,
@Autowired val mockServer: MockRestServiceServer

View File

@ -5,10 +5,12 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.common.config.next
import roomescape.util.PaymentFixture
import roomescape.util.TsidFactory
import java.util.*
@DataJpaTest
@DataJpaTest(showSql = false)
class CanceledPaymentRepositoryTest(
@Autowired val canceledPaymentRepository: CanceledPaymentRepository,
) : FunSpec() {
@ -16,7 +18,7 @@ class CanceledPaymentRepositoryTest(
context("paymentKey로 CanceledPaymentEntity 조회") {
val paymentKey = "test-payment-key"
beforeTest {
PaymentFixture.createCanceled(paymentKey = paymentKey)
PaymentFixture.createCanceled(id = TsidFactory.next(), paymentKey = paymentKey)
.also { canceledPaymentRepository.save(it) }
}

View File

@ -6,11 +6,13 @@ import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.common.config.next
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.util.PaymentFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
@DataJpaTest
@DataJpaTest(showSql = false)
class PaymentRepositoryTest(
@Autowired val paymentRepository: PaymentRepository,
@Autowired val entityManager: EntityManager
@ -91,7 +93,9 @@ class PaymentRepositoryTest(
}
private fun setupReservation(): ReservationEntity {
return ReservationFixture.create().also {
return ReservationFixture.create(
id = TsidFactory.next()
).also {
entityManager.persist(it.member)
entityManager.persist(it.theme)
entityManager.persist(it.time)

View File

@ -16,6 +16,7 @@ import roomescape.theme.business.ThemeService
import roomescape.time.business.TimeService
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.TsidFactory
import roomescape.util.TimeFixture
import java.time.LocalDate
import java.time.LocalTime
@ -27,6 +28,7 @@ class ReservationServiceTest : FunSpec({
val memberService: MemberService = mockk()
val themeService: ThemeService = mockk()
val reservationService = ReservationService(
TsidFactory,
reservationRepository,
timeService,
memberService,

View File

@ -15,7 +15,7 @@ import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
@DataJpaTest
@DataJpaTest(showSql = false)
class ReservationRepositoryTest(
val entityManager: EntityManager,
val reservationRepository: ReservationRepository,
@ -106,7 +106,7 @@ class ReservationRepositoryTest(
entityManager.clear()
reservationRepository.findByIdOrNull(reservationId)?.also { updated ->
updated.reservationStatus shouldBe it
updated.status shouldBe it
}
}
}

View File

@ -15,7 +15,7 @@ import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture
import java.time.LocalDate
@DataJpaTest
@DataJpaTest(showSql = false)
class ReservationSearchSpecificationTest(
val entityManager: EntityManager,
val reservationRepository: ReservationRepository

View File

@ -39,7 +39,7 @@ import java.time.LocalTime
class ReservationControllerTest(
@LocalServerPort val port: Int,
val entityManager: EntityManager,
val transactionTemplate: TransactionTemplate
val transactionTemplate: TransactionTemplate,
) : FunSpec({
extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_EACH_TEST))
}) {
@ -55,24 +55,34 @@ class ReservationControllerTest(
@MockkBean
lateinit var jwtHandler: JwtHandler
lateinit var testDataHelper: TestDataHelper
fun login(member: MemberEntity) {
every { jwtHandler.getMemberIdFromToken(any()) } returns member.id!!
every { memberService.findById(member.id!!) } returns member
every { memberIdResolver.resolveArgument(any(), any(), any(), any()) } returns member.id!!
}
init {
beforeSpec {
testDataHelper = TestDataHelper(entityManager, transactionTemplate)
}
context("POST /reservations") {
lateinit var member: MemberEntity
beforeTest {
member = login(MemberFixture.create(role = Role.MEMBER))
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
}
test("정상 응답") {
val reservationRequest = createRequest()
val reservationRequest = testDataHelper.createReservationRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every {
paymentClient.confirm(any())
} returns paymentApproveResponse
every { paymentClient.confirm(any()) } returns paymentApproveResponse
Given {
port(port)
@ -88,12 +98,10 @@ class ReservationControllerTest(
}
test("결제 과정에서 발생하는 에러는 그대로 응답") {
val reservationRequest = createRequest()
val reservationRequest = testDataHelper.createReservationRequest()
val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
every {
paymentClient.confirm(any())
} throws paymentException
every { paymentClient.confirm(any()) } throws paymentException
Given {
port(port)
@ -108,24 +116,20 @@ class ReservationControllerTest(
}
test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답을 받는다.") {
val reservationRequest = createRequest()
val reservationRequest = testDataHelper.createReservationRequest()
val paymentApproveResponse = PaymentFixture.createApproveResponse().copy(
paymentKey = reservationRequest.paymentKey,
orderId = reservationRequest.orderId,
totalAmount = reservationRequest.amount,
)
every {
paymentClient.confirm(any())
} returns paymentApproveResponse
every { paymentClient.confirm(any()) } returns paymentApproveResponse
// 예약 저장 과정에서 테마가 없는 예외
val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1)
val expectedException = ThemeErrorCode.THEME_NOT_FOUND
every {
paymentClient.cancel(any())
} returns PaymentFixture.createCancelResponse()
every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
@ -153,13 +157,13 @@ class ReservationControllerTest(
}
context("GET /reservations") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
reservations = testDataHelper.createDummyReservations()
}
test("관리자이면 정상 응답") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
Given {
port(port)
contentType(MediaType.APPLICATION_JSON_VALUE)
@ -173,13 +177,14 @@ class ReservationControllerTest(
}
context("GET /reservations-mine") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
reservations = testDataHelper.createDummyReservations()
}
test("로그인한 회원이 자신의 예약 목록을 조회한다.") {
val member: MemberEntity = login(reservations.keys.first())
val member = reservations.keys.first()
login(member)
val expectedReservations: Int = reservations[member]?.size ?: 0
Given {
@ -195,9 +200,9 @@ class ReservationControllerTest(
}
context("GET /reservations/search") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
reservations = testDataHelper.createDummyReservations()
}
test("관리자만 검색할 수 있다.") {
@ -216,7 +221,7 @@ class ReservationControllerTest(
}
test("파라미터를 지정하지 않으면 전체 목록 응답") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
Given {
port(port)
@ -230,7 +235,7 @@ class ReservationControllerTest(
}
test("시작 날짜가 종료 날짜 이전이면 예외 응답") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
val startDate = LocalDate.now().plusDays(1)
val endDate = LocalDate.now()
@ -250,8 +255,8 @@ class ReservationControllerTest(
}
test("동일한 회원의 모든 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
val member: MemberEntity = reservations.keys.first()
login(testDataHelper.createMember(role = Role.ADMIN))
val member = reservations.keys.first()
Given {
port(port)
@ -266,7 +271,7 @@ class ReservationControllerTest(
}
test("동일한 테마의 모든 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
val themes = reservations.values.flatten().map { it.theme }
val requestThemeId: Long = themes.first().id!!
@ -278,12 +283,12 @@ class ReservationControllerTest(
get("/reservations/search")
}.Then {
statusCode(200)
body("data.reservations.size()", equalTo(themes.filter { it.id == requestThemeId }.size))
body("data.reservations.size()", equalTo(themes.count { it.id == requestThemeId }))
}
}
test("시작 날짜와 종료 날짜 사이의 예약 응답") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
val dateFrom: LocalDate = reservations.values.flatten().minOf { it.date }
val dateTo: LocalDate = reservations.values.flatten().maxOf { it.date }
@ -302,14 +307,14 @@ class ReservationControllerTest(
}
context("DELETE /reservations/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
reservations = testDataHelper.createDummyReservations()
}
test("관리자만 예약을 삭제할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = reservations.values.flatten().first()
login(testDataHelper.createMember(role = Role.MEMBER))
val reservation = reservations.values.flatten().first()
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
@ -323,18 +328,12 @@ class ReservationControllerTest(
}
test("결제되지 않은 예약은 바로 제거") {
login(MemberFixture.create(role = Role.ADMIN))
val reservationId: Long = reservations.values.flatten().first().id!!
login(testDataHelper.createMember(role = Role.ADMIN))
val reservationId = reservations.values.flatten().first().id!!
transactionTemplate.execute {
val reservation: ReservationEntity = entityManager.find(
ReservationEntity::class.java,
reservationId
)
reservation.reservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
entityManager.persist(reservation)
entityManager.flush()
entityManager.clear()
transactionTemplate.executeWithoutResult {
val reservation = entityManager.find(ReservationEntity::class.java, reservationId)
reservation.status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
}
Given {
@ -345,32 +344,18 @@ class ReservationControllerTest(
statusCode(HttpStatus.NO_CONTENT.value())
}
// 예약이 삭제되었는지 확인
transactionTemplate.executeWithoutResult {
val deletedReservation = entityManager.find(
ReservationEntity::class.java,
reservationId
)
deletedReservation shouldBe null
val deletedReservation = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservationId)
}
deletedReservation shouldBe null
}
test("결제된 예약은 취소 후 제거") {
login(MemberFixture.create(role = Role.ADMIN))
val reservation: ReservationEntity = reservations.values.flatten().first()
lateinit var payment: PaymentEntity
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = reservations.values.flatten().first { it.status == ReservationStatus.CONFIRMED }
testDataHelper.createPayment(reservation)
transactionTemplate.execute {
payment = PaymentFixture.create(reservation = reservation).also {
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
}
every {
paymentClient.cancel(any())
} returns PaymentFixture.createCancelResponse()
every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse()
val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery(
"SELECT COUNT(c) FROM CanceledPaymentEntity c",
@ -396,15 +381,17 @@ class ReservationControllerTest(
context("POST /reservations/admin") {
test("관리자가 예약을 추가하면 결제 대기 상태로 예약 생성") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val adminRequest: AdminReservationCreateRequest = createRequest().let {
AdminReservationCreateRequest(
date = it.date,
themeId = it.themeId,
timeId = it.timeId,
memberId = member.id!!,
val admin = testDataHelper.createMember(role = Role.ADMIN)
login(admin)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val adminRequest = AdminReservationCreateRequest(
date = LocalDate.now().plusDays(1),
themeId = theme.id!!,
timeId = time.id!!,
memberId = admin.id!!,
)
}
Given {
port(port)
@ -420,13 +407,13 @@ class ReservationControllerTest(
}
context("GET /reservations/waiting") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
lateinit var reservations: Map<MemberEntity, List<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
reservations = testDataHelper.createDummyReservations(reservationCount = 5)
}
test("관리자가 아니면 조회할 수 없다.") {
login(MemberFixture.create(role = Role.MEMBER))
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
@ -441,9 +428,9 @@ class ReservationControllerTest(
}
test("대기 중인 예약 목록을 조회한다.") {
login(MemberFixture.create(role = Role.ADMIN))
login(testDataHelper.createMember(role = Role.ADMIN))
val expected = reservations.values.flatten()
.count { it.reservationStatus == ReservationStatus.WAITING }
.count { it.status == ReservationStatus.WAITING }
Given {
port(port)
@ -459,14 +446,16 @@ class ReservationControllerTest(
context("POST /reservations/waiting") {
test("회원이 대기 예약을 추가한다.") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val waitingCreateRequest: WaitingCreateRequest = createRequest().let {
WaitingCreateRequest(
date = it.date,
themeId = it.themeId,
timeId = it.timeId
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val waitingCreateRequest = WaitingCreateRequest(
date = LocalDate.now().plusDays(1),
themeId = theme.id!!,
timeId = time.id!!
)
}
Given {
port(port)
@ -476,33 +465,30 @@ class ReservationControllerTest(
post("/reservations/waiting")
}.Then {
statusCode(201)
body("data.member.id", equalTo(member.id!!.toInt()))
body("data.member.id", equalTo(member.id!!))
body("data.status", equalTo(ReservationStatus.WAITING.name))
}
}
test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val reservationRequest = createRequest()
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val theme = testDataHelper.createTheme()
val time = testDataHelper.createTime()
val date = LocalDate.now().plusDays(1)
transactionTemplate.executeWithoutResult {
val reservation = ReservationFixture.create(
date = reservationRequest.date,
theme = entityManager.find(ThemeEntity::class.java, reservationRequest.themeId),
time = entityManager.find(TimeEntity::class.java, reservationRequest.timeId),
testDataHelper.createReservation(
date = date,
theme = theme,
time = time,
member = member,
status = ReservationStatus.WAITING
status = ReservationStatus.CONFIRMED
)
entityManager.persist(reservation)
entityManager.flush()
entityManager.clear()
}
// 이미 예약된 시간, 테마로 대기 예약 요청
val waitingCreateRequest = WaitingCreateRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId
date = date,
themeId = theme.id!!,
timeId = time.id!!
)
val expectedError = ReservationErrorCode.ALREADY_RESERVE
@ -520,14 +506,10 @@ class ReservationControllerTest(
}
context("DELETE /reservations/waiting/{id}") {
lateinit var reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>>
beforeTest {
reservations = createDummyReservations()
}
test("대기 중인 예약을 취소한다.") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val waiting: ReservationEntity = createSingleReservation(
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val waiting = testDataHelper.createReservation(
member = member,
status = ReservationStatus.WAITING
)
@ -540,17 +522,16 @@ class ReservationControllerTest(
statusCode(HttpStatus.NO_CONTENT.value())
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
waiting.id
) shouldBe null
val deleted = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, waiting.id)
}
deleted shouldBe null
}
test("이미 확정된 예약을 삭제하면 예외 응답") {
val member = login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = createSingleReservation(
val member = testDataHelper.createMember(role = Role.MEMBER)
login(member)
val reservation = testDataHelper.createReservation(
member = member,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
)
@ -559,7 +540,7 @@ class ReservationControllerTest(
Given {
port(port)
}.When {
delete("/reservations/waiting/{id}", reservation.id)
delete("/reservations/waiting/${reservation.id}")
}.Then {
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
@ -569,7 +550,7 @@ class ReservationControllerTest(
context("POST /reservations/waiting/{id}/confirm") {
test("관리자만 승인할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
port(port)
@ -582,9 +563,8 @@ class ReservationControllerTest(
}
test("대기 예약을 승인하면 결제 대기 상태로 변경") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val reservation = createSingleReservation(
member = member,
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = testDataHelper.createReservation(
status = ReservationStatus.WAITING
)
@ -596,39 +576,28 @@ class ReservationControllerTest(
statusCode(200)
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
reservation.id
)?.also {
it.reservationStatus shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
} ?: throw AssertionError("Reservation not found")
val updatedReservation = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservation.id)
}
updatedReservation?.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
}
test("다른 확정된 예약을 승인하면 예외 응답") {
val admin = login(MemberFixture.create(role = Role.ADMIN))
val alreadyReserved = createSingleReservation(
val admin = testDataHelper.createMember(role = Role.ADMIN)
login(admin)
val alreadyReserved = testDataHelper.createReservation(
member = admin,
status = ReservationStatus.CONFIRMED
)
val member = MemberFixture.create(account = "account", role = Role.MEMBER).also { it ->
transactionTemplate.executeWithoutResult { _ ->
entityManager.persist(it)
}
}
val waiting = ReservationFixture.create(
val member = testDataHelper.createMember(role = Role.MEMBER)
val waiting = testDataHelper.createReservation(
date = alreadyReserved.date,
time = alreadyReserved.time,
theme = alreadyReserved.theme,
member = member,
status = ReservationStatus.WAITING
).also {
transactionTemplate.executeWithoutResult { _ ->
entityManager.persist(it)
}
}
)
val expectedError = ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
Given {
@ -636,7 +605,6 @@ class ReservationControllerTest(
}.When {
post("/reservations/waiting/${waiting.id!!}/confirm")
}.Then {
log().all()
statusCode(expectedError.httpStatus.value())
body("code", equalTo(expectedError.errorCode))
}
@ -645,7 +613,7 @@ class ReservationControllerTest(
context("POST /reservations/waiting/{id}/reject") {
test("관리자만 거절할 수 있다.") {
login(MemberFixture.create(role = Role.MEMBER))
login(testDataHelper.createMember(role = Role.MEMBER))
val expectedError = AuthErrorCode.ACCESS_DENIED
Given {
@ -659,9 +627,8 @@ class ReservationControllerTest(
}
test("거절된 예약은 삭제된다.") {
val member = login(MemberFixture.create(role = Role.ADMIN))
val reservation = createSingleReservation(
member = member,
login(testDataHelper.createMember(role = Role.ADMIN))
val reservation = testDataHelper.createReservation(
status = ReservationStatus.WAITING
)
@ -673,125 +640,91 @@ class ReservationControllerTest(
statusCode(204)
}
transactionTemplate.executeWithoutResult { _ ->
entityManager.find(
ReservationEntity::class.java,
reservation.id
) shouldBe null
val rejected = transactionTemplate.execute {
entityManager.find(ReservationEntity::class.java, reservation.id)
}
rejected shouldBe null
}
}
}
}
fun createSingleReservation(
class TestDataHelper(
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate,
) {
private var memberSequence = 0L
private var themeSequence = 0L
private var timeSequence = 0L
fun createMember(
role: Role = Role.MEMBER,
account: String = "member${++memberSequence}@test.com",
): MemberEntity {
val member = MemberFixture.create(role = role, account = account)
return persist(member)
}
fun createTheme(name: String = "theme-${++themeSequence}"): ThemeEntity {
val theme = ThemeFixture.create(name = name)
return persist(theme)
}
fun createTime(startAt: LocalTime = LocalTime.of(10, 0).plusMinutes(++timeSequence * 10)): TimeEntity {
val time = TimeFixture.create(startAt = startAt)
return persist(time)
}
fun createReservation(
date: LocalDate = LocalDate.now().plusDays(1),
time: LocalTime = LocalTime.now(),
themeName: String = "Default Theme",
member: MemberEntity = MemberFixture.create(role = Role.MEMBER),
status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
theme: ThemeEntity = createTheme(),
time: TimeEntity = createTime(),
member: MemberEntity = createMember(),
status: ReservationStatus = ReservationStatus.CONFIRMED,
): ReservationEntity {
return ReservationFixture.create(
date = date,
theme = ThemeFixture.create(name = themeName),
time = TimeFixture.create(startAt = time),
member = member,
status = status
).also { it ->
transactionTemplate.execute { _ ->
if (member.id == null) {
entityManager.persist(member)
}
entityManager.persist(it.time)
entityManager.persist(it.theme)
entityManager.persist(it)
entityManager.flush()
entityManager.clear()
}
}
}
fun createDummyReservations(): MutableMap<MemberEntity, MutableList<ReservationEntity>> {
val reservations: MutableMap<MemberEntity, MutableList<ReservationEntity>> = mutableMapOf()
val members: List<MemberEntity> = listOf(
MemberFixture.create(role = Role.MEMBER),
MemberFixture.create(role = Role.MEMBER)
)
transactionTemplate.executeWithoutResult {
members.forEach { member ->
entityManager.persist(member)
}
entityManager.flush()
entityManager.clear()
}
transactionTemplate.executeWithoutResult {
repeat(10) { index ->
val theme = ThemeFixture.create(name = "theme$index")
val time = TimeFixture.create(startAt = LocalTime.now().plusMinutes(index.toLong()))
entityManager.persist(theme)
entityManager.persist(time)
val reservation = ReservationFixture.create(
date = LocalDate.now().plusDays(index.toLong()),
date = date,
theme = theme,
time = time,
member = members[index % members.size],
status = ReservationStatus.CONFIRMED
member = member,
status = status
)
entityManager.persist(reservation)
reservations.getOrPut(reservation.member) { mutableListOf() }.add(reservation)
}
entityManager.flush()
entityManager.clear()
return persist(reservation)
}
return reservations
fun createPayment(reservation: ReservationEntity): PaymentEntity {
val payment = PaymentFixture.create(reservation = reservation)
return persist(payment)
}
fun createRequest(
theme: ThemeEntity = ThemeFixture.create(),
time: TimeEntity = TimeFixture.create(),
fun createReservationRequest(
theme: ThemeEntity = createTheme(),
time: TimeEntity = createTime(),
): ReservationCreateWithPaymentRequest {
lateinit var reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest
transactionTemplate.executeWithoutResult {
entityManager.persist(theme)
entityManager.persist(time)
reservationCreateWithPaymentRequest = ReservationFixture.createRequest(
return ReservationFixture.createRequest(
themeId = theme.id!!,
timeId = time.id!!,
)
entityManager.flush()
entityManager.clear()
}
return reservationCreateWithPaymentRequest
fun createDummyReservations(
memberCount: Int = 2,
reservationCount: Int = 10,
): Map<MemberEntity, List<ReservationEntity>> {
val members = (1..memberCount).map { createMember(role = Role.MEMBER) }
val reservations = (1..reservationCount).map { index ->
createReservation(
member = members[index % memberCount],
status = ReservationStatus.CONFIRMED
)
}
return reservations.groupBy { it.member }
}
fun login(member: MemberEntity): MemberEntity {
if (member.id == null) {
private fun <T> persist(entity: T): T {
transactionTemplate.executeWithoutResult {
entityManager.persist(member)
entityManager.flush()
entityManager.clear()
}
}
every {
jwtHandler.getMemberIdFromToken(any())
} returns member.id!!
every {
memberService.findById(member.id!!)
} returns member
every {
memberIdResolver.resolveArgument(any(), any(), any(), any())
} returns member.id!!
return member
entityManager.persist(entity)
}
return entity
}
}

View File

@ -13,12 +13,13 @@ import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.util.TsidFactory
import roomescape.util.ThemeFixture
class ThemeServiceTest : FunSpec({
val themeRepository: ThemeRepository = mockk()
val themeService = ThemeService(themeRepository)
val themeService = ThemeService(TsidFactory, themeRepository)
context("findThemeById") {
val themeId = 1L

View File

@ -8,7 +8,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.theme.util.TestThemeCreateUtil
import java.time.LocalDate
@DataJpaTest
@DataJpaTest(showSql = false)
class ThemeRepositoryTest(
val themeRepository: ThemeRepository,
val entityManager: EntityManager

View File

@ -10,6 +10,7 @@ import io.mockk.just
import io.mockk.runs
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode

View File

@ -14,6 +14,7 @@ import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.time.web.TimeCreateRequest
import roomescape.util.TsidFactory
import roomescape.util.TimeFixture
import java.time.LocalTime
@ -22,6 +23,7 @@ class TimeServiceTest : FunSpec({
val reservationRepository: ReservationRepository = mockk()
val timeService = TimeService(
tsidFactory = TsidFactory,
timeRepository = timeRepository,
reservationRepository = reservationRepository
)

View File

@ -7,7 +7,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.util.TimeFixture
import java.time.LocalTime
@DataJpaTest
@DataJpaTest(showSql = false)
class TimeRepositoryTest(
val entityManager: EntityManager,
val timeRepository: TimeRepository,

View File

@ -9,6 +9,7 @@ import io.mockk.every
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
@ -27,7 +28,6 @@ import java.time.LocalDate
import java.time.LocalTime
@WebMvcTest(TimeController::class)
@Import(JacksonConfig::class)
class TimeControllerTest(
val mockMvc: MockMvc,
) : RoomescapeApiTest() {

View File

@ -1,7 +1,9 @@
package roomescape.util
import com.github.f4b6a3.tsid.TsidFactory
import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.LoginRequest
import roomescape.common.config.next
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role
import roomescape.payment.infrastructure.client.PaymentApproveRequest
@ -20,11 +22,14 @@ import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime
val TsidFactory: TsidFactory = TsidFactory(0)
object MemberFixture {
const val NOT_LOGGED_IN_USERID: Long = 0
fun create(
id: Long? = null,
id: Long? = TsidFactory.next(),
name: String = "sangdol",
account: String = "default",
password: String = "password",
@ -56,14 +61,14 @@ object MemberFixture {
object TimeFixture {
fun create(
id: Long? = null,
id: Long? = TsidFactory.next(),
startAt: LocalTime = LocalTime.now().plusHours(1),
): TimeEntity = TimeEntity(id, startAt)
}
object ThemeFixture {
fun create(
id: Long? = null,
id: Long? = TsidFactory.next(),
name: String = "Default Theme",
description: String = "Default Description",
thumbnail: String = "https://example.com/default-thumbnail.jpg"
@ -72,7 +77,7 @@ object ThemeFixture {
object ReservationFixture {
fun create(
id: Long? = null,
id: Long? = TsidFactory.next(),
date: LocalDate = LocalDate.now().plusWeeks(1),
theme: ThemeEntity = ThemeFixture.create(),
time: TimeEntity = TimeFixture.create(),
@ -125,14 +130,14 @@ object PaymentFixture {
const val AMOUNT: Long = 10000L
fun create(
id: Long? = null,
id: Long? = TsidFactory.next(),
orderId: String = ORDER_ID,
paymentKey: String = PAYMENT_KEY,
totalAmount: Long = AMOUNT,
reservation: ReservationEntity = ReservationFixture.create(id = 1L),
approvedAt: OffsetDateTime = OffsetDateTime.now()
): PaymentEntity = PaymentEntity(
id = id,
_id = id,
orderId = orderId,
paymentKey = paymentKey,
totalAmount = totalAmount,
@ -141,14 +146,14 @@ object PaymentFixture {
)
fun createCanceled(
id: Long? = null,
id: Long? = TsidFactory.next(),
paymentKey: String = PAYMENT_KEY,
cancelReason: String = "Test Cancel",
cancelAmount: Long = AMOUNT,
approvedAt: OffsetDateTime = OffsetDateTime.now(),
canceledAt: OffsetDateTime = approvedAt.plusHours(1)
): CanceledPaymentEntity = CanceledPaymentEntity(
id = id,
_id = id,
paymentKey = paymentKey,
cancelReason = cancelReason,
cancelAmount = cancelAmount,

View File

@ -1,10 +1,16 @@
package roomescape.util
import com.fasterxml.jackson.databind.ObjectMapper
import com.github.f4b6a3.tsid.TsidFactory
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.every
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.context.annotation.Primary
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
@ -21,6 +27,8 @@ import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID
@Import(TestConfig::class, JacksonConfig::class)
@MockkBean(JpaMetamodelMappingContext::class)
abstract class RoomescapeApiTest : BehaviorSpec() {
@SpykBean
@ -128,3 +136,10 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
""".trimIndent()
)
}
@TestConfiguration
class TestConfig {
@Bean
@Primary
fun tsidFactory(): TsidFactory = TsidFactory
}

View File

@ -0,0 +1,5 @@
logging:
level:
root: INFO
org.springframework.orm.jpa: INFO
org.springframework.transaction: DEBUG

View File

@ -1,29 +0,0 @@
spring:
jpa:
show-sql: false
properties:
hibernate:
format_sql: true
ddl-auto: create-drop
defer-datasource-initialization: true
sql:
init:
data-locations:
security:
jwt:
token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
ttl-seconds: 1800000
payment:
api-base-url: https://api.tosspayments.com
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
read-timeout: 3
connect-timeout: 30
logging:
level:
root: INFO
org.springframework.orm.jpa: INFO
org.springframework.transaction: DEBUG