diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..87d42727 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ae046cbe..bfec98c1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,7 +39,9 @@ dependencies { implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9") // DB + implementation("com.github.f4b6a3:tsid-creator:5.2.6") runtimeOnly("com.h2database:h2") + runtimeOnly("com.mysql:mysql-connector-j") // Jwt implementation("io.jsonwebtoken:jjwt:0.12.6") diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..3dd9fe5b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +.DS_Store +npm-debug.log +dist +build \ No newline at end of file diff --git a/frontend/.env b/frontend/.env index 2717bd56..2131aae2 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VITE_API_BASE_URL = "http://localhost:8080" \ No newline at end of file +VITE_API_BASE_URL = '/api' \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..cfc4d6d9 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..f304b884 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 83090c44..6e488ac3 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,7 +1,7 @@ import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios'; const apiClient = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080', + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, }); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9c736580..e4440f76 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import Navbar from './Navbar'; interface LayoutProps { diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 83c75a2c..6d6a324d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,12 +1,11 @@ -import { checkLogin } from '@_api/auth/authAPI'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from 'src/context/AuthContext'; const Navbar: React.FC = () => { const { loggedIn, userName, logout } = useAuth(); const navigate = useNavigate(); - + const handleLogout = async (e: React.MouseEvent) => { e.preventDefault(); try { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index b4e83a61..c367b18f 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,13 +1,13 @@ -import React, { createContext, useState, useEffect, ReactNode, useContext } from 'react'; import { checkLogin as apiCheckLogin, login as apiLogin, logout as apiLogout } from '@_api/auth/authAPI'; -import type { LoginRequest, LoginCheckResponse } from '@_api/auth/authTypes'; +import type { LoginRequest, LoginResponse } from '@_api/auth/authTypes'; +import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; interface AuthContextType { loggedIn: boolean; userName: string | null; role: 'ADMIN' | 'MEMBER' | null; loading: boolean; // Add loading state to type - login: (data: LoginRequest) => Promise; + login: (data: LoginRequest) => Promise; logout: () => Promise; checkLogin: () => Promise; } diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index 26987dbe..47034a06 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import AdminNavbar from './AdminNavbar'; interface AdminLayoutProps { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1c97543f..9fe3fabb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,4 +8,19 @@ export default defineConfig({ react(), tsconfigPaths(), ], + server: { + proxy: { + '/api': { + // 실제 백엔드 서버 주소로 전달 + target: 'http://localhost:8080', + + // Origin 헤더를 target의 Origin으로 변경 (CORS 에러 방지) + changeOrigin: true, + + // Ingress의 rewrite-target과 동일한 역할. + // '/api/themes' -> '/themes'로 경로를 재작성하여 백엔드에 전달 + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, }) diff --git a/src/main/kotlin/roomescape/RoomescapeApplication.kt b/src/main/kotlin/roomescape/RoomescapeApplication.kt index aca20d20..be15c160 100644 --- a/src/main/kotlin/roomescape/RoomescapeApplication.kt +++ b/src/main/kotlin/roomescape/RoomescapeApplication.kt @@ -3,7 +3,9 @@ package roomescape import org.springframework.boot.Banner import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +@EnableJpaAuditing @SpringBootApplication class RoomescapeApplication diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt index c8e611fc..0e224cb2 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt @@ -22,7 +22,7 @@ class JwtHandler( fun createToken(memberId: Long): String { val date = Date() - val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds) + val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) return Jwts.builder() .claim(MEMBER_ID_CLAIM_KEY, memberId) diff --git a/src/main/kotlin/roomescape/common/config/CorsConfig.kt b/src/main/kotlin/roomescape/common/config/CorsConfig.kt deleted file mode 100644 index f66ed3fc..00000000 --- a/src/main/kotlin/roomescape/common/config/CorsConfig.kt +++ /dev/null @@ -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 - } -} diff --git a/src/main/kotlin/roomescape/common/config/TsidConfig.kt b/src/main/kotlin/roomescape/common/config/TsidConfig.kt new file mode 100644 index 00000000..ea84f02c --- /dev/null +++ b/src/main/kotlin/roomescape/common/config/TsidConfig.kt @@ -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() \ No newline at end of file diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntity.kt b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt new file mode 100644 index 00000000..439f62ef --- /dev/null +++ b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt @@ -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 { + + @Transient + private var isNewEntity: Boolean = true + + @PostLoad + @PostPersist + fun markNotNew() { + isNewEntity = false + } + + override fun isNew(): Boolean = isNewEntity + + abstract override fun getId(): Long? + +} diff --git a/src/main/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt b/src/main/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt new file mode 100644 index 00000000..bbc44024 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt @@ -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 + ) { + 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 { + override fun test(t: Long): Boolean = (t >= thresholdMs) +} diff --git a/src/main/kotlin/roomescape/common/log/ProxyDataSourceConfig.kt b/src/main/kotlin/roomescape/common/log/ProxyDataSourceConfig.kt new file mode 100644 index 00000000..b0300f54 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/ProxyDataSourceConfig.kt @@ -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, +) diff --git a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt index 3fb696b1..2c94c436 100644 --- a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt +++ b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt @@ -36,7 +36,7 @@ class RoomescapeLogMaskingConverter : MessageConverter() { private fun maskedPlainMessage(message: String): String { val keys: String = SENSITIVE_KEYS.joinToString("|") - val regex = Regex("(?i)($keys)(\\s*=\\s*)([^,\\s]+)") + val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)") return regex.replace(message) { matchResult -> val key = matchResult.groupValues[1] diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt index 565590f7..d1a9e0ed 100644 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ b/src/main/kotlin/roomescape/member/business/MemberService.kt @@ -1,9 +1,11 @@ package roomescape.member.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.member.exception.MemberErrorCode import roomescape.member.exception.MemberException import roomescape.member.infrastructure.persistence.MemberEntity @@ -16,6 +18,7 @@ private val log = KotlinLogging.logger {} @Service @Transactional(readOnly = true) class MemberService( + private val tsidFactory: TsidFactory, private val memberRepository: MemberRepository, ) { fun findMembers(): MemberRetrieveListResponse { @@ -46,6 +49,7 @@ class MemberService( } val member = MemberEntity( + _id = tsidFactory.next(), name = request.name, email = request.email, password = request.password, diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt index 75db9f8f..7ea5665d 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt @@ -1,20 +1,30 @@ package roomescape.member.infrastructure.persistence import jakarta.persistence.* +import roomescape.common.entity.BaseEntity @Entity @Table(name = "members") class MemberEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, + @Column(name = "member_id") + private var _id: Long?, + + @Column(name = "name", nullable = false) var name: String, + + @Column(name = "email", nullable = false) var email: String, + + @Column(name = "password", nullable = false) var password: String, + @Column(name = "role", nullable = false, length = 20) @Enumerated(value = EnumType.STRING) var role: Role -) { +): BaseEntity() { + override fun getId(): Long? = _id + fun isAdmin(): Boolean = role == Role.ADMIN } diff --git a/src/main/kotlin/roomescape/payment/business/PaymentService.kt b/src/main/kotlin/roomescape/payment/business/PaymentService.kt index 236bcbf3..b5458b69 100644 --- a/src/main/kotlin/roomescape/payment/business/PaymentService.kt +++ b/src/main/kotlin/roomescape/payment/business/PaymentService.kt @@ -1,8 +1,10 @@ package roomescape.payment.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.client.PaymentApproveResponse @@ -21,6 +23,7 @@ private val log = KotlinLogging.logger {} @Service class PaymentService( + private val tsidFactory: TsidFactory, private val paymentRepository: PaymentRepository, private val canceledPaymentRepository: CanceledPaymentRepository, ) { @@ -31,6 +34,7 @@ class PaymentService( ): PaymentCreateResponse { log.debug { "[PaymentService.createPayment] 결제 정보 저장 시작: request=$approveResponse, reservationId=${reservation.id}" } val payment = PaymentEntity( + _id = tsidFactory.next(), orderId = approveResponse.orderId, paymentKey = approveResponse.paymentKey, totalAmount = approveResponse.totalAmount, @@ -62,6 +66,7 @@ class PaymentService( ", cancelInfo=$cancelInfo" } val canceledPayment = CanceledPaymentEntity( + _id = tsidFactory.next(), paymentKey = paymentKey, cancelReason = cancelInfo.cancelReason, cancelAmount = cancelInfo.cancelAmount, @@ -110,6 +115,7 @@ class PaymentService( } val canceledPayment = CanceledPaymentEntity( + _id = tsidFactory.next(), paymentKey = paymentKey, cancelReason = cancelReason, cancelAmount = payment.totalAmount, diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt index 6a581d55..133e3eb4 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentEntity.kt @@ -1,18 +1,33 @@ package roomescape.payment.infrastructure.persistence -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import roomescape.common.entity.BaseEntity import java.time.OffsetDateTime @Entity @Table(name = "canceled_payments") class CanceledPaymentEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, + @Column(name = "canceled_payment_id") + private var _id: Long?, + @Column(name = "payment_key", nullable = false) var paymentKey: String, + + @Column(name = "cancel_reason", nullable = false) var cancelReason: String, + + @Column(name = "cancel_amount", nullable = false) var cancelAmount: Long, + + @Column(name = "approved_at", nullable = false) var approvedAt: OffsetDateTime, + + @Column(name = "canceled_at", nullable = false) var canceledAt: OffsetDateTime, -) +): BaseEntity() { + override fun getId(): Long? = _id +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentEntity.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentEntity.kt index 59a89e8d..f3c80bd6 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentEntity.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/PaymentEntity.kt @@ -1,6 +1,7 @@ package roomescape.payment.infrastructure.persistence import jakarta.persistence.* +import roomescape.common.entity.BaseEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity import java.time.OffsetDateTime @@ -8,22 +9,24 @@ import java.time.OffsetDateTime @Table(name = "payments") class PaymentEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, + @Column(name = "payment_id") + private var _id: Long?, - @Column(nullable = false) + @Column(name = "order_id", nullable = false) var orderId: String, - @Column(nullable = false) + @Column(name="payment_key", nullable = false) var paymentKey: String, - @Column(nullable = false) + @Column(name="total_amount", nullable = false) var totalAmount: Long, @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "reservation_id", nullable = false) var reservation: ReservationEntity, - @Column(nullable = false) + @Column(name="approved_at", nullable = false) var approvedAt: OffsetDateTime -) \ No newline at end of file +): BaseEntity() { + override fun getId(): Long? = _id +} diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index a5374f75..d22be309 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -1,10 +1,12 @@ package roomescape.reservation.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.reservation.exception.ReservationErrorCode @@ -26,6 +28,7 @@ private val log = KotlinLogging.logger {} @Service @Transactional class ReservationService( + private val tsidFactory: TsidFactory, private val reservationRepository: ReservationRepository, private val timeService: TimeService, private val memberService: MemberService, @@ -79,7 +82,7 @@ class ReservationService( createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED) return reservationRepository.save(reservation) - .also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.reservationStatus}" } } + .also { log.info { "[ReservationService.createConfirmedReservation] 예약 추가 완료: reservationId=${it.id}, status=${it.status}" } } } fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { @@ -188,11 +191,12 @@ class ReservationService( validateDateAndTime(date, time) return ReservationEntity( + _id = tsidFactory.next(), date = date, time = time, theme = theme, member = member, - reservationStatus = status + status = status ) } @@ -259,7 +263,7 @@ class ReservationService( if (!reservation.isWaiting()) { log.warn { "[ReservationService.deleteWaiting] 대기 취소 실패(대기 예약이 아님): reservationId=$reservationId" + - ", currentStatus=${reservation.reservationStatus} memberId=$memberId" + ", currentStatus=${reservation.status} memberId=$memberId" } throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) } @@ -284,7 +288,7 @@ class ReservationService( if (!reservation.isWaiting()) { log.warn { "[ReservationService.rejectWaiting] 대기 예약 삭제 실패(이미 확정 상태): reservationId=$reservationId" + - ", status=${reservation.reservationStatus}" + ", status=${reservation.status}" } throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index 53939054..e3c92f2f 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -2,6 +2,7 @@ package roomescape.reservation.infrastructure.persistence import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.persistence.* +import roomescape.common.entity.BaseEntity import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.time.infrastructure.persistence.TimeEntity @@ -11,9 +12,10 @@ import java.time.LocalDate @Table(name = "reservations") class ReservationEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, + @Column(name = "reservation_id") + private var _id: Long?, + @Column(name = "date", nullable = false) var date: LocalDate, @ManyToOne(fetch = FetchType.LAZY) @@ -29,10 +31,13 @@ class ReservationEntity( var member: MemberEntity, @Enumerated(value = EnumType.STRING) - var reservationStatus: ReservationStatus -) { + @Column(name = "status", nullable = false, length = 30) + var status: ReservationStatus, +): BaseEntity() { + override fun getId(): Long? = _id + @JsonIgnore - fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING + fun isWaiting(): Boolean = status == ReservationStatus.WAITING @JsonIgnore fun isReservedBy(memberId: Long): Boolean { diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index c11891a7..e9f6c053 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -19,7 +19,7 @@ interface ReservationRepository @Query( """ UPDATE ReservationEntity r - SET r.reservationStatus = :status + SET r.status = :status WHERE r.id = :id """ ) @@ -39,7 +39,7 @@ interface ReservationRepository WHERE r.theme.id = r2.theme.id AND r.time.id = r2.time.id AND r.date = r2.date - AND r.reservationStatus != 'WAITING' + AND r.status != 'WAITING' ) ) """ @@ -53,7 +53,7 @@ interface ReservationRepository t.name, r.date, r.time.startAt, - r.reservationStatus, + r.status, (SELECT COUNT (r2) * 1L FROM ReservationEntity r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.time = r.time AND r2.id < r.id), p.paymentKey, p.totalAmount diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt index 195f28ca..fb6d01e8 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt @@ -36,11 +36,11 @@ class ReservationSearchSpecification( fun confirmed(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> cb.or( cb.equal( - root.get("reservationStatus"), + root.get("status"), ReservationStatus.CONFIRMED ), cb.equal( - root.get("reservationStatus"), + root.get("status"), ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ) ) @@ -48,7 +48,7 @@ class ReservationSearchSpecification( fun waiting(): ReservationSearchSpecification = andIfNotNull { root, _, cb -> cb.equal( - root.get("reservationStatus"), + root.get("status"), ReservationStatus.WAITING ) } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt index 5e4ea6c0..7b733dac 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt @@ -54,7 +54,7 @@ fun ReservationEntity.toRetrieveResponse(): ReservationRetrieveResponse = Reserv member = this.member.toRetrieveResponse(), time = this.time.toCreateResponse(), theme = this.theme.toResponse(), - status = this.reservationStatus + status = this.status ) data class ReservationRetrieveListResponse( diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index ed8006c2..665fe7a2 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -1,20 +1,26 @@ package roomescape.theme.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.* +import roomescape.theme.web.ThemeCreateRequest +import roomescape.theme.web.ThemeRetrieveListResponse +import roomescape.theme.web.ThemeRetrieveResponse +import roomescape.theme.web.toResponse import java.time.LocalDate private val log = KotlinLogging.logger {} @Service class ThemeService( + private val tsidFactory: TsidFactory, private val themeRepository: ThemeRepository, ) { @Transactional(readOnly = true) @@ -60,7 +66,12 @@ class ThemeService( throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) } - val theme: ThemeEntity = request.toEntity() + val theme = ThemeEntity( + _id = tsidFactory.next(), + name = request.name, + description = request.description, + thumbnail = request.thumbnail + ) return themeRepository.save(theme) .also { log.info { "[ThemeService.createTheme] 테마 생성 완료: themeId=${it.id}" } } .toResponse() diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt index acb6555b..4abd4383 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeEntity.kt @@ -1,15 +1,26 @@ package roomescape.theme.infrastructure.persistence -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import roomescape.common.entity.BaseEntity @Entity @Table(name = "themes") class ThemeEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, + @Column(name = "theme_id") + private var _id: Long?, + @Column(name = "name", nullable = false) var name: String, + + @Column(name = "description", nullable = false) var description: String, - var thumbnail: String -) \ No newline at end of file + + @Column(name = "thumbnail", nullable = false) + var thumbnail: String, +): BaseEntity() { + override fun getId(): Long? = _id +} diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt index 7ff50c62..4db87e77 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt @@ -21,12 +21,6 @@ data class ThemeCreateRequest( val thumbnail: String ) -fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity( - name = this.name, - description = this.description, - thumbnail = this.thumbnail -) - data class ThemeRetrieveResponse( val id: Long, val name: String, diff --git a/src/main/kotlin/roomescape/time/business/TimeService.kt b/src/main/kotlin/roomescape/time/business/TimeService.kt index be911dc0..a49b8561 100644 --- a/src/main/kotlin/roomescape/time/business/TimeService.kt +++ b/src/main/kotlin/roomescape/time/business/TimeService.kt @@ -1,9 +1,11 @@ package roomescape.time.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.time.exception.TimeErrorCode @@ -18,6 +20,7 @@ private val log = KotlinLogging.logger {} @Service class TimeService( + private val tsidFactory: TsidFactory, private val timeRepository: TimeRepository, private val reservationRepository: ReservationRepository, ) { @@ -50,7 +53,10 @@ class TimeService( throw TimeException(TimeErrorCode.TIME_DUPLICATED) } - val time: TimeEntity = request.toEntity() + val time = TimeEntity( + _id = tsidFactory.next(), + startAt = request.startAt + ) return timeRepository.save(time) .also { log.info { "[TimeService.createTime] 시간 생성 완료: timeId=${it.id}" } } .toCreateResponse() diff --git a/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt index f2f78706..3e31dd80 100644 --- a/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt +++ b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt @@ -1,13 +1,21 @@ package roomescape.time.infrastructure.persistence -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import roomescape.common.entity.BaseEntity import java.time.LocalTime @Entity @Table(name = "times") class TimeEntity( @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null, - var startAt: LocalTime -) + @Column(name = "time_id") + private var _id: Long?, + + @Column(name = "start_at", nullable = false) + var startAt: LocalTime, +): BaseEntity() { + override fun getId(): Long? = _id +} diff --git a/src/main/kotlin/roomescape/time/web/TimeDTO.kt b/src/main/kotlin/roomescape/time/web/TimeDTO.kt index 8ffa146e..62ee965f 100644 --- a/src/main/kotlin/roomescape/time/web/TimeDTO.kt +++ b/src/main/kotlin/roomescape/time/web/TimeDTO.kt @@ -10,8 +10,6 @@ data class TimeCreateRequest( val startAt: LocalTime ) -fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt) - @Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") data class TimeCreateResponse( @Schema(description = "시간 식별자") diff --git a/src/main/resources/application-deploy.yaml b/src/main/resources/application-deploy.yaml new file mode 100644 index 00000000..c57acd55 --- /dev/null +++ b/src/main/resources/application-deploy.yaml @@ -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} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 9a8b7f2d..3ed9a706 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,27 +1,29 @@ spring: jpa: - show-sql: false properties: hibernate: format_sql: true - ddl-auto: create-drop - defer-datasource-initialization: true - + hibernate: + ddl-auto: validate h2: console: enabled: true path: /h2-console datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:database - username: sa - password: + hikari: + jdbc-url: jdbc:h2:mem:database + driver-class-name: org.h2.Driver + username: sa + password: + sql: + init: + schema-locations: classpath:schema/schema-h2.sql security: jwt: token: secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi - ttl-seconds: 1800000 + ttl-seconds: 1800 payment: confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 @@ -35,11 +37,11 @@ jdbc: query: enable-logging: true log-level: DEBUG - logger-name: query-logger + logger-name: all-query-logger multiline: true includes: connection,query,keys,fetch management: tracing: sampling: - probability: 1 \ No newline at end of file + probability: 1 diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql deleted file mode 100644 index 8c38d67d..00000000 --- a/src/main/resources/data.sql +++ /dev/null @@ -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); \ No newline at end of file diff --git a/src/main/resources/logback-deploy.xml b/src/main/resources/logback-deploy.xml new file mode 100644 index 00000000..662eea5e --- /dev/null +++ b/src/main/resources/logback-deploy.xml @@ -0,0 +1,95 @@ + + + + + + + + + timestamp + UTC + + + level + + + logger + + + thread + + + + + { + "message": "%maskedMessage" + } + + + + stack_trace + + 5 + 2048 + true + + + + + + + + + + logs/application-%d{yyyy-MM-dd}.%i.log.gz + 100MB + 3 + 1GB + + + + + timestamp + UTC + + + level + + + logger + + + thread + + + + + { + "message": "%maskedMessage" + } + + + + stack_trace + + 5 + 2048 + true + + + + + + + + + 512 + 0 false + + + + + + + diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml index fb0a2202..f94aa623 100644 --- a/src/main/resources/logback-local.xml +++ b/src/main/resources/logback-local.xml @@ -20,7 +20,7 @@ - + diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index d60c1c92..a4773a2b 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,5 +1,9 @@ + + + + diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql new file mode 100644 index 00000000..ce3b4fdd --- /dev/null +++ b/src/main/resources/schema/schema-h2.sql @@ -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 +); diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql new file mode 100644 index 00000000..e10c4072 --- /dev/null +++ b/src/main/resources/schema/schema-mysql.sql @@ -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 +); diff --git a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt index 8d629510..aa570047 100644 --- a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt +++ b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt @@ -15,10 +15,11 @@ import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.util.JwtFixture import roomescape.util.MemberFixture +import roomescape.util.TsidFactory class AuthServiceTest : BehaviorSpec({ val memberRepository: MemberRepository = mockk() - val memberService: MemberService = MemberService(memberRepository) + val memberService = MemberService(TsidFactory, memberRepository) val jwtHandler: JwtHandler = JwtFixture.create() val authService = AuthService(memberService, jwtHandler) diff --git a/src/test/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt b/src/test/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt new file mode 100644 index 00000000..ac224f63 --- /dev/null +++ b/src/test/kotlin/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt @@ -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 + } + } +}) diff --git a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt index 7b5f2e36..71d9696d 100644 --- a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt +++ b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt @@ -14,13 +14,14 @@ import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.payment.web.PaymentCancelRequest import roomescape.util.PaymentFixture +import roomescape.util.TsidFactory import java.time.OffsetDateTime class PaymentServiceTest : FunSpec({ val paymentRepository: PaymentRepository = mockk() val canceledPaymentRepository: CanceledPaymentRepository = mockk() - val paymentService = PaymentService(paymentRepository, canceledPaymentRepository) + val paymentService = PaymentService(TsidFactory, paymentRepository, canceledPaymentRepository) context("createCanceledPaymentByReservationId") { val reservationId = 1L diff --git a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt index f5984ddd..eb8906ad 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt @@ -1,11 +1,13 @@ package roomescape.payment.infrastructure.client +import com.ninjasquad.springmockk.MockkBean import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.client.RestClientTest +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -20,6 +22,7 @@ import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse @RestClientTest(TossPaymentClient::class) +@MockkBean(JpaMetamodelMappingContext::class) class TossPaymentClientTest( @Autowired val client: TossPaymentClient, @Autowired val mockServer: MockRestServiceServer diff --git a/src/test/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt index f08657bb..052d1eb8 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/persistence/CanceledPaymentRepositoryTest.kt @@ -5,10 +5,12 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import roomescape.common.config.next import roomescape.util.PaymentFixture +import roomescape.util.TsidFactory import java.util.* -@DataJpaTest +@DataJpaTest(showSql = false) class CanceledPaymentRepositoryTest( @Autowired val canceledPaymentRepository: CanceledPaymentRepository, ) : FunSpec() { @@ -16,7 +18,7 @@ class CanceledPaymentRepositoryTest( context("paymentKey로 CanceledPaymentEntity 조회") { val paymentKey = "test-payment-key" beforeTest { - PaymentFixture.createCanceled(paymentKey = paymentKey) + PaymentFixture.createCanceled(id = TsidFactory.next(), paymentKey = paymentKey) .also { canceledPaymentRepository.save(it) } } diff --git a/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt index 02e5c165..51979b53 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/persistence/PaymentRepositoryTest.kt @@ -6,11 +6,13 @@ import io.kotest.matchers.shouldBe import jakarta.persistence.EntityManager import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import roomescape.common.config.next import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.util.PaymentFixture import roomescape.util.ReservationFixture +import roomescape.util.TsidFactory -@DataJpaTest +@DataJpaTest(showSql = false) class PaymentRepositoryTest( @Autowired val paymentRepository: PaymentRepository, @Autowired val entityManager: EntityManager @@ -91,7 +93,9 @@ class PaymentRepositoryTest( } private fun setupReservation(): ReservationEntity { - return ReservationFixture.create().also { + return ReservationFixture.create( + id = TsidFactory.next() + ).also { entityManager.persist(it.member) entityManager.persist(it.theme) entityManager.persist(it.time) diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt index 95f5b118..54c6cccf 100644 --- a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt +++ b/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt @@ -16,6 +16,7 @@ import roomescape.theme.business.ThemeService import roomescape.time.business.TimeService import roomescape.util.MemberFixture import roomescape.util.ReservationFixture +import roomescape.util.TsidFactory import roomescape.util.TimeFixture import java.time.LocalDate import java.time.LocalTime @@ -27,6 +28,7 @@ class ReservationServiceTest : FunSpec({ val memberService: MemberService = mockk() val themeService: ThemeService = mockk() val reservationService = ReservationService( + TsidFactory, reservationRepository, timeService, memberService, diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt index d1c095ab..78d44d3f 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt +++ b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt @@ -15,7 +15,7 @@ import roomescape.util.ReservationFixture import roomescape.util.ThemeFixture import roomescape.util.TimeFixture -@DataJpaTest +@DataJpaTest(showSql = false) class ReservationRepositoryTest( val entityManager: EntityManager, val reservationRepository: ReservationRepository, @@ -106,7 +106,7 @@ class ReservationRepositoryTest( entityManager.clear() reservationRepository.findByIdOrNull(reservationId)?.also { updated -> - updated.reservationStatus shouldBe it + updated.status shouldBe it } } } diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt index 130b062b..88323f85 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt +++ b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt @@ -15,7 +15,7 @@ import roomescape.util.ThemeFixture import roomescape.util.TimeFixture import java.time.LocalDate -@DataJpaTest +@DataJpaTest(showSql = false) class ReservationSearchSpecificationTest( val entityManager: EntityManager, val reservationRepository: ReservationRepository diff --git a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt index 054ffe3b..a5fdf245 100644 --- a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt +++ b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt @@ -39,7 +39,7 @@ import java.time.LocalTime class ReservationControllerTest( @LocalServerPort val port: Int, val entityManager: EntityManager, - val transactionTemplate: TransactionTemplate + val transactionTemplate: TransactionTemplate, ) : FunSpec({ extension(DatabaseCleanerExtension(mode = CleanerMode.AFTER_EACH_TEST)) }) { @@ -55,24 +55,34 @@ class ReservationControllerTest( @MockkBean lateinit var jwtHandler: JwtHandler + lateinit var testDataHelper: TestDataHelper + + fun login(member: MemberEntity) { + every { jwtHandler.getMemberIdFromToken(any()) } returns member.id!! + every { memberService.findById(member.id!!) } returns member + every { memberIdResolver.resolveArgument(any(), any(), any(), any()) } returns member.id!! + } + init { + beforeSpec { + testDataHelper = TestDataHelper(entityManager, transactionTemplate) + } + context("POST /reservations") { - lateinit var member: MemberEntity beforeTest { - member = login(MemberFixture.create(role = Role.MEMBER)) + val member = testDataHelper.createMember(role = Role.MEMBER) + login(member) } test("정상 응답") { - val reservationRequest = createRequest() + val reservationRequest = testDataHelper.createReservationRequest() val paymentApproveResponse = PaymentFixture.createApproveResponse().copy( paymentKey = reservationRequest.paymentKey, orderId = reservationRequest.orderId, totalAmount = reservationRequest.amount, ) - every { - paymentClient.confirm(any()) - } returns paymentApproveResponse + every { paymentClient.confirm(any()) } returns paymentApproveResponse Given { port(port) @@ -88,12 +98,10 @@ class ReservationControllerTest( } test("결제 과정에서 발생하는 에러는 그대로 응답") { - val reservationRequest = createRequest() + val reservationRequest = testDataHelper.createReservationRequest() val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) - every { - paymentClient.confirm(any()) - } throws paymentException + every { paymentClient.confirm(any()) } throws paymentException Given { port(port) @@ -108,24 +116,20 @@ class ReservationControllerTest( } test("결제 완료 후 예약 / 결제 정보 저장 과정에서 에러 발생시 결제 취소 후 에러 응답을 받는다.") { - val reservationRequest = createRequest() + val reservationRequest = testDataHelper.createReservationRequest() val paymentApproveResponse = PaymentFixture.createApproveResponse().copy( paymentKey = reservationRequest.paymentKey, orderId = reservationRequest.orderId, totalAmount = reservationRequest.amount, ) - every { - paymentClient.confirm(any()) - } returns paymentApproveResponse + every { paymentClient.confirm(any()) } returns paymentApproveResponse // 예약 저장 과정에서 테마가 없는 예외 val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1) val expectedException = ThemeErrorCode.THEME_NOT_FOUND - every { - paymentClient.cancel(any()) - } returns PaymentFixture.createCancelResponse() + every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse() val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", @@ -153,13 +157,13 @@ class ReservationControllerTest( } context("GET /reservations") { - lateinit var reservations: MutableMap> + lateinit var reservations: Map> beforeTest { - reservations = createDummyReservations() + reservations = testDataHelper.createDummyReservations() } test("관리자이면 정상 응답") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) Given { port(port) contentType(MediaType.APPLICATION_JSON_VALUE) @@ -173,13 +177,14 @@ class ReservationControllerTest( } context("GET /reservations-mine") { - lateinit var reservations: MutableMap> + lateinit var reservations: Map> beforeTest { - reservations = createDummyReservations() + reservations = testDataHelper.createDummyReservations() } test("로그인한 회원이 자신의 예약 목록을 조회한다.") { - val member: MemberEntity = login(reservations.keys.first()) + val member = reservations.keys.first() + login(member) val expectedReservations: Int = reservations[member]?.size ?: 0 Given { @@ -195,9 +200,9 @@ class ReservationControllerTest( } context("GET /reservations/search") { - lateinit var reservations: MutableMap> + lateinit var reservations: Map> beforeTest { - reservations = createDummyReservations() + reservations = testDataHelper.createDummyReservations() } test("관리자만 검색할 수 있다.") { @@ -216,7 +221,7 @@ class ReservationControllerTest( } test("파라미터를 지정하지 않으면 전체 목록 응답") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) Given { port(port) @@ -230,7 +235,7 @@ class ReservationControllerTest( } test("시작 날짜가 종료 날짜 이전이면 예외 응답") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) val startDate = LocalDate.now().plusDays(1) val endDate = LocalDate.now() @@ -250,8 +255,8 @@ class ReservationControllerTest( } test("동일한 회원의 모든 예약 응답") { - login(MemberFixture.create(role = Role.ADMIN)) - val member: MemberEntity = reservations.keys.first() + login(testDataHelper.createMember(role = Role.ADMIN)) + val member = reservations.keys.first() Given { port(port) @@ -266,7 +271,7 @@ class ReservationControllerTest( } test("동일한 테마의 모든 예약 응답") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) val themes = reservations.values.flatten().map { it.theme } val requestThemeId: Long = themes.first().id!! @@ -278,12 +283,12 @@ class ReservationControllerTest( get("/reservations/search") }.Then { statusCode(200) - body("data.reservations.size()", equalTo(themes.filter { it.id == requestThemeId }.size)) + body("data.reservations.size()", equalTo(themes.count { it.id == requestThemeId })) } } test("시작 날짜와 종료 날짜 사이의 예약 응답") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) val dateFrom: LocalDate = reservations.values.flatten().minOf { it.date } val dateTo: LocalDate = reservations.values.flatten().maxOf { it.date } @@ -302,14 +307,14 @@ class ReservationControllerTest( } context("DELETE /reservations/{id}") { - lateinit var reservations: MutableMap> + lateinit var reservations: Map> beforeTest { - reservations = createDummyReservations() + reservations = testDataHelper.createDummyReservations() } test("관리자만 예약을 삭제할 수 있다.") { - login(MemberFixture.create(role = Role.MEMBER)) - val reservation: ReservationEntity = reservations.values.flatten().first() + login(testDataHelper.createMember(role = Role.MEMBER)) + val reservation = reservations.values.flatten().first() val expectedError = AuthErrorCode.ACCESS_DENIED Given { @@ -323,18 +328,12 @@ class ReservationControllerTest( } test("결제되지 않은 예약은 바로 제거") { - login(MemberFixture.create(role = Role.ADMIN)) - val reservationId: Long = reservations.values.flatten().first().id!! + login(testDataHelper.createMember(role = Role.ADMIN)) + val reservationId = reservations.values.flatten().first().id!! - transactionTemplate.execute { - val reservation: ReservationEntity = entityManager.find( - ReservationEntity::class.java, - reservationId - ) - reservation.reservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - entityManager.persist(reservation) - entityManager.flush() - entityManager.clear() + transactionTemplate.executeWithoutResult { + val reservation = entityManager.find(ReservationEntity::class.java, reservationId) + reservation.status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED } Given { @@ -345,32 +344,18 @@ class ReservationControllerTest( statusCode(HttpStatus.NO_CONTENT.value()) } - // 예약이 삭제되었는지 확인 - transactionTemplate.executeWithoutResult { - val deletedReservation = entityManager.find( - ReservationEntity::class.java, - reservationId - ) - deletedReservation shouldBe null + val deletedReservation = transactionTemplate.execute { + entityManager.find(ReservationEntity::class.java, reservationId) } + deletedReservation shouldBe null } test("결제된 예약은 취소 후 제거") { - login(MemberFixture.create(role = Role.ADMIN)) - val reservation: ReservationEntity = reservations.values.flatten().first() - lateinit var payment: PaymentEntity + login(testDataHelper.createMember(role = Role.ADMIN)) + val reservation = reservations.values.flatten().first { it.status == ReservationStatus.CONFIRMED } + testDataHelper.createPayment(reservation) - transactionTemplate.execute { - payment = PaymentFixture.create(reservation = reservation).also { - entityManager.persist(it) - entityManager.flush() - entityManager.clear() - } - } - - every { - paymentClient.cancel(any()) - } returns PaymentFixture.createCancelResponse() + every { paymentClient.cancel(any()) } returns PaymentFixture.createCancelResponse() val canceledPaymentSizeBeforeApiCall: Long = entityManager.createQuery( "SELECT COUNT(c) FROM CanceledPaymentEntity c", @@ -396,15 +381,17 @@ class ReservationControllerTest( context("POST /reservations/admin") { test("관리자가 예약을 추가하면 결제 대기 상태로 예약 생성") { - val member = login(MemberFixture.create(role = Role.ADMIN)) - val adminRequest: AdminReservationCreateRequest = createRequest().let { - AdminReservationCreateRequest( - date = it.date, - themeId = it.themeId, - timeId = it.timeId, - memberId = member.id!!, - ) - } + val admin = testDataHelper.createMember(role = Role.ADMIN) + login(admin) + val theme = testDataHelper.createTheme() + val time = testDataHelper.createTime() + + val adminRequest = AdminReservationCreateRequest( + date = LocalDate.now().plusDays(1), + themeId = theme.id!!, + timeId = time.id!!, + memberId = admin.id!!, + ) Given { port(port) @@ -420,13 +407,13 @@ class ReservationControllerTest( } context("GET /reservations/waiting") { - lateinit var reservations: MutableMap> + lateinit var reservations: Map> beforeTest { - reservations = createDummyReservations() + reservations = testDataHelper.createDummyReservations(reservationCount = 5) } test("관리자가 아니면 조회할 수 없다.") { - login(MemberFixture.create(role = Role.MEMBER)) + login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { @@ -441,9 +428,9 @@ class ReservationControllerTest( } test("대기 중인 예약 목록을 조회한다.") { - login(MemberFixture.create(role = Role.ADMIN)) + login(testDataHelper.createMember(role = Role.ADMIN)) val expected = reservations.values.flatten() - .count { it.reservationStatus == ReservationStatus.WAITING } + .count { it.status == ReservationStatus.WAITING } Given { port(port) @@ -459,14 +446,16 @@ class ReservationControllerTest( context("POST /reservations/waiting") { test("회원이 대기 예약을 추가한다.") { - val member = login(MemberFixture.create(role = Role.MEMBER)) - val waitingCreateRequest: WaitingCreateRequest = createRequest().let { - WaitingCreateRequest( - date = it.date, - themeId = it.themeId, - timeId = it.timeId - ) - } + val member = testDataHelper.createMember(role = Role.MEMBER) + login(member) + val theme = testDataHelper.createTheme() + val time = testDataHelper.createTime() + + val waitingCreateRequest = WaitingCreateRequest( + date = LocalDate.now().plusDays(1), + themeId = theme.id!!, + timeId = time.id!! + ) Given { port(port) @@ -476,33 +465,30 @@ class ReservationControllerTest( post("/reservations/waiting") }.Then { statusCode(201) - body("data.member.id", equalTo(member.id!!.toInt())) + body("data.member.id", equalTo(member.id!!)) body("data.status", equalTo(ReservationStatus.WAITING.name)) } } test("이미 예약된 시간, 테마로 대기 예약 요청 시 예외 응답") { - val member = login(MemberFixture.create(role = Role.MEMBER)) - val reservationRequest = createRequest() + val member = testDataHelper.createMember(role = Role.MEMBER) + login(member) + val theme = testDataHelper.createTheme() + val time = testDataHelper.createTime() + val date = LocalDate.now().plusDays(1) - transactionTemplate.executeWithoutResult { - val reservation = ReservationFixture.create( - date = reservationRequest.date, - theme = entityManager.find(ThemeEntity::class.java, reservationRequest.themeId), - time = entityManager.find(TimeEntity::class.java, reservationRequest.timeId), - member = member, - status = ReservationStatus.WAITING - ) - entityManager.persist(reservation) - entityManager.flush() - entityManager.clear() - } + testDataHelper.createReservation( + date = date, + theme = theme, + time = time, + member = member, + status = ReservationStatus.CONFIRMED + ) - // 이미 예약된 시간, 테마로 대기 예약 요청 val waitingCreateRequest = WaitingCreateRequest( - date = reservationRequest.date, - themeId = reservationRequest.themeId, - timeId = reservationRequest.timeId + date = date, + themeId = theme.id!!, + timeId = time.id!! ) val expectedError = ReservationErrorCode.ALREADY_RESERVE @@ -520,14 +506,10 @@ class ReservationControllerTest( } context("DELETE /reservations/waiting/{id}") { - lateinit var reservations: MutableMap> - beforeTest { - reservations = createDummyReservations() - } - test("대기 중인 예약을 취소한다.") { - val member = login(MemberFixture.create(role = Role.MEMBER)) - val waiting: ReservationEntity = createSingleReservation( + val member = testDataHelper.createMember(role = Role.MEMBER) + login(member) + val waiting = testDataHelper.createReservation( member = member, status = ReservationStatus.WAITING ) @@ -540,17 +522,16 @@ class ReservationControllerTest( statusCode(HttpStatus.NO_CONTENT.value()) } - transactionTemplate.executeWithoutResult { _ -> - entityManager.find( - ReservationEntity::class.java, - waiting.id - ) shouldBe null + val deleted = transactionTemplate.execute { + entityManager.find(ReservationEntity::class.java, waiting.id) } + deleted shouldBe null } test("이미 확정된 예약을 삭제하면 예외 응답") { - val member = login(MemberFixture.create(role = Role.MEMBER)) - val reservation: ReservationEntity = createSingleReservation( + val member = testDataHelper.createMember(role = Role.MEMBER) + login(member) + val reservation = testDataHelper.createReservation( member = member, status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ) @@ -559,7 +540,7 @@ class ReservationControllerTest( Given { port(port) }.When { - delete("/reservations/waiting/{id}", reservation.id) + delete("/reservations/waiting/${reservation.id}") }.Then { statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) @@ -569,7 +550,7 @@ class ReservationControllerTest( context("POST /reservations/waiting/{id}/confirm") { test("관리자만 승인할 수 있다.") { - login(MemberFixture.create(role = Role.MEMBER)) + login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { port(port) @@ -582,9 +563,8 @@ class ReservationControllerTest( } test("대기 예약을 승인하면 결제 대기 상태로 변경") { - val member = login(MemberFixture.create(role = Role.ADMIN)) - val reservation = createSingleReservation( - member = member, + login(testDataHelper.createMember(role = Role.ADMIN)) + val reservation = testDataHelper.createReservation( status = ReservationStatus.WAITING ) @@ -596,39 +576,28 @@ class ReservationControllerTest( statusCode(200) } - transactionTemplate.executeWithoutResult { _ -> - entityManager.find( - ReservationEntity::class.java, - reservation.id - )?.also { - it.reservationStatus shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - } ?: throw AssertionError("Reservation not found") + val updatedReservation = transactionTemplate.execute { + entityManager.find(ReservationEntity::class.java, reservation.id) } + updatedReservation?.status shouldBe ReservationStatus.CONFIRMED_PAYMENT_REQUIRED } test("다른 확정된 예약을 승인하면 예외 응답") { - val admin = login(MemberFixture.create(role = Role.ADMIN)) - val alreadyReserved = createSingleReservation( + val admin = testDataHelper.createMember(role = Role.ADMIN) + login(admin) + val alreadyReserved = testDataHelper.createReservation( member = admin, status = ReservationStatus.CONFIRMED ) - val member = MemberFixture.create(account = "account", role = Role.MEMBER).also { it -> - transactionTemplate.executeWithoutResult { _ -> - entityManager.persist(it) - } - } - val waiting = ReservationFixture.create( + val member = testDataHelper.createMember(role = Role.MEMBER) + val waiting = testDataHelper.createReservation( date = alreadyReserved.date, time = alreadyReserved.time, theme = alreadyReserved.theme, member = member, status = ReservationStatus.WAITING - ).also { - transactionTemplate.executeWithoutResult { _ -> - entityManager.persist(it) - } - } + ) val expectedError = ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS Given { @@ -636,7 +605,6 @@ class ReservationControllerTest( }.When { post("/reservations/waiting/${waiting.id!!}/confirm") }.Then { - log().all() statusCode(expectedError.httpStatus.value()) body("code", equalTo(expectedError.errorCode)) } @@ -645,7 +613,7 @@ class ReservationControllerTest( context("POST /reservations/waiting/{id}/reject") { test("관리자만 거절할 수 있다.") { - login(MemberFixture.create(role = Role.MEMBER)) + login(testDataHelper.createMember(role = Role.MEMBER)) val expectedError = AuthErrorCode.ACCESS_DENIED Given { @@ -659,9 +627,8 @@ class ReservationControllerTest( } test("거절된 예약은 삭제된다.") { - val member = login(MemberFixture.create(role = Role.ADMIN)) - val reservation = createSingleReservation( - member = member, + login(testDataHelper.createMember(role = Role.ADMIN)) + val reservation = testDataHelper.createReservation( status = ReservationStatus.WAITING ) @@ -673,125 +640,91 @@ class ReservationControllerTest( statusCode(204) } - transactionTemplate.executeWithoutResult { _ -> - entityManager.find( - ReservationEntity::class.java, - reservation.id - ) shouldBe null + val rejected = transactionTemplate.execute { + entityManager.find(ReservationEntity::class.java, reservation.id) } + rejected shouldBe null } } } - - fun createSingleReservation( - date: LocalDate = LocalDate.now().plusDays(1), - time: LocalTime = LocalTime.now(), - themeName: String = "Default Theme", - member: MemberEntity = MemberFixture.create(role = Role.MEMBER), - status: ReservationStatus = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - ): ReservationEntity { - return ReservationFixture.create( - date = date, - theme = ThemeFixture.create(name = themeName), - time = TimeFixture.create(startAt = time), - member = member, - status = status - ).also { it -> - transactionTemplate.execute { _ -> - if (member.id == null) { - entityManager.persist(member) - } - entityManager.persist(it.time) - entityManager.persist(it.theme) - entityManager.persist(it) - entityManager.flush() - entityManager.clear() - } - } - } - - fun createDummyReservations(): MutableMap> { - val reservations: MutableMap> = mutableMapOf() - val members: List = listOf( - MemberFixture.create(role = Role.MEMBER), - MemberFixture.create(role = Role.MEMBER) - ) - - transactionTemplate.executeWithoutResult { - members.forEach { member -> - entityManager.persist(member) - } - entityManager.flush() - entityManager.clear() - } - - transactionTemplate.executeWithoutResult { - repeat(10) { index -> - val theme = ThemeFixture.create(name = "theme$index") - val time = TimeFixture.create(startAt = LocalTime.now().plusMinutes(index.toLong())) - entityManager.persist(theme) - entityManager.persist(time) - - val reservation = ReservationFixture.create( - date = LocalDate.now().plusDays(index.toLong()), - theme = theme, - time = time, - member = members[index % members.size], - status = ReservationStatus.CONFIRMED - ) - entityManager.persist(reservation) - reservations.getOrPut(reservation.member) { mutableListOf() }.add(reservation) - } - entityManager.flush() - entityManager.clear() - } - - return reservations - } - - fun createRequest( - theme: ThemeEntity = ThemeFixture.create(), - time: TimeEntity = TimeFixture.create(), - ): ReservationCreateWithPaymentRequest { - lateinit var reservationCreateWithPaymentRequest: ReservationCreateWithPaymentRequest - - transactionTemplate.executeWithoutResult { - entityManager.persist(theme) - entityManager.persist(time) - - reservationCreateWithPaymentRequest = ReservationFixture.createRequest( - themeId = theme.id!!, - 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 - } } + +class TestDataHelper( + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, +) { + private var memberSequence = 0L + private var themeSequence = 0L + private var timeSequence = 0L + + fun createMember( + role: Role = Role.MEMBER, + account: String = "member${++memberSequence}@test.com", + ): MemberEntity { + val member = MemberFixture.create(role = role, account = account) + return persist(member) + } + + fun createTheme(name: String = "theme-${++themeSequence}"): ThemeEntity { + val theme = ThemeFixture.create(name = name) + return persist(theme) + } + + fun createTime(startAt: LocalTime = LocalTime.of(10, 0).plusMinutes(++timeSequence * 10)): TimeEntity { + val time = TimeFixture.create(startAt = startAt) + return persist(time) + } + + fun createReservation( + date: LocalDate = LocalDate.now().plusDays(1), + theme: ThemeEntity = createTheme(), + time: TimeEntity = createTime(), + member: MemberEntity = createMember(), + status: ReservationStatus = ReservationStatus.CONFIRMED, + ): ReservationEntity { + val reservation = ReservationFixture.create( + date = date, + theme = theme, + time = time, + member = member, + status = status + ) + return persist(reservation) + } + + fun createPayment(reservation: ReservationEntity): PaymentEntity { + val payment = PaymentFixture.create(reservation = reservation) + return persist(payment) + } + + fun createReservationRequest( + theme: ThemeEntity = createTheme(), + time: TimeEntity = createTime(), + ): ReservationCreateWithPaymentRequest { + return ReservationFixture.createRequest( + themeId = theme.id!!, + timeId = time.id!!, + ) + } + + fun createDummyReservations( + memberCount: Int = 2, + reservationCount: Int = 10, + ): Map> { + val members = (1..memberCount).map { createMember(role = Role.MEMBER) } + val reservations = (1..reservationCount).map { index -> + createReservation( + member = members[index % memberCount], + status = ReservationStatus.CONFIRMED + ) + } + return reservations.groupBy { it.member } + } + + private fun persist(entity: T): T { + transactionTemplate.executeWithoutResult { + entityManager.persist(entity) + } + return entity + } +} \ No newline at end of file diff --git a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt index 80a534e7..4cecc9d1 100644 --- a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt +++ b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt @@ -13,12 +13,13 @@ import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.ThemeCreateRequest import roomescape.theme.web.ThemeRetrieveResponse +import roomescape.util.TsidFactory import roomescape.util.ThemeFixture class ThemeServiceTest : FunSpec({ val themeRepository: ThemeRepository = mockk() - val themeService = ThemeService(themeRepository) + val themeService = ThemeService(TsidFactory, themeRepository) context("findThemeById") { val themeId = 1L diff --git a/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt b/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt index bc7d35de..2784f4ea 100644 --- a/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt +++ b/src/test/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepositoryTest.kt @@ -8,7 +8,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import roomescape.theme.util.TestThemeCreateUtil import java.time.LocalDate -@DataJpaTest +@DataJpaTest(showSql = false) class ThemeRepositoryTest( val themeRepository: ThemeRepository, val entityManager: EntityManager diff --git a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt index 7a6a130d..a6cc1dff 100644 --- a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt +++ b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt @@ -10,6 +10,7 @@ import io.mockk.just import io.mockk.runs import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import roomescape.auth.exception.AuthErrorCode diff --git a/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt index 125976bf..73266aaf 100644 --- a/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt +++ b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt @@ -14,6 +14,7 @@ import roomescape.time.exception.TimeErrorCode import roomescape.time.exception.TimeException import roomescape.time.infrastructure.persistence.TimeRepository import roomescape.time.web.TimeCreateRequest +import roomescape.util.TsidFactory import roomescape.util.TimeFixture import java.time.LocalTime @@ -22,6 +23,7 @@ class TimeServiceTest : FunSpec({ val reservationRepository: ReservationRepository = mockk() val timeService = TimeService( + tsidFactory = TsidFactory, timeRepository = timeRepository, reservationRepository = reservationRepository ) diff --git a/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt b/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt index 5c99b07d..bc79357b 100644 --- a/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt +++ b/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt @@ -7,7 +7,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import roomescape.util.TimeFixture import java.time.LocalTime -@DataJpaTest +@DataJpaTest(showSql = false) class TimeRepositoryTest( val entityManager: EntityManager, val timeRepository: TimeRepository, diff --git a/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt index b9ef49a9..c5a57e44 100644 --- a/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt +++ b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt @@ -9,6 +9,7 @@ import io.mockk.every import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.Import +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc @@ -27,7 +28,6 @@ import java.time.LocalDate import java.time.LocalTime @WebMvcTest(TimeController::class) -@Import(JacksonConfig::class) class TimeControllerTest( val mockMvc: MockMvc, ) : RoomescapeApiTest() { diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index 43fa2824..526e3d16 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -1,7 +1,9 @@ package roomescape.util +import com.github.f4b6a3.tsid.TsidFactory import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.LoginRequest +import roomescape.common.config.next import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role import roomescape.payment.infrastructure.client.PaymentApproveRequest @@ -20,11 +22,14 @@ import java.time.LocalDate import java.time.LocalTime import java.time.OffsetDateTime + +val TsidFactory: TsidFactory = TsidFactory(0) + object MemberFixture { const val NOT_LOGGED_IN_USERID: Long = 0 fun create( - id: Long? = null, + id: Long? = TsidFactory.next(), name: String = "sangdol", account: String = "default", password: String = "password", @@ -56,14 +61,14 @@ object MemberFixture { object TimeFixture { fun create( - id: Long? = null, + id: Long? = TsidFactory.next(), startAt: LocalTime = LocalTime.now().plusHours(1), ): TimeEntity = TimeEntity(id, startAt) } object ThemeFixture { fun create( - id: Long? = null, + id: Long? = TsidFactory.next(), name: String = "Default Theme", description: String = "Default Description", thumbnail: String = "https://example.com/default-thumbnail.jpg" @@ -72,7 +77,7 @@ object ThemeFixture { object ReservationFixture { fun create( - id: Long? = null, + id: Long? = TsidFactory.next(), date: LocalDate = LocalDate.now().plusWeeks(1), theme: ThemeEntity = ThemeFixture.create(), time: TimeEntity = TimeFixture.create(), @@ -125,14 +130,14 @@ object PaymentFixture { const val AMOUNT: Long = 10000L fun create( - id: Long? = null, + id: Long? = TsidFactory.next(), orderId: String = ORDER_ID, paymentKey: String = PAYMENT_KEY, totalAmount: Long = AMOUNT, reservation: ReservationEntity = ReservationFixture.create(id = 1L), approvedAt: OffsetDateTime = OffsetDateTime.now() ): PaymentEntity = PaymentEntity( - id = id, + _id = id, orderId = orderId, paymentKey = paymentKey, totalAmount = totalAmount, @@ -141,14 +146,14 @@ object PaymentFixture { ) fun createCanceled( - id: Long? = null, + id: Long? = TsidFactory.next(), paymentKey: String = PAYMENT_KEY, cancelReason: String = "Test Cancel", cancelAmount: Long = AMOUNT, approvedAt: OffsetDateTime = OffsetDateTime.now(), canceledAt: OffsetDateTime = approvedAt.plusHours(1) ): CanceledPaymentEntity = CanceledPaymentEntity( - id = id, + _id = id, paymentKey = paymentKey, cancelReason = cancelReason, cancelAmount = cancelAmount, diff --git a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt index f5e40349..9a038941 100644 --- a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt +++ b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt @@ -1,10 +1,16 @@ package roomescape.util import com.fasterxml.jackson.databind.ObjectMapper +import com.github.f4b6a3.tsid.TsidFactory import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.SpykBean import io.kotest.core.spec.style.BehaviorSpec import io.mockk.every +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -21,6 +27,8 @@ import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID +@Import(TestConfig::class, JacksonConfig::class) +@MockkBean(JpaMetamodelMappingContext::class) abstract class RoomescapeApiTest : BehaviorSpec() { @SpykBean @@ -128,3 +136,10 @@ abstract class RoomescapeApiTest : BehaviorSpec() { """.trimIndent() ) } + +@TestConfiguration +class TestConfig { + @Bean + @Primary + fun tsidFactory(): TsidFactory = TsidFactory +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 00000000..7a658a0e --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,5 @@ +logging: + level: + root: INFO + org.springframework.orm.jpa: INFO + org.springframework.transaction: DEBUG diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml deleted file mode 100644 index 98b14091..00000000 --- a/src/test/resources/application.yaml +++ /dev/null @@ -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