[#28] 쿠버네티스 환경 배포 #29

Merged
pricelees merged 25 commits from infra/#28 into main 2025-08-04 05:59:38 +00: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") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")
// DB // DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
// Jwt // Jwt
implementation("io.jsonwebtoken:jjwt:0.12.6") 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'; import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
const apiClient = axios.create({ 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, timeout: 10000,
}); });

View File

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

View File

@ -1,5 +1,4 @@
import { checkLogin } from '@_api/auth/authAPI'; import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from 'src/context/AuthContext'; 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 { 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 { interface AuthContextType {
loggedIn: boolean; loggedIn: boolean;
userName: string | null; userName: string | null;
role: 'ADMIN' | 'MEMBER' | null; role: 'ADMIN' | 'MEMBER' | null;
loading: boolean; // Add loading state to type loading: boolean; // Add loading state to type
login: (data: LoginRequest) => Promise<LoginCheckResponse>; login: (data: LoginRequest) => Promise<LoginResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkLogin: () => 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'; import AdminNavbar from './AdminNavbar';
interface AdminLayoutProps { interface AdminLayoutProps {

View File

@ -8,4 +8,19 @@ export default defineConfig({
react(), react(),
tsconfigPaths(), 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.Banner
import org.springframework.boot.SpringApplication import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
@EnableJpaAuditing
@SpringBootApplication @SpringBootApplication
class RoomescapeApplication class RoomescapeApplication

View File

@ -22,7 +22,7 @@ class JwtHandler(
fun createToken(memberId: Long): String { fun createToken(memberId: Long): String {
val date = Date() val date = Date()
val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds) val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000))
return Jwts.builder() return Jwts.builder()
.claim(MEMBER_ID_CLAIM_KEY, memberId) .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 { private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|") 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 -> return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1] val key = matchResult.groupValues[1]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ interface ReservationRepository
@Query( @Query(
""" """
UPDATE ReservationEntity r UPDATE ReservationEntity r
SET r.reservationStatus = :status SET r.status = :status
WHERE r.id = :id WHERE r.id = :id
""" """
) )
@ -39,7 +39,7 @@ interface ReservationRepository
WHERE r.theme.id = r2.theme.id WHERE r.theme.id = r2.theme.id
AND r.time.id = r2.time.id AND r.time.id = r2.time.id
AND r.date = r2.date AND r.date = r2.date
AND r.reservationStatus != 'WAITING' AND r.status != 'WAITING'
) )
) )
""" """
@ -53,7 +53,7 @@ interface ReservationRepository
t.name, t.name,
r.date, r.date,
r.time.startAt, 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), (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.paymentKey,
p.totalAmount p.totalAmount

View File

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

View File

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

View File

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

View File

@ -1,15 +1,26 @@
package roomescape.theme.infrastructure.persistence 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 @Entity
@Table(name = "themes") @Table(name = "themes")
class ThemeEntity( class ThemeEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "theme_id")
var id: Long? = null, private var _id: Long?,
@Column(name = "name", nullable = false)
var name: String, var name: String,
@Column(name = "description", nullable = false)
var description: String, 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 val thumbnail: String
) )
fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity(
name = this.name,
description = this.description,
thumbnail = this.thumbnail
)
data class ThemeRetrieveResponse( data class ThemeRetrieveResponse(
val id: Long, val id: Long,
val name: String, val name: String,

View File

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

View File

@ -1,13 +1,21 @@
package roomescape.time.infrastructure.persistence 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 import java.time.LocalTime
@Entity @Entity
@Table(name = "times") @Table(name = "times")
class TimeEntity( class TimeEntity(
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "time_id")
var id: Long? = null, private var _id: Long?,
var startAt: LocalTime
) @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 val startAt: LocalTime
) )
fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") @Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
data class TimeCreateResponse( data class TimeCreateResponse(
@Schema(description = "시간 식별자") @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: spring:
jpa: jpa:
show-sql: false
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
ddl-auto: create-drop hibernate:
defer-datasource-initialization: true ddl-auto: validate
h2: h2:
console: console:
enabled: true enabled: true
path: /h2-console path: /h2-console
datasource: datasource:
driver-class-name: org.h2.Driver hikari:
url: jdbc:h2:mem:database jdbc-url: jdbc:h2:mem:database
username: sa driver-class-name: org.h2.Driver
password: username: sa
password:
sql:
init:
schema-locations: classpath:schema/schema-h2.sql
security: security:
jwt: jwt:
token: token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
ttl-seconds: 1800000 ttl-seconds: 1800
payment: payment:
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
@ -35,7 +37,7 @@ jdbc:
query: query:
enable-logging: true enable-logging: true
log-level: DEBUG log-level: DEBUG
logger-name: query-logger logger-name: all-query-logger
multiline: true multiline: true
includes: connection,query,keys,fetch 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"/> <appender-ref ref="CONSOLE"/>
</logger> </logger>
<logger name="query-logger" level="debug" additivity="false"> <logger name="all-query-logger" level="debug" additivity="false">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
</logger> </logger>
</included> </included>

View File

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true"> <configuration scan="true">
<springProfile name="deploy">
<include resource="logback-deploy.xml"/>
</springProfile>
<springProfile name="local"> <springProfile name="local">
<include resource="logback-local.xml"/> <include resource="logback-local.xml"/>
</springProfile> </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.member.infrastructure.persistence.MemberRepository
import roomescape.util.JwtFixture import roomescape.util.JwtFixture
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.TsidFactory
class AuthServiceTest : BehaviorSpec({ class AuthServiceTest : BehaviorSpec({
val memberRepository: MemberRepository = mockk() val memberRepository: MemberRepository = mockk()
val memberService: MemberService = MemberService(memberRepository) val memberService = MemberService(TsidFactory, memberRepository)
val jwtHandler: JwtHandler = JwtFixture.create() val jwtHandler: JwtHandler = JwtFixture.create()
val authService = AuthService(memberService, jwtHandler) 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.infrastructure.persistence.PaymentRepository
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.util.PaymentFixture import roomescape.util.PaymentFixture
import roomescape.util.TsidFactory
import java.time.OffsetDateTime import java.time.OffsetDateTime
class PaymentServiceTest : FunSpec({ class PaymentServiceTest : FunSpec({
val paymentRepository: PaymentRepository = mockk() val paymentRepository: PaymentRepository = mockk()
val canceledPaymentRepository: CanceledPaymentRepository = mockk() val canceledPaymentRepository: CanceledPaymentRepository = mockk()
val paymentService = PaymentService(paymentRepository, canceledPaymentRepository) val paymentService = PaymentService(TsidFactory, paymentRepository, canceledPaymentRepository)
context("createCanceledPaymentByReservationId") { context("createCanceledPaymentByReservationId") {
val reservationId = 1L val reservationId = 1L

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture
import roomescape.util.TimeFixture import roomescape.util.TimeFixture
@DataJpaTest @DataJpaTest(showSql = false)
class ReservationRepositoryTest( class ReservationRepositoryTest(
val entityManager: EntityManager, val entityManager: EntityManager,
val reservationRepository: ReservationRepository, val reservationRepository: ReservationRepository,
@ -106,7 +106,7 @@ class ReservationRepositoryTest(
entityManager.clear() entityManager.clear()
reservationRepository.findByIdOrNull(reservationId)?.also { updated -> 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 roomescape.util.TimeFixture
import java.time.LocalDate import java.time.LocalDate
@DataJpaTest @DataJpaTest(showSql = false)
class ReservationSearchSpecificationTest( class ReservationSearchSpecificationTest(
val entityManager: EntityManager, val entityManager: EntityManager,
val reservationRepository: ReservationRepository val reservationRepository: ReservationRepository

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,16 @@
package roomescape.util package roomescape.util
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.github.f4b6a3.tsid.TsidFactory
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import io.kotest.core.spec.style.BehaviorSpec import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.every 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.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
@ -21,6 +27,8 @@ import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID
@Import(TestConfig::class, JacksonConfig::class)
@MockkBean(JpaMetamodelMappingContext::class)
abstract class RoomescapeApiTest : BehaviorSpec() { abstract class RoomescapeApiTest : BehaviorSpec() {
@SpykBean @SpykBean
@ -128,3 +136,10 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
""".trimIndent() """.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