generated from pricelees/issue-pr-template
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 329139b87c | |||
| 1172a685fc | |||
| 7a79ad68d6 | |||
| b2ff133273 | |||
| 71a106cde5 | |||
| 15b9ef4a08 | |||
| cfd35b875b | |||
| a49d36ed34 | |||
| 992ac4232c | |||
| 3f5af93817 | |||
| 612bbfbddc | |||
| c0c2ef21c6 | |||
| 8a126344f0 | |||
| 769576a8d5 | |||
| d2e2c9c888 | |||
| 2af0923189 | |||
| d293b56b0f | |||
| 9e86222d5b | |||
| e5002ae540 | |||
| 9a38d8beb8 | |||
| b986a20004 | |||
| 6db8730764 | |||
| d96c890dc0 | |||
| 0c83798b3f | |||
| 526598a24f |
10
Dockerfile
Normal file
10
Dockerfile
Normal 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"]
|
||||||
@ -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
6
frontend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
|
build
|
||||||
@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL = "http://localhost:8080"
|
VITE_API_BASE_URL = '/api'
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
15
frontend/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
src/main/kotlin/roomescape/common/config/TsidConfig.kt
Normal file
17
src/main/kotlin/roomescape/common/config/TsidConfig.kt
Normal 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()
|
||||||
35
src/main/kotlin/roomescape/common/entity/BaseEntity.kt
Normal file
35
src/main/kotlin/roomescape/common/entity/BaseEntity.kt
Normal 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?
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 = "시간 식별자")
|
||||||
|
|||||||
42
src/main/resources/application-deploy.yaml
Normal file
42
src/main/resources/application-deploy.yaml
Normal 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}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
95
src/main/resources/logback-deploy.xml
Normal file
95
src/main/resources/logback-deploy.xml
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
63
src/main/resources/schema/schema-h2.sql
Normal file
63
src/main/resources/schema/schema-h2.sql
Normal 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
|
||||||
|
);
|
||||||
69
src/main/resources/schema/schema-mysql.sql
Normal file
69
src/main/resources/schema/schema-mysql.sql
Normal 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
|
||||||
|
);
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
5
src/test/resources/application-test.yaml
Normal file
5
src/test/resources/application-test.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
org.springframework.orm.jpa: INFO
|
||||||
|
org.springframework.transaction: DEBUG
|
||||||
@ -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
|
|
||||||
Loading…
x
Reference in New Issue
Block a user