From 59907bd6436aa39b638acc23475d2809e9777a4e Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 9 Sep 2025 15:57:19 +0900 Subject: [PATCH 01/73] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20LoginUtil=20->=20AuthUtil=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/PaymentAPITest.kt | 16 ++--- .../reservation/ReservationApiTest.kt | 68 +++++++++---------- .../roomescape/schedule/ScheduleApiTest.kt | 38 +++++------ .../kotlin/roomescape/theme/ThemeApiTest.kt | 32 ++++----- .../kotlin/roomescape/util/KotestConfig.kt | 4 +- .../roomescape/util/RestAssuredUtils.kt | 2 +- 6 files changed, 80 insertions(+), 80 deletions(-) diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index e7fd99ef..9ae9b620 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -145,8 +145,8 @@ class PaymentAPITest( PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser() + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser() ) val request = PaymentFixture.confirmRequest @@ -163,7 +163,7 @@ class PaymentAPITest( ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(PaymentFixture.confirmRequest) }, @@ -182,7 +182,7 @@ class PaymentAPITest( context("결제를 취소한다.") { test("정상 취소") { - val token = loginUtil.loginAsAdmin() + val token = authUtil.loginAsAdmin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( adminToken = token, @@ -230,7 +230,7 @@ class PaymentAPITest( } test("예약에 대한 결제 정보가 없으면 실패한다.") { - val token = loginUtil.loginAsAdmin() + val token = authUtil.loginAsAdmin() val reservation = dummyInitializer.createConfirmReservation( adminToken = token, reserverToken = token, @@ -282,8 +282,8 @@ class PaymentAPITest( val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser(), ) val method = if (easyPayDetail != null) { @@ -305,7 +305,7 @@ class PaymentAPITest( } returns clientResponse runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(request) }, diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index f9589a50..87ac97b6 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -38,13 +38,13 @@ class ReservationApiTest( test("정상 생성") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.HOLD ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, @@ -67,13 +67,13 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.AVAILABLE ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, @@ -88,7 +88,7 @@ class ReservationApiTest( } test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { - val adminToken = loginUtil.loginAsAdmin() + val adminToken = authUtil.loginAsAdmin() val theme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = ThemeFixture.createRequest @@ -101,7 +101,7 @@ class ReservationApiTest( ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body( commonRequest.copy( @@ -120,7 +120,7 @@ class ReservationApiTest( ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body( commonRequest.copy( @@ -142,7 +142,7 @@ class ReservationApiTest( context("필수 입력값이 입력되지 않으면 실패한다.") { test("예약자명") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(commonRequest.copy(reserverName = "")) }, @@ -158,7 +158,7 @@ class ReservationApiTest( test("예약자 연락처") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(commonRequest.copy(reserverContact = "")) }, @@ -176,10 +176,10 @@ class ReservationApiTest( context("예약을 확정한다.") { test("정상 응답") { - val userToken = loginUtil.loginAsUser() + val userToken = authUtil.loginAsUser() val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), reserverToken = userToken, ) @@ -205,7 +205,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { post("/reservations/$INVALID_PK/confirm") }, @@ -219,10 +219,10 @@ class ReservationApiTest( context("예약을 취소한다.") { test("정상 응답") { - val userToken = loginUtil.loginAsUser() + val userToken = authUtil.loginAsUser() val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), reserverToken = userToken, ) @@ -251,7 +251,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(ReservationCancelRequest(cancelReason = "test")) }, @@ -267,11 +267,11 @@ class ReservationApiTest( test("관리자가 아닌 회원은 다른 회원의 예약을 취소할 수 없다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser(), ) - val otherUserToken = loginUtil.login("other@example.com", "other", role = Role.MEMBER) + val otherUserToken = authUtil.login("other@example.com", "other", role = Role.MEMBER) runTest( token = otherUserToken, @@ -290,11 +290,11 @@ class ReservationApiTest( test("관리자는 다른 회원의 예약을 취소할 수 있다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsAdmin(), ) - val otherAdminToken = loginUtil.login("admin1@example.com", "admin1", role = Role.ADMIN) + val otherAdminToken = authUtil.login("admin1@example.com", "admin1", role = Role.ADMIN) runTest( token = otherAdminToken, @@ -322,8 +322,8 @@ class ReservationApiTest( context("나의 예약 목록을 조회한다.") { test("정상 응답") { - val userToken = loginUtil.loginAsUser() - val adminToken = loginUtil.loginAsAdmin() + val userToken = authUtil.loginAsUser() + val adminToken = authUtil.loginAsAdmin() for (i in 1..3) { dummyInitializer.createConfirmReservation( @@ -362,8 +362,8 @@ class ReservationApiTest( beforeTest { reservation = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser(), ) } @@ -428,7 +428,7 @@ class ReservationApiTest( ) val cancelReason = "테스트입니다." - val memberId = loginUtil.getUser().id!! + val memberId = authUtil.getUser().id!! dummyInitializer.cancelPayment( memberId = memberId, @@ -495,7 +495,7 @@ class ReservationApiTest( test("예약이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/reservations/$INVALID_PK/detail") }, @@ -508,12 +508,12 @@ class ReservationApiTest( test("예약은 있지만, 결제 정보가 없으면 실패한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser(), ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/reservations/${reservation.id}/detail") }, @@ -526,8 +526,8 @@ class ReservationApiTest( test("예약과 결제는 있지만, 결제 세부 내역이 없으면 실패한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = loginUtil.loginAsAdmin(), - reserverToken = loginUtil.loginAsUser(), + adminToken = authUtil.loginAsAdmin(), + reserverToken = authUtil.loginAsUser(), ) dummyInitializer.createPayment( @@ -538,7 +538,7 @@ class ReservationApiTest( } runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/reservations/${reservation.id}/detail") }, @@ -555,7 +555,7 @@ class ReservationApiTest( reservation: ReservationEntity ): LinkedHashMap { return runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/reservations/${reservation.id}/detail") }, diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 28dc692a..e8164fee 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -35,7 +35,7 @@ class ScheduleApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsUser() + token = authUtil.loginAsUser() } val commonAssertion: ValidatableResponse.() -> Unit = { @@ -107,7 +107,7 @@ class ScheduleApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsUser() + token = authUtil.loginAsUser() } test("예약 가능 테마 조회: GET /schedules/themes?date={date}") { @@ -184,7 +184,7 @@ class ScheduleApiTest( } runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/schedules/themes?date=$date") }, @@ -216,7 +216,7 @@ class ScheduleApiTest( } runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/schedules?date=$date&themeId=${createdSchedule.themeId}") }, @@ -237,7 +237,7 @@ class ScheduleApiTest( val createdSchedule = createDummySchedule(createRequest) runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { get("/schedules/${createdSchedule.id}") }, @@ -261,7 +261,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { get("/schedules/$INVALID_PK") }, @@ -280,7 +280,7 @@ class ScheduleApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsAdmin() + token = authUtil.loginAsAdmin() } test("정상 생성 및 감사 정보 확인") { @@ -383,7 +383,7 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { patch("/schedules/${createdSchedule.id}/hold") }, @@ -400,7 +400,7 @@ class ScheduleApiTest( test("예약이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { patch("/schedules/$INVALID_PK/hold") }, @@ -419,7 +419,7 @@ class ScheduleApiTest( * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body( ScheduleUpdateRequest( @@ -436,7 +436,7 @@ class ScheduleApiTest( ) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { patch("/schedules/${createdSchedule.id}/hold") }, @@ -461,7 +461,7 @@ class ScheduleApiTest( time = LocalTime.now().plusMinutes(1), ) ) - val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + val otherAdminToken = authUtil.login("admin1@admin.com", "admin1", Role.ADMIN) runTest( token = otherAdminToken, @@ -490,7 +490,7 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body(ScheduleUpdateRequest()) }, @@ -510,7 +510,7 @@ class ScheduleApiTest( test("일정이 없으면 실패한다.") { runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body(updateRequest) }, @@ -530,7 +530,7 @@ class ScheduleApiTest( ) runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body( updateRequest.copy( @@ -554,7 +554,7 @@ class ScheduleApiTest( val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { delete("/schedules/${createdSchedule.id}") }, @@ -574,7 +574,7 @@ class ScheduleApiTest( * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body( ScheduleUpdateRequest( @@ -594,7 +594,7 @@ class ScheduleApiTest( * 삭제 테스트 */ runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { delete("/schedules/${createdSchedule.id}") }, @@ -608,7 +608,7 @@ class ScheduleApiTest( } fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity { - val token = loginUtil.loginAsAdmin() + val token = authUtil.loginAsAdmin() val themeId: Long = if (request.themeId > 1L) { request.themeId diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index e6aee6d0..c96fa6a1 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -35,7 +35,7 @@ class ThemeApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsUser() + token = authUtil.loginAsUser() } val commonAssertion: ValidatableResponse.() -> Unit = { @@ -102,10 +102,10 @@ class ThemeApiTest( context("일반 회원도 접근할 수 있다.") { test("테마 조회: GET /v2/themes") { - val token = loginUtil.loginAsUser() + val token = authUtil.loginAsUser() dummyInitializer.createTheme( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), request = createRequest.copy(name = "test123", isOpen = true) ) @@ -129,7 +129,7 @@ class ThemeApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsAdmin() + token = authUtil.loginAsAdmin() } test("정상 생성 및 감사 정보 확인") { @@ -340,7 +340,7 @@ class ThemeApiTest( beforeTest { for (i in 1..3) { dummyInitializer.createTheme( - adminToken = loginUtil.loginAsAdmin(), + adminToken = authUtil.loginAsAdmin(), request = createRequest.copy(name = "test$i") ).also { themeIds.add(it.id) @@ -354,7 +354,7 @@ class ThemeApiTest( test("정상 응답") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(ThemeListRetrieveRequest(themeIds)) }, @@ -372,7 +372,7 @@ class ThemeApiTest( themeIds.add(INVALID_PK) runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), using = { body(ThemeListRetrieveRequest(themeIds)) }, @@ -391,7 +391,7 @@ class ThemeApiTest( lateinit var token: String beforeTest { - token = loginUtil.loginAsAdmin() + token = authUtil.loginAsAdmin() dummyInitializer.createTheme( adminToken = token, request = createRequest.copy(name = "open", isOpen = true) @@ -420,7 +420,7 @@ class ThemeApiTest( test("예약 페이지에서는 공개된 테마의 전체 정보가 조회된다.") { runTest( - token = loginUtil.loginAsUser(), + token = authUtil.loginAsUser(), on = { get("/v2/themes") }, @@ -442,7 +442,7 @@ class ThemeApiTest( context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { test("정상 응답") { - val token = loginUtil.loginAsAdmin() + val token = authUtil.loginAsAdmin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -470,7 +470,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { get("/admin/themes/$INVALID_PK") }, @@ -484,7 +484,7 @@ class ThemeApiTest( context("테마를 삭제한다.") { test("정상 삭제") { - val token = loginUtil.loginAsAdmin() + val token = authUtil.loginAsAdmin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -505,7 +505,7 @@ class ThemeApiTest( test("테마가 없으면 실패한다.") { runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), on = { delete("/admin/themes/$INVALID_PK") }, @@ -525,7 +525,7 @@ class ThemeApiTest( val updateRequest = ThemeUpdateRequest(name = "modified") beforeTest { - token = loginUtil.loginAsAdmin() + token = authUtil.loginAsAdmin() createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest.copy(name = "theme-${Random.nextInt()}") @@ -534,7 +534,7 @@ class ThemeApiTest( } test("정상 수정 및 감사 정보 변경 확인") { - val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + val otherAdminToken = authUtil.login("admin1@admin.com", "admin1", Role.ADMIN) runTest( token = otherAdminToken, @@ -559,7 +559,7 @@ class ThemeApiTest( test("입력값이 없으면 수정하지 않는다.") { runTest( - token = loginUtil.loginAsAdmin(), + token = authUtil.loginAsAdmin(), using = { body(ThemeUpdateRequest()) }, diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/util/KotestConfig.kt index 6833fe8e..91c80938 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/util/KotestConfig.kt @@ -38,11 +38,11 @@ abstract class FunSpecSpringbootTest : FunSpec({ @LocalServerPort var port: Int = 0 - lateinit var loginUtil: LoginUtil + lateinit var authUtil: AuthUtil override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - loginUtil = LoginUtil(memberRepository) + authUtil = AuthUtil(memberRepository) } } diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index 64b6e6e4..752680d1 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -14,7 +14,7 @@ import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.Role -class LoginUtil( +class AuthUtil( private val memberRepository: MemberRepository ) { fun login(email: String, password: String, role: Role = Role.MEMBER): String { -- 2.47.2 From 75acdc2c2f193ed3322252b817cffdd01bae6840 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:24:25 +0900 Subject: [PATCH 02/73] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20/=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/schema-h2.sql | 54 ++++++++++++++++++++++ src/main/resources/schema/schema-mysql.sql | 54 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 64c1439d..978bbae1 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -18,6 +18,60 @@ create table if not exists members ( last_modified_at timestamp ); +create table if not exists users( + id bigint primary key, + name varchar(50) not null, + email varchar(255) not null, + password varchar(255) not null, + phone varchar(20) not null, + region_code varchar(10) not null, + status varchar(20) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + + constraint uk__users_email unique (email), + constraint uk__users_phone unique (phone), + constraint fk__users_region_code foreign key (region_code) references region (code) +); + +create table if not exists user_status_history( + id bigint primary key, + user_id bigint not null, + status varchar(20) not null, + reason varchar(255) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + + constraint fk__user_status_history_user_id foreign key (user_id) references users (id) +); + +create table if not exists admin( + id bigint primary key, + account varchar(20) not null, + password varchar(255) not null, + name varchar(20) not null, + phone varchar(20) not null, + permission_level varchar(20) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null +); + +create table if not exists login_history( + id bigint primary key, + principal_id bigint not null, + principal_type varchar(20) not null, + success boolean not null, + ip_address varchar(45) not null, + user_agent varchar(255) not null, + created_at timestamp not null +); + create table if not exists theme ( id bigint primary key , name varchar(30) not null, diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index cae84864..54d4d19d 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -19,6 +19,60 @@ create table if not exists members last_modified_at datetime(6) null ); +create table if not exists users( + id bigint primary key, + name varchar(50) not null, + email varchar(255) not null, + password varchar(255) not null, + phone varchar(20) not null, + region_code varchar(10) not null, + status varchar(20) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + + constraint uk__users_email unique (email), + constraint uk__users_phone unique (phone), + constraint fk__users_region_code foreign key (region_code) references region (code) +); + +create table if not exists user_status_history( + id bigint primary key, + user_id bigint not null, + status varchar(20) not null, + reason varchar(255) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + + constraint fk__user_status_history_user_id foreign key (user_id) references users (id) +); + +create table if not exists admin( + id bigint primary key, + account varchar(20) not null, + password varchar(255) not null, + name varchar(20) not null, + phone varchar(20) not null, + permission_level varchar(20) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null +); + +create table if not exists login_history( + id bigint primary key, + principal_id bigint not null, + principal_type varchar(20) not null, + success boolean not null, + ip_address varchar(45) not null, + user_agent varchar(255) not null, + created_at datetime(6) not null +); + create table if not exists theme ( id bigint primary key , name varchar(30) not null, -- 2.47.2 From c15e0f456ec49a6ca8e908b45bb64a0869381e7f Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:26:16 +0900 Subject: [PATCH 03/73] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20/=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20Entity=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/AdminEntity.kt | 55 +++++++++++++++++++ .../persistence/AdminRepository.kt | 5 ++ .../persistence/LoginHistoryEntity.kt | 28 ++++++++++ .../persistence/LoginHistoryRepository.kt | 5 ++ .../persistence/UserEntities.kt | 46 ++++++++++++++++ .../persistence/UserRepositories.kt | 7 +++ 6 files changed, 146 insertions(+) create mode 100644 src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt create mode 100644 src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt create mode 100644 src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt create mode 100644 src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt create mode 100644 src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt create mode 100644 src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt diff --git a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt new file mode 100644 index 00000000..5f98745e --- /dev/null +++ b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt @@ -0,0 +1,55 @@ +package roomescape.admin.infrastructure.persistence + +import jakarta.persistence.Entity +import jakarta.persistence.EntityListeners +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import roomescape.common.entity.AuditingBaseEntity + +@Entity +@Table(name = "admin") +@EntityListeners(AuditingEntityListener::class) +class AdminEntity( + id: Long, + + val account: String, + var password: String, + val name: String, + var phone: String, + + @Enumerated(value = EnumType.STRING) + var permissionLevel: AdminPermissionLevel + +) : AuditingBaseEntity(id) + +enum class AdminPermissionLevel( + val privileges: Set +) { + FULL_ACCESS( + privileges = setOf(Privilege.MANAGE) + ), + WRITABLE( + privileges = (Privilege.entries.toSet() - Privilege.MANAGE) + ), + READ_ALL( + privileges = setOf(Privilege.READ_DETAIL, Privilege.READ_SUMMARY) + ), + READ_SUMMARY( + privileges = setOf(Privilege.READ_SUMMARY) + ); + + fun hasPrivilege(privilege: Privilege): Boolean { + return this == FULL_ACCESS || this.privileges.contains(privilege) + } +} + +enum class Privilege { + MANAGE, + CREATE, + UPDATE, + DELETE, + READ_DETAIL, + READ_SUMMARY, +} diff --git a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt new file mode 100644 index 00000000..fd86eedf --- /dev/null +++ b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt @@ -0,0 +1,5 @@ +package roomescape.admin.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface AdminRepository : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt new file mode 100644 index 00000000..126e6a9e --- /dev/null +++ b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryEntity.kt @@ -0,0 +1,28 @@ +package roomescape.auth.infrastructure.persistence + +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import roomescape.common.dto.PrincipalType +import roomescape.common.entity.PersistableBaseEntity +import java.time.LocalDateTime + +@Entity +@Table(name = "login_history") +@EntityListeners(AuditingEntityListener::class) +class LoginHistoryEntity( + id: Long, + + val principalId: Long, + + @Enumerated(value = EnumType.STRING) + val principalType: PrincipalType, + + val success: Boolean, + val ipAddress: String, + val userAgent: String, + + @Column(updatable = false) + @CreatedDate + var createdAt: LocalDateTime? = null, +) : PersistableBaseEntity(id) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt new file mode 100644 index 00000000..5f131468 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt @@ -0,0 +1,5 @@ +package roomescape.auth.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface LoginHistoryRepository : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt new file mode 100644 index 00000000..407a83a1 --- /dev/null +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt @@ -0,0 +1,46 @@ +package roomescape.member.infrastructure.persistence + +import jakarta.persistence.* +import roomescape.common.entity.AuditingBaseEntity + +@Entity +@Table(name = "users") +class UserEntity( + id: Long, + + val name: String, + + @Column(unique = true) + val email: String, + + var password: String, + + @Column(unique = true) + var phone: String, + + var regionCode: String?, + + @Enumerated(value = EnumType.STRING) + var status: UserStatus +): AuditingBaseEntity(id) { + + fun updateStatus(status: UserStatus) { + this.status = status + } +} + +@Entity +@Table(name = "user_status_history") +class UserStatusHistoryEntity( + id: Long, + + val userId: Long, + val reason: String, + + @Enumerated(value = EnumType.STRING) + val status: UserStatus, +) : AuditingBaseEntity(id) + +enum class UserStatus { + ACTIVE, DORMANT, WITHDRAWN, SUSPENDED +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt new file mode 100644 index 00000000..e9aee97b --- /dev/null +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt @@ -0,0 +1,7 @@ +package roomescape.member.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository + +interface UserStatusHistoryRepository : JpaRepository -- 2.47.2 From 573ab14acacea07a4b549dcf4a8448ffe5344c1b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:28:32 +0900 Subject: [PATCH 04/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20use?= =?UTF-8?q?r=20/=20admin=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/exception/AdminException.kt | 16 ++++++++++++++++ .../roomescape/member/exception/UserException.kt | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/main/kotlin/roomescape/admin/exception/AdminException.kt create mode 100644 src/main/kotlin/roomescape/member/exception/UserException.kt diff --git a/src/main/kotlin/roomescape/admin/exception/AdminException.kt b/src/main/kotlin/roomescape/admin/exception/AdminException.kt new file mode 100644 index 00000000..3347ab4d --- /dev/null +++ b/src/main/kotlin/roomescape/admin/exception/AdminException.kt @@ -0,0 +1,16 @@ +package roomescape.admin.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class AdminException( + override val errorCode: AdminErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class AdminErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode diff --git a/src/main/kotlin/roomescape/member/exception/UserException.kt b/src/main/kotlin/roomescape/member/exception/UserException.kt new file mode 100644 index 00000000..93115986 --- /dev/null +++ b/src/main/kotlin/roomescape/member/exception/UserException.kt @@ -0,0 +1,16 @@ +package roomescape.member.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class UserException( + override val errorCode: UserErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) + +enum class UserErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode -- 2.47.2 From f32613d6d99f21497d8943d613907543c88bf9ce Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:31:15 +0900 Subject: [PATCH 05/73] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EC=9A=B4=20user=20/=20admin=20Fixture=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/util/Fixtures.kt | 57 +++++++++++++++++++ .../kotlin/roomescape/util/KotestConfig.kt | 10 +++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index a4eb0d8a..4abd39f9 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -1,8 +1,15 @@ package roomescape.util import com.github.f4b6a3.tsid.TsidFactory +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.common.config.next import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role +import roomescape.member.infrastructure.persistence.UserEntity +import roomescape.member.infrastructure.persistence.UserStatus +import roomescape.member.web.MIN_PASSWORD_LENGTH +import roomescape.member.web.UserCreateRequest import roomescape.payment.infrastructure.client.* import roomescape.payment.infrastructure.common.* import roomescape.payment.web.PaymentCancelRequest @@ -36,6 +43,56 @@ object MemberFixture { ) } +object AdminFixture { + val default: AdminEntity = create() + + fun create( + id: Long = tsidFactory.next(), + account: String = "admin", + password: String = "adminPassword", + name: String = "admin12345", + phone: String = "01012345678", + permissionLevel: AdminPermissionLevel = AdminPermissionLevel.FULL_ACCESS + ): AdminEntity = AdminEntity( + id = id, + account = account, + password = password, + name = name, + phone = phone, + permissionLevel = permissionLevel + ) +} + +object UserFixture { + val default: UserEntity = createUser() + + fun createUser( + id: Long = tsidFactory.next(), + name: String = "sample", + email: String = "sample@example.com", + password: String = "a".repeat(MIN_PASSWORD_LENGTH), + phone: String = "01012345678", + regionCode: String = "1130510300", + status: UserStatus = UserStatus.ACTIVE + ): UserEntity = UserEntity( + id = id, + name = name, + email = email, + password = password, + phone = phone, + regionCode = regionCode, + status = status + ) + + val createRequest: UserCreateRequest = UserCreateRequest( + name = "sample", + email = "sample@example.com", + password = "a".repeat(MIN_PASSWORD_LENGTH), + phone = "01012345678", + regionCode = "1130510300" + ) +} + object ThemeFixture { val createRequest: ThemeCreateRequest = ThemeCreateRequest( name = "Matilda Green", diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/util/KotestConfig.kt index 91c80938..5342a5e3 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/util/KotestConfig.kt @@ -12,7 +12,9 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import +import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.member.infrastructure.persistence.MemberRepository +import roomescape.member.infrastructure.persistence.UserRepository import roomescape.payment.business.PaymentWriter import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository @@ -32,6 +34,12 @@ abstract class FunSpecSpringbootTest : FunSpec({ @Autowired private lateinit var memberRepository: MemberRepository + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var adminRepository: AdminRepository + @Autowired lateinit var dummyInitializer: DummyInitializer @@ -42,7 +50,7 @@ abstract class FunSpecSpringbootTest : FunSpec({ override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - authUtil = AuthUtil(memberRepository) + authUtil = AuthUtil(memberRepository, userRepository, adminRepository) } } -- 2.47.2 From c9b7c9d4f1b4e163bbae3f1b432d8f2614f92855 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:33:45 +0900 Subject: [PATCH 06/73] =?UTF-8?q?feat:=20user=20/=20admin=20/=20auth=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=97=AD=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/dto/CommonAuth.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/kotlin/roomescape/common/dto/CommonAuth.kt diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt new file mode 100644 index 00000000..139f4429 --- /dev/null +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -0,0 +1,29 @@ +package roomescape.common.dto + +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel + +abstract class LoginCredentials { + abstract val id: Long + abstract val password: String +} + +data class AdminLoginCredentials( + override val id: Long, + override val password: String, + val permissionLevel: AdminPermissionLevel +) : LoginCredentials() + +data class UserLoginCredentials( + override val id: Long, + override val password: String, +) : LoginCredentials() + +data class CurrentUserContext( + val id: Long, + val name: String, + val type: PrincipalType +); + +enum class PrincipalType { + USER, ADMIN +} -- 2.47.2 From da9c7953f49633f8fd5349981b448771ad1bd655 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:49:30 +0900 Subject: [PATCH 07/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EA=B2=8C=20=EB=90=A0=20subject=20+=20claim?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=EC=9D=98=20JwtUtils=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtUtils.kt | 99 +++++++++++++++++++ .../kotlin/roomescape/auth/JwtUtilsTest.kt | 63 ++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt create mode 100644 src/test/kotlin/roomescape/auth/JwtUtilsTest.kt diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt new file mode 100644 index 00000000..88fd9b18 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -0,0 +1,99 @@ +package roomescape.auth.infrastructure.jwt + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import java.util.* +import javax.crypto.SecretKey + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class JwtUtils( + @Value("\${security.jwt.token.secret-key}") + private val secretKeyString: String, + + @Value("\${security.jwt.token.ttl-seconds}") + private val tokenTtlSeconds: Long +) { + private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) + + fun createToken(subject: String, claims: Map): String { + log.debug { "[JwtUtils.createToken] 토큰 생성 시작: subject=$subject, claims=${claims}" } + + val date = Date() + val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) + + return Jwts.builder() + .subject(subject) + .claims(claims) + .issuedAt(date) + .expiration(accessTokenExpiredAt) + .signWith(secretKey) + .compact() + .also { + log.debug { "[JwtUtils.createToken] 토큰 생성 완료. token=${it}" } + } + } + + fun extractSubject(token: String?): String { + return runWithHandle { + log.debug { "[JwtUtils.extractSubject] subject 조회 시작: token=$token" } + + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + .subject + ?.also { + log.debug { "[JwtUtils.extractSubject] subject 조회 완료: subject=${it}" } + } + ?: run { + log.debug { "[JwtUtils.extractSubject] subject 조회 실패: token=${token}" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + fun extractClaim(token: String?, key: String): String { + return runWithHandle { + log.debug { "[JwtUtils.extractClaim] claim 조회 시작: token=$token, claimKey=$key" } + + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + .get(key, String::class.java) + ?.also { + log.debug { "[JwtHandler.extractClaim] claim 조회 완료: claim=${it}" } + } + ?: run { + log.info { "[JwtUtils.extractClaim] claim=${key} 조회 실패: token=$token" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + private fun runWithHandle(block: () -> T): T { + try { + return block() + } catch (e: AuthException) { + throw e + } catch (_: IllegalArgumentException) { + throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) + } catch (_: ExpiredJwtException) { + throw AuthException(AuthErrorCode.EXPIRED_TOKEN) + } catch (e: Exception) { + log.warn { "[JwtUtils] 예외 발생: message=${e.message}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + } +} diff --git a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt new file mode 100644 index 00000000..627ed8dd --- /dev/null +++ b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt @@ -0,0 +1,63 @@ +package roomescape.auth + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.assertThrows +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.common.config.next +import roomescape.util.tsidFactory + +class JwtUtilsTest( +) : FunSpec() { + private val jwtUtils: JwtUtils = JwtUtils(secretKeyString = "caSf+JhhY9J9VcZxDQ7SNNOEIAJSZ9onsFstGNv9bjPHmHoTTcX+5wway5+//SPi", tokenTtlSeconds = 5) + + init { + context("종합 테스트") { + test("Subject + Claim을 담아 토큰을 생성한 뒤 읽어온다.") { + val subject = "${tsidFactory.next()}" + val claim = mapOf("name" to "sangdol") + + jwtUtils.createToken(subject, claim).also { token -> + val extractedSubject = jwtUtils.extractSubject(token) + val name = jwtUtils.extractClaim(token, "name") + + extractedSubject shouldBe subject + name shouldBe "sangdol" + } + } + } + + context("실패 테스트") { + val subject = "${tsidFactory.next()}" + val claim = mapOf("name" to "sangdol") + val commonToken = jwtUtils.createToken(subject, claim) + + context("subject를 가져올 때 null 토큰을 입력하면 실패한다.") { + shouldThrow { + jwtUtils.extractSubject(null) + }.also { + it.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND + } + } + + context("claim을 가져올 때 null 토큰을 입력하면 실패한다.") { + shouldThrow { + jwtUtils.extractClaim(token = null, key = "") + }.also { + it.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND + } + } + + context("토큰은 유효하나 claim이 없으면 실패한다.") { + shouldThrow { + jwtUtils.extractClaim(token = commonToken, key = "abcde") + }.also { + it.errorCode shouldBe AuthErrorCode.MEMBER_NOT_FOUND + } + } + } + } +} \ No newline at end of file -- 2.47.2 From 8c7bf2980fabc5c3d8facb4b32816733e62e8b70 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:50:55 +0900 Subject: [PATCH 08/73] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=ED=95=9C=20Credential=EA=B3=BC=20Context?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20admin=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/admin/business/AdminService.kt | 58 +++++++++++++++++++ .../admin/exception/AdminException.kt | 4 +- .../persistence/AdminRepository.kt | 4 +- 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/roomescape/admin/business/AdminService.kt diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt new file mode 100644 index 00000000..ac121aee --- /dev/null +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -0,0 +1,58 @@ +package roomescape.admin.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.admin.exception.AdminErrorCode +import roomescape.admin.exception.AdminException +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminRepository +import roomescape.common.dto.AdminLoginCredentials +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.PrincipalType + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class AdminService( + private val adminRepository: AdminRepository, +) { + @Transactional(readOnly = true) + fun findContextById(id: Long): CurrentUserContext { + log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 시작: id=${id}" } + + val admin: AdminEntity = findOrThrow(id) + + return CurrentUserContext(admin.id, admin.name, PrincipalType.ADMIN).also { + log.info { "[AdminService.findById] 현재 로그인된 관리자 조회 완료: id=${id}" } + } + } + + @Transactional(readOnly = true) + fun findCredentialsByAccount(account: String): AdminLoginCredentials { + log.info { "[AdminService.findInfoByAccount] 관리자 조회 시작: account=${account}" } + + return adminRepository.findByAccount(account) + ?.let { + log.info { "[AdminService.findByAccount] 관리자 조회 완료: id=${it.id}" } + AdminLoginCredentials(it.id, it.password, it.permissionLevel) + } + ?: run { + log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패" } + throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) + } + } + + private fun findOrThrow(id: Long): AdminEntity { + log.info { "[AdminService.findOrThrow] 조회 시작: id=${id}" } + + return adminRepository.findByIdOrNull(id) + ?.also { log.info { "[AdminService.findOrThrow] 조회 완료: id=${id}, name=${it.name}" } } + ?: run { + log.info { "[AdminService.findOrThrow] 조회 실패: id=${id}" } + throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/admin/exception/AdminException.kt b/src/main/kotlin/roomescape/admin/exception/AdminException.kt index 3347ab4d..a9bfcc8c 100644 --- a/src/main/kotlin/roomescape/admin/exception/AdminException.kt +++ b/src/main/kotlin/roomescape/admin/exception/AdminException.kt @@ -13,4 +13,6 @@ enum class AdminErrorCode( override val httpStatus: HttpStatus, override val errorCode: String, override val message: String -) : ErrorCode +) : ErrorCode { + ADMIN_NOT_FOUND(HttpStatus.NOT_FOUND, "A001", "관리자를 찾을 수 없어요."), +} diff --git a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt index fd86eedf..7520c23c 100644 --- a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt +++ b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminRepository.kt @@ -2,4 +2,6 @@ package roomescape.admin.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -interface AdminRepository : JpaRepository \ No newline at end of file +interface AdminRepository : JpaRepository { + fun findByAccount(account: String): AdminEntity? +} -- 2.47.2 From 39da28d3f1e12dd335ee73032b282be2e269494b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:52:56 +0900 Subject: [PATCH 09/73] =?UTF-8?q?feat:=20=EC=9D=B4=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=ED=95=9C=20Credential=EA=B3=BC=20Context?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20user=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/member/business/UserService.kt | 52 +++++++++++++++++++ .../persistence/UserRepositories.kt | 5 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/roomescape/member/business/UserService.kt diff --git a/src/main/kotlin/roomescape/member/business/UserService.kt b/src/main/kotlin/roomescape/member/business/UserService.kt new file mode 100644 index 00000000..036f5033 --- /dev/null +++ b/src/main/kotlin/roomescape/member/business/UserService.kt @@ -0,0 +1,52 @@ +package roomescape.member.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.PrincipalType +import roomescape.common.dto.UserLoginCredentials +import roomescape.member.exception.UserErrorCode +import roomescape.member.exception.UserException +import roomescape.member.infrastructure.persistence.UserEntity +import roomescape.member.infrastructure.persistence.UserRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class UserService( + private val userRepository: UserRepository, +) { + @Transactional(readOnly = true) + fun findContextById(id: Long): CurrentUserContext { + log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 시작: id=${id}" } + val user: UserEntity = findOrThrow(id) + + return CurrentUserContext(user.id, user.name, PrincipalType.USER) + .also { + log.info { "[UserService.findContextById] 현재 로그인된 회원 조회 완료: id=${id}" } + } + } + + @Transactional(readOnly = true) + fun findCredentialsByAccount(email: String): UserLoginCredentials { + log.info { "[UserService.findCredentialsByAccount] 회원 조회 시작: email=${email}" } + + return userRepository.findByEmail(email) + ?.let { + log.info { "[UserService.findCredentialsByAccount] 회원 조회 완료: id=${it.id}" } + UserLoginCredentials(it.id, it.password) + } + ?: run { + log.info { "[UserService.findCredentialsByAccount] 회원 조회 실패" } + throw UserException(UserErrorCode.USER_NOT_FOUND) + } + } + + private fun findOrThrow(id: Long): UserEntity { + return userRepository.findByIdOrNull(id) + ?: throw UserException(UserErrorCode.USER_NOT_FOUND) + } +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt index e9aee97b..13a0adb3 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt @@ -2,6 +2,9 @@ package roomescape.member.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -interface UserRepository : JpaRepository +interface UserRepository : JpaRepository { + + fun findByEmail(email: String): UserEntity? +} interface UserStatusHistoryRepository : JpaRepository -- 2.47.2 From e02086680be45665e4205f310dbbbe673315d06c Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:58:05 +0900 Subject: [PATCH 10/73] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20API=EC=97=90=20=EC=82=AC=EC=9A=A9=ED=95=A0?= =?UTF-8?q?=20=EC=83=88=EB=A1=9C=EC=9A=B4=20Interceptor=20=EB=B0=8F=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/web/support/AuthAnnotations.kt | 10 +++- .../support/interceptors/AdminInterceptor.kt | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 3f98cdf1..2fb7af52 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -1,5 +1,7 @@ package roomescape.auth.web.support +import roomescape.admin.infrastructure.persistence.Privilege + @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class Admin @@ -10,4 +12,10 @@ annotation class LoginRequired @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) -annotation class MemberId \ No newline at end of file +annotation class MemberId + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class AdminOnly( + val privilege: Privilege +) diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt new file mode 100644 index 00000000..6361fdca --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -0,0 +1,54 @@ +package roomescape.auth.web.support.interceptors + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.auth.business.CLAIM_PERMISSION_KEY +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.support.AdminOnly +import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import roomescape.auth.web.support.accessToken + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class AdminInterceptor( + private val jwtUtils: JwtUtils +) : HandlerInterceptor { + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any + ): Boolean { + if (handler !is HandlerMethod) { + return true + } + + val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true + + val token: String? = request.accessToken() + val adminId = jwtUtils.extractSubject(token).also { MDC.put(MDC_MEMBER_ID_KEY, it) } + + jwtUtils.extractClaim( + token = token, key = CLAIM_PERMISSION_KEY + ).also { + val permission = AdminPermissionLevel.valueOf(it) + + if (!permission.hasPrivilege(annotation.privilege)) { + log.warn { "[AuthInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) + } + log.info { "[AuthInterceptor] 인증 완료. adminId=$adminId, permission=${permission}" } + } + + return true + } +} -- 2.47.2 From c79a4bdd1f063498c5651c615bd94a6a4a8488fb Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 16:58:29 +0900 Subject: [PATCH 11/73] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=90=9C=20=ED=9A=8C=EC=9B=90=20=EC=A0=84=EC=9A=A9=20API?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20Interceptor=20=EB=B0=8F=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/web/support/AuthAnnotations.kt | 4 ++ .../support/interceptors/UserInterceptor.kt | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 2fb7af52..55490e4b 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -19,3 +19,7 @@ annotation class MemberId annotation class AdminOnly( val privilege: Privilege ) + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class UserOnly diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt new file mode 100644 index 00000000..c350f2ec --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -0,0 +1,50 @@ +package roomescape.auth.web.support.interceptors + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor +import roomescape.auth.business.CLAIM_TYPE_KEY +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import roomescape.auth.web.support.UserOnly +import roomescape.auth.web.support.accessToken +import roomescape.common.dto.PrincipalType + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class UserInterceptor( + private val jwtUtils: JwtUtils +) : HandlerInterceptor { + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any + ): Boolean { + if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(UserOnly::class.java) == null)) { + return true + } + + val token: String? = request.accessToken() + val userId = jwtUtils.extractSubject(token).also { id -> MDC.put(MDC_MEMBER_ID_KEY, id) } + + jwtUtils.extractClaim(token, CLAIM_TYPE_KEY).also { + if (it != PrincipalType.USER.name) { + log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${userId}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) + } + } + + log.info { "[AuthInterceptor] 인증 완료. userId=$userId" } + + return true + } +} -- 2.47.2 From 7f1ab906b7b27383e48deaf3ca5ccfb6dbbd3280 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:01:04 +0900 Subject: [PATCH 12/73] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20/?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=EA=B5=AC=EB=B6=84=EC=97=86=EB=8A=94=20?= =?UTF-8?q?'=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=83=81=ED=83=9C'=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20API=EC=99=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EC=99=80=20=EC=83=81=EA=B4=80=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?API=EC=97=90=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20Interceptor=20=EB=B0=8F=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/web/support/AuthAnnotations.kt | 8 +++ .../interceptors/AuthenticatedInterceptor.kt | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 55490e4b..f06e0bc2 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -23,3 +23,11 @@ annotation class AdminOnly( @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class UserOnly + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Authenticated + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Public diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt new file mode 100644 index 00000000..ff248ff1 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt @@ -0,0 +1,50 @@ +package roomescape.auth.web.support.interceptors + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor +import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.business.CLAIM_TYPE_KEY +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.support.Authenticated +import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import roomescape.auth.web.support.accessToken +import roomescape.common.dto.PrincipalType + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class AuthenticatedInterceptor( + private val jwtUtils: JwtUtils, + private val authService: AuthServiceV2 +) : HandlerInterceptor { + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any + ): Boolean { + if ((handler !is HandlerMethod) || (handler.getMethodAnnotation(Authenticated::class.java) == null)) { + return true + } + + val token: String? = request.accessToken() + + val id = jwtUtils.extractSubject(token).also { MDC.put(MDC_MEMBER_ID_KEY, it) } + val type = jwtUtils.extractClaim(token, CLAIM_TYPE_KEY) + + try { + authService.findContextById(id.toLong(), PrincipalType.valueOf(type)) + log.info { "[AuthenticatedInterceptor] 인증 완료. id=$id, type=${type}" } + + return true + } catch (e: Exception) { + throw e + } + } +} -- 2.47.2 From 26c3c62b04b91e9740d7b5a5eac2288e0c75e4ff Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:01:48 +0900 Subject: [PATCH 13/73] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=EC=9D=98=20\@M?= =?UTF-8?q?emberId=EB=A5=BC=20=EB=8C=80=EC=B2=B4=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20CurrentUserContext=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=20resolver=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/web/support/AuthAnnotations.kt | 4 ++ .../resolver/CurrentUserContextResolver.kt | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index f06e0bc2..5cc61d1e 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -31,3 +31,7 @@ annotation class Authenticated @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class Public + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUser diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt new file mode 100644 index 00000000..662537db --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt @@ -0,0 +1,54 @@ +package roomescape.auth.web.support.resolver + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.MethodParameter +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.business.CLAIM_TYPE_KEY +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.accessToken +import roomescape.common.dto.PrincipalType + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class CurrentUserContextResolver( + private val jwtUtils: JwtUtils, + private val authService: AuthServiceV2 +) : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.hasParameterAnnotation(CurrentUser::class.java) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? + ): Any? { + val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest + val token: String? = request.accessToken() + + try { + val id: String = jwtUtils.extractSubject(token) + val type: PrincipalType = PrincipalType.valueOf(jwtUtils.extractClaim(token, CLAIM_TYPE_KEY)) + + return authService.findContextById(id.toLong(), type) + } catch (e: Exception) { + log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } + val errorCode = AuthErrorCode.MEMBER_NOT_FOUND + throw AuthException(errorCode, e.message ?: errorCode.message) + } + } +} + -- 2.47.2 From 797ee2c0d0e65d75732955752a6e13a54c61fb16 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:02:07 +0900 Subject: [PATCH 14/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=EB=90=9C=20Interceptor=20=EB=B0=8F=20Resolver=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/config/WebMvcConfig.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index 17249551..9d744670 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -6,18 +6,30 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import roomescape.auth.web.support.AuthInterceptor import roomescape.auth.web.support.MemberIdResolver +import roomescape.auth.web.support.interceptors.AdminInterceptor +import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor +import roomescape.auth.web.support.interceptors.UserInterceptor +import roomescape.auth.web.support.resolver.CurrentUserContextResolver @Configuration class WebMvcConfig( private val memberIdResolver: MemberIdResolver, - private val authInterceptor: AuthInterceptor + private val authInterceptor: AuthInterceptor, + private val adminInterceptor: AdminInterceptor, + private val userInterceptor: UserInterceptor, + private val authenticatedInterceptor: AuthenticatedInterceptor, + private val currentUserContextResolver: CurrentUserContextResolver ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { resolvers.add(memberIdResolver) + resolvers.add(currentUserContextResolver) } override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(authInterceptor) + registry.addInterceptor(adminInterceptor) + registry.addInterceptor(userInterceptor) + registry.addInterceptor(authenticatedInterceptor) } } -- 2.47.2 From 66ae7d7beb948c6d1a7225b3df41613b8571c1d9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:04:08 +0900 Subject: [PATCH 15/73] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EC=9D=84=20=EA=B4=80=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B3=84=EB=8F=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/business/LoginHistoryService.kt | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt diff --git a/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt b/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt new file mode 100644 index 00000000..bffedb50 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt @@ -0,0 +1,60 @@ +package roomescape.auth.business + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import roomescape.auth.infrastructure.persistence.LoginHistoryEntity +import roomescape.auth.infrastructure.persistence.LoginHistoryRepository +import roomescape.auth.web.LoginContext +import roomescape.common.config.next +import roomescape.common.dto.PrincipalType + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class LoginHistoryService( + private val loginHistoryRepository: LoginHistoryRepository, + private val tsidFactory: TsidFactory, +) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createSuccessHistory( + principalId: Long, + principalType: PrincipalType, + context: LoginContext + ) { + createHistory(principalId, principalType, success = true, context = context) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createFailureHistory( + principalId: Long, + principalType: PrincipalType, + context: LoginContext + ) { + createHistory(principalId, principalType, success = false, context = context) + } + + private fun createHistory( + principalId: Long, + principalType: PrincipalType, + success: Boolean, + context: LoginContext + ) { + log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" } + + LoginHistoryEntity( + id = tsidFactory.next(), + principalId = principalId, + principalType = principalType, + success = success, + ipAddress = context.ipAddress, + userAgent = context.userAgent, + ).also { + loginHistoryRepository.save(it) + log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" } + } + } +} -- 2.47.2 From 3c71562317dc716da7e2e486695c75d0eef1666a Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:05:34 +0900 Subject: [PATCH 16/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/auth/business/AuthServiceV2.kt | 100 ++++++++++++++++++ .../kotlin/roomescape/auth/docs/AuthAPIV2.kt | 52 +++++++++ .../roomescape/auth/web/AuthControllerV2.kt | 48 +++++++++ .../kotlin/roomescape/auth/web/AuthDTOV2.kt | 24 +++++ 4 files changed, 224 insertions(+) create mode 100644 src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt create mode 100644 src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt create mode 100644 src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt create mode 100644 src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt diff --git a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt new file mode 100644 index 00000000..591ddb44 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt @@ -0,0 +1,100 @@ +package roomescape.auth.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.LoginContext +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.LoginCredentials +import roomescape.common.dto.PrincipalType +import roomescape.member.business.UserService + +private val log: KLogger = KotlinLogging.logger {} + +const val CLAIM_PERMISSION_KEY = "permission" +const val CLAIM_TYPE_KEY = "principal_type" + +@Service +class AuthServiceV2( + private val adminService: AdminService, + private val userService: UserService, + private val loginHistoryService: LoginHistoryService, + private val jwtUtils: JwtUtils, +) { + @Transactional(readOnly = true) + fun login( + request: LoginRequestV2, + context: LoginContext + ): LoginSuccessResponse { + log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}" } + + val extraClaims: MutableMap = mutableMapOf() + + val credentials: LoginCredentials = when (request.principalType) { + PrincipalType.ADMIN -> { + adminService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) + } + } + + PrincipalType.USER -> { + userService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) + } + } + } + + try { + if (credentials.password != request.password) { + log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } + throw AuthException(AuthErrorCode.LOGIN_FAILED) + } + + val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) + return LoginSuccessResponse(accessToken) + .also { + log.info { "[AuthService.login] 관리자 로그인 완료: account = ${request.account}, id=${credentials.id}" } + loginHistoryService.createSuccessHistory(credentials.id, PrincipalType.ADMIN, context) + } + } catch (e: Exception) { + log.warn { "[AuthService.login] 관리자 로그인 실패: account = ${request.account}, message=${e.message}" } + loginHistoryService.createFailureHistory(credentials.id, PrincipalType.ADMIN, context) + + throw e + } + } + + @Transactional(readOnly = true) + fun checkLogin(context: CurrentUserContext): CurrentUserContext { + return findContextById(context.id, context.type).also { + if (it != context) { + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + } + } + + @Transactional(readOnly = true) + fun findContextById(id: Long, type: PrincipalType): CurrentUserContext { + log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" } + + return when (type) { + PrincipalType.ADMIN -> { + adminService.findContextById(id) + } + + PrincipalType.USER -> { + userService.findContextById(id) + } + }.also { + log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } + } + } +} diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt new file mode 100644 index 00000000..9cacbdd1 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt @@ -0,0 +1,52 @@ +package roomescape.auth.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.Public +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.response.CommonApiResponse + +@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") +interface AuthAPIV2 { + + @Public + @Operation(summary = "로그인") + @ApiResponses( + ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), + ) + fun login( + @Valid @RequestBody loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> + + @Operation(summary = "로그인 상태 확인") + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "입력된 ID / 결과(Boolean)을 반환합니다.", + useReturnTypeSchema = true + ), + ) + fun checkLogin( + @CurrentUser user: CurrentUserContext + ): ResponseEntity> + + @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse(responseCode = "200"), + ) + fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt new file mode 100644 index 00000000..38151ccd --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt @@ -0,0 +1,48 @@ +package roomescape.auth.web + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.docs.AuthAPIV2 +import roomescape.auth.web.support.CurrentUser +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.response.CommonApiResponse + +@RestController +@RequestMapping("/auth") +class AuthControllerV2( + private val authService: AuthServiceV2, +) : AuthAPIV2 { + + @PostMapping("/login") + override fun login( + loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> { + val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext()) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/login/check") + override fun checkLogin( + @CurrentUser user: CurrentUserContext, + ): ResponseEntity> { + val response = authService.checkLogin(user) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/logout") + override fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> { + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt b/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt new file mode 100644 index 00000000..4bf06274 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt @@ -0,0 +1,24 @@ +package roomescape.auth.web + +import jakarta.servlet.http.HttpServletRequest +import roomescape.common.dto.PrincipalType + +data class LoginContext( + val ipAddress: String, + val userAgent: String, +) + +fun HttpServletRequest.toLoginContext() = LoginContext( + ipAddress = this.remoteAddr, + userAgent = this.getHeader("User-Agent") +) + +data class LoginRequestV2( + val account: String, + val password: String, + val principalType: PrincipalType +) + +data class LoginSuccessResponse( + val accessToken: String +) -- 2.47.2 From 0b5d91d30104249a8506921046c406f9caaf9b0a Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:05:55 +0900 Subject: [PATCH 17/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=97=90=20=EB=A7=9E=EC=B6=98=20pho?= =?UTF-8?q?ne=20=EB=A1=9C=EA=B7=B8=20=EB=A7=88=EC=8A=A4=ED=82=B9=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/log/RoomescapeLogMaskingConverter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt index 2c94c436..104eeba9 100644 --- a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt +++ b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.node.TextNode import roomescape.common.config.JacksonConfig private const val MASK: String = "****" -private val SENSITIVE_KEYS = setOf("password", "accessToken") +private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone") private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() class RoomescapeLogMaskingConverter : MessageConverter() { -- 2.47.2 From 1e9dbd87c3e41461ccf699332f4ecde32ea5ea9e Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:06:35 +0900 Subject: [PATCH 18/73] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=97=90=20=EB=A7=9E=EC=B6=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20AuthUtil=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20FK=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=EC=9D=98=20region=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD=EC=A0=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/util/DatabaseCleaner.kt | 3 ++ .../roomescape/util/RestAssuredUtils.kt | 52 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt b/src/test/kotlin/roomescape/util/DatabaseCleaner.kt index 12ac477d..b2b6ce43 100644 --- a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt +++ b/src/test/kotlin/roomescape/util/DatabaseCleaner.kt @@ -26,6 +26,9 @@ class DatabaseCleaner( jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE") tables.forEach { + if (it == "region") { + return@forEach + } jdbcTemplate.execute("TRUNCATE TABLE $it RESTART IDENTITY") } jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE") diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index 752680d1..de99b716 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -8,14 +8,18 @@ import io.restassured.response.Response import io.restassured.response.ValidatableResponse import io.restassured.specification.RequestSpecification import org.springframework.http.MediaType +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.auth.web.LoginRequest +import roomescape.auth.web.LoginRequestV2 import roomescape.common.config.next -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository -import roomescape.member.infrastructure.persistence.Role +import roomescape.common.dto.PrincipalType +import roomescape.member.infrastructure.persistence.* class AuthUtil( - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val userRepository: UserRepository, + private val adminRepository: AdminRepository ) { fun login(email: String, password: String, role: Role = Role.MEMBER): String { if (!memberRepository.existsByEmail(email)) { @@ -54,6 +58,46 @@ class AuthUtil( MemberFixture.user.email, MemberFixture.user.password ) ?: throw AssertionError("Unexpected Exception Occurred.") + + + + fun adminLogin(admin: AdminEntity): String { + if (adminRepository.findByAccount(admin.account) == null) { + adminRepository.save(admin) + } + + return Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(LoginRequestV2(account = admin.account, password = admin.password, principalType = PrincipalType.ADMIN)) + } When { + post("/auth/login") + } Then { + statusCode(200) + } Extract { + path("data.accessToken") + } + } + + fun defaultAdminLogin(): String = adminLogin(AdminFixture.default) + + fun userLogin(user: UserEntity): String { + if (userRepository.findByEmail(user.email) == null) { + userRepository.save(user) + } + + return Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(LoginRequestV2(account = user.email, password = user.password, principalType = PrincipalType.USER)) + } When { + post("/auth/login") + } Then { + statusCode(200) + } Extract { + path("data.accessToken") + } + } + + fun defaultUserLogin(): String = userLogin(UserFixture.default) } fun runTest( -- 2.47.2 From c8377a3dde0a2a15e33f59da274ef3323cc54d07 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 17:16:08 +0900 Subject: [PATCH 19/73] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20API?= =?UTF-8?q?=EC=97=90=20=EC=83=88=EB=A1=9C=20=EC=A0=95=EC=9D=98=EB=90=9C=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/docs/PaymentAPI.kt | 9 ++++++--- .../payment/web/PaymentController.kt | 6 ++++-- .../reservation/docs/ReservationAPI.kt | 17 +++++++++-------- .../reservation/web/ReservationController.kt | 16 ++++++++-------- .../roomescape/schedule/docs/ScheduleAPI.kt | 18 +++++++++++------- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 17 ++++++++++------- .../roomescape/payment/PaymentAPITest.kt | 16 ++++++++-------- 7 files changed, 56 insertions(+), 43 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt index 98633997..5afb5d7a 100644 --- a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt +++ b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt @@ -8,8 +8,12 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import roomescape.auth.web.support.Authenticated +import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.MemberId +import roomescape.auth.web.support.UserOnly +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentConfirmRequest @@ -17,7 +21,7 @@ import roomescape.payment.web.PaymentCreateResponse interface PaymentAPI { - @LoginRequired + @UserOnly @Operation(summary = "결제 승인", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun confirmPayment( @@ -25,11 +29,10 @@ interface PaymentAPI { @Valid @RequestBody request: PaymentConfirmRequest ): ResponseEntity> - @LoginRequired @Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelPayment( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @Valid @RequestBody request: PaymentCancelRequest ): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/payment/web/PaymentController.kt b/src/main/kotlin/roomescape/payment/web/PaymentController.kt index 9e3dc906..3923a39c 100644 --- a/src/main/kotlin/roomescape/payment/web/PaymentController.kt +++ b/src/main/kotlin/roomescape/payment/web/PaymentController.kt @@ -7,7 +7,9 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.MemberId +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.payment.business.PaymentService import roomescape.payment.docs.PaymentAPI @@ -29,10 +31,10 @@ class PaymentController( @PostMapping("/payments/cancel") override fun cancelPayment( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @Valid @RequestBody request: PaymentCancelRequest ): ResponseEntity> { - paymentService.cancel(memberId, request) + paymentService.cancel(user.id, request) return ResponseEntity.ok(CommonApiResponse()) } diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index dd3e6612..2d25ff0a 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -8,45 +8,46 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.MemberId +import roomescape.auth.web.support.UserOnly +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.reservation.web.* interface ReservationAPI { - @LoginRequired @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createPendingReservation( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @Valid @RequestBody request: PendingReservationCreateRequest ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "예약 확정", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun confirmReservation( @PathVariable("id") id: Long ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelReservation( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @PathVariable reservationId: Long, @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> - @LoginRequired @Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findSummaryByMemberId( - @MemberId @Parameter(hidden = true) memberId: Long + @CurrentUser user: CurrentUserContext, ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "특정 예약에 대한 상세 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findDetailById( diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index b5e60372..dedf02f5 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -1,10 +1,10 @@ package roomescape.reservation.web -import io.swagger.v3.oas.annotations.Parameter import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* -import roomescape.auth.web.support.MemberId +import roomescape.auth.web.support.CurrentUser +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.reservation.business.ReservationService import roomescape.reservation.docs.ReservationAPI @@ -16,10 +16,10 @@ class ReservationController( @PostMapping("/reservations/pending") override fun createPendingReservation( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @Valid @RequestBody request: PendingReservationCreateRequest ): ResponseEntity> { - val response = reservationService.createPendingReservation(memberId, request) + val response = reservationService.createPendingReservation(user.id, request) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -35,20 +35,20 @@ class ReservationController( @PostMapping("/reservations/{reservationId}/cancel") override fun cancelReservation( - @MemberId @Parameter(hidden = true) memberId: Long, + @CurrentUser user: CurrentUserContext, @PathVariable reservationId: Long, @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> { - reservationService.cancelReservation(memberId, reservationId, request) + reservationService.cancelReservation(user.id, reservationId, request) return ResponseEntity.ok().body(CommonApiResponse()) } @GetMapping("/reservations/summary") override fun findSummaryByMemberId( - @MemberId @Parameter(hidden = true) memberId: Long + @CurrentUser user: CurrentUserContext, ): ResponseEntity> { - val response = reservationService.findSummaryByMemberId(memberId) + val response = reservationService.findSummaryByMemberId(user.id) return ResponseEntity.ok(CommonApiResponse(response)) } diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index 95e2cc2d..1ae6df4b 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -4,27 +4,31 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid +import org.aspectj.internal.lang.annotation.ajcPrivileged import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam +import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.Admin +import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.LoginRequired +import roomescape.auth.web.support.UserOnly import roomescape.common.dto.response.CommonApiResponse import roomescape.schedule.web.* import java.time.LocalDate interface ScheduleAPI { - @LoginRequired + @UserOnly @Operation(summary = "입력된 날짜에 가능한 테마 목록 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true)) fun findAvailableThemes( @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회", tags = ["로그인이 필요한 API"]) @ApiResponses( ApiResponse( @@ -38,7 +42,7 @@ interface ScheduleAPI { @RequestParam("themeId") themeId: Long ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "일정을 Hold 상태로 변경", tags = ["로그인이 필요한 API"]) @ApiResponses( ApiResponse( @@ -51,21 +55,21 @@ interface ScheduleAPI { @PathVariable("id") id: Long ): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.READ_DETAIL) @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) fun findScheduleDetail( @PathVariable("id") id: Long ): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.CREATE) @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createSchedule( @Valid @RequestBody request: ScheduleCreateRequest ): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.UPDATE) @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateSchedule( @@ -73,7 +77,7 @@ interface ScheduleAPI { @Valid @RequestBody request: ScheduleUpdateRequest ): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.DELETE) @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteSchedule( diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 284d7684..36587af1 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -8,35 +8,38 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.Admin +import roomescape.auth.web.support.AdminOnly import roomescape.auth.web.support.LoginRequired +import roomescape.auth.web.support.UserOnly import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* @Tag(name = "5. 관리자 테마 API", description = "관리자 페이지에서 테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface ThemeAPIV2 { - @Admin + @AdminOnly(privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemes(): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findAdminThemeDetail(@PathVariable("id") id: Long): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.CREATE) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequest: ThemeCreateRequest): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.DELETE) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) fun deleteTheme(@PathVariable id: Long): ResponseEntity> - @Admin + @AdminOnly(privilege = Privilege.UPDATE) @Operation(summary = "테마 수정", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun updateTheme( @@ -44,12 +47,12 @@ interface ThemeAPIV2 { @Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest ): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findUserThemes(): ResponseEntity> - @LoginRequired + @UserOnly @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity> diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index 9ae9b620..69017655 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -145,8 +145,8 @@ class PaymentAPITest( PaymentMethod.entries.filter { it !in supportedMethod }.forEach { test("결제 수단: ${it.koreanName}") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser() + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin() ) val request = PaymentFixture.confirmRequest @@ -163,7 +163,7 @@ class PaymentAPITest( ) runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(PaymentFixture.confirmRequest) }, @@ -182,7 +182,7 @@ class PaymentAPITest( context("결제를 취소한다.") { test("정상 취소") { - val token = authUtil.loginAsAdmin() + val token = authUtil.defaultAdminLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( adminToken = token, @@ -230,7 +230,7 @@ class PaymentAPITest( } test("예약에 대한 결제 정보가 없으면 실패한다.") { - val token = authUtil.loginAsAdmin() + val token = authUtil.defaultAdminLogin() val reservation = dummyInitializer.createConfirmReservation( adminToken = token, reserverToken = token, @@ -282,8 +282,8 @@ class PaymentAPITest( val request = PaymentFixture.confirmRequest.copy(paymentKey = paymentKey, amount = amount) val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser(), + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin(), ) val method = if (easyPayDetail != null) { @@ -305,7 +305,7 @@ class PaymentAPITest( } returns clientResponse runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(request) }, -- 2.47.2 From b041df2167f5c9180b83866ca90a60a4e12c2a9b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 19:48:48 +0900 Subject: [PATCH 20/73] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20API=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/member/business/UserService.kt | 41 ++++- .../member/business/UserValidator.kt | 29 ++++ .../kotlin/roomescape/member/docs/UserAPI.kt | 30 ++++ .../member/exception/UserException.kt | 6 +- .../persistence/UserRepositories.kt | 2 + .../roomescape/member/web/UserController.kt | 25 +++ .../kotlin/roomescape/member/web/UserDTO.kt | 43 +++++ .../kotlin/roomescape/user/UserApiTest.kt | 147 ++++++++++++++++++ 8 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/roomescape/member/business/UserValidator.kt create mode 100644 src/main/kotlin/roomescape/member/docs/UserAPI.kt create mode 100644 src/main/kotlin/roomescape/member/web/UserController.kt create mode 100644 src/main/kotlin/roomescape/member/web/UserDTO.kt create mode 100644 src/test/kotlin/roomescape/user/UserApiTest.kt diff --git a/src/main/kotlin/roomescape/member/business/UserService.kt b/src/main/kotlin/roomescape/member/business/UserService.kt index 036f5033..86804401 100644 --- a/src/main/kotlin/roomescape/member/business/UserService.kt +++ b/src/main/kotlin/roomescape/member/business/UserService.kt @@ -1,23 +1,32 @@ package roomescape.member.business +import com.github.f4b6a3.tsid.TsidFactory import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.PrincipalType import roomescape.common.dto.UserLoginCredentials import roomescape.member.exception.UserErrorCode import roomescape.member.exception.UserException -import roomescape.member.infrastructure.persistence.UserEntity -import roomescape.member.infrastructure.persistence.UserRepository +import roomescape.member.infrastructure.persistence.* +import roomescape.member.web.UserCreateRequest +import roomescape.member.web.UserCreateResponse +import roomescape.member.web.toEntity private val log: KLogger = KotlinLogging.logger {} +const val SIGNUP: String = "최초 가입" + @Service class UserService( private val userRepository: UserRepository, + private val userStatusHistoryRepository: UserStatusHistoryRepository, + private val userValidator: UserValidator, + private val tsidFactory: TsidFactory ) { @Transactional(readOnly = true) fun findContextById(id: Long): CurrentUserContext { @@ -45,8 +54,36 @@ class UserService( } } + @Transactional + fun signup(request: UserCreateRequest): UserCreateResponse { + log.info { "[UserService.signup] 회원가입 시작: request:$request" } + + userValidator.validateCanSignup(request.email, request.phone) + + val user: UserEntity = userRepository.save( + request.toEntity(id = tsidFactory.next(), status = UserStatus.ACTIVE) + ).also { + log.info { "[UserService.signup] 회원 저장 완료: id:${it.id}" } + }.also { + createHistory(user = it, reason = SIGNUP) + } + + return UserCreateResponse(user.id, user.name) + .also { + log.info { "[UserService.signup] 회원가입 완료: id:${it.id}" } + } + } + private fun findOrThrow(id: Long): UserEntity { return userRepository.findByIdOrNull(id) ?: throw UserException(UserErrorCode.USER_NOT_FOUND) } + + private fun createHistory(user: UserEntity, reason: String): UserStatusHistoryEntity { + return userStatusHistoryRepository.save( + UserStatusHistoryEntity(id = tsidFactory.next(), userId = user.id, reason = reason, status = user.status) + ).also { + log.info { "[UserService.signup] 회원 상태 이력 저장 완료: userStatusHistoryId:${it.id}" } + } + } } diff --git a/src/main/kotlin/roomescape/member/business/UserValidator.kt b/src/main/kotlin/roomescape/member/business/UserValidator.kt new file mode 100644 index 00000000..ede9f9a7 --- /dev/null +++ b/src/main/kotlin/roomescape/member/business/UserValidator.kt @@ -0,0 +1,29 @@ +package roomescape.member.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.member.exception.UserErrorCode +import roomescape.member.exception.UserException +import roomescape.member.infrastructure.persistence.UserRepository + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class UserValidator( + private val userRepository: UserRepository, +) { + + fun validateCanSignup(email: String, phone: String) { + log.info { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" } + + if (userRepository.existsByEmail(email)) { + log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" } + throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS) + } + if (userRepository.existsByPhone(phone)) { + log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" } + throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS) + } + } +} diff --git a/src/main/kotlin/roomescape/member/docs/UserAPI.kt b/src/main/kotlin/roomescape/member/docs/UserAPI.kt new file mode 100644 index 00000000..a8011f85 --- /dev/null +++ b/src/main/kotlin/roomescape/member/docs/UserAPI.kt @@ -0,0 +1,30 @@ +package roomescape.member.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.support.Public +import roomescape.common.dto.response.CommonApiResponse +import roomescape.member.web.UserCreateRequest +import roomescape.member.web.UserCreateResponse + +@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.") +interface UserAPI { + + @Public + @Operation(summary = "회원 가입") + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "성공", + useReturnTypeSchema = true + ) + ) + fun signup( + @Valid @RequestBody request: UserCreateRequest + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/member/exception/UserException.kt b/src/main/kotlin/roomescape/member/exception/UserException.kt index 93115986..e0421f79 100644 --- a/src/main/kotlin/roomescape/member/exception/UserException.kt +++ b/src/main/kotlin/roomescape/member/exception/UserException.kt @@ -13,4 +13,8 @@ enum class UserErrorCode( override val httpStatus: HttpStatus, override val errorCode: String, override val message: String -) : ErrorCode +) : ErrorCode { + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "회원을 찾을 수 없어요."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "U002", "이미 가입된 이메일이에요."), + PHONE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 가입된 휴대폰 번호가 있어요."), +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt index 13a0adb3..37b6f4e0 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt @@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserRepository : JpaRepository { + fun existsByEmail(email: String): Boolean + fun existsByPhone(phone: String): Boolean fun findByEmail(email: String): UserEntity? } diff --git a/src/main/kotlin/roomescape/member/web/UserController.kt b/src/main/kotlin/roomescape/member/web/UserController.kt new file mode 100644 index 00000000..30c182c9 --- /dev/null +++ b/src/main/kotlin/roomescape/member/web/UserController.kt @@ -0,0 +1,25 @@ +package roomescape.member.web + +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import roomescape.common.dto.response.CommonApiResponse +import roomescape.member.business.UserService +import roomescape.member.docs.UserAPI + +@RestController +class UserController( + private val userService: UserService +) : UserAPI { + + @PostMapping("/users") + override fun signup( + @Valid @RequestBody request: UserCreateRequest + ): ResponseEntity> { + val response = userService.signup(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/member/web/UserDTO.kt b/src/main/kotlin/roomescape/member/web/UserDTO.kt new file mode 100644 index 00000000..ed9fe940 --- /dev/null +++ b/src/main/kotlin/roomescape/member/web/UserDTO.kt @@ -0,0 +1,43 @@ +package roomescape.member.web + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import roomescape.member.infrastructure.persistence.UserEntity +import roomescape.member.infrastructure.persistence.UserStatus + +const val MIN_PASSWORD_LENGTH = 8 + +data class UserCreateRequest( + @NotEmpty + val name: String, + + @NotEmpty + @Email + val email: String, + + @Size(min = MIN_PASSWORD_LENGTH) + val password: String, + + @NotEmpty + @Pattern(regexp = "^010([0-9]{3,4})([0-9]{4})$") + val phone: String, + + val regionCode: String?, +) + +fun UserCreateRequest.toEntity(id: Long, status: UserStatus) = UserEntity( + id = id, + name = this.name, + email = this.email, + password = this.password, + phone = this.phone, + regionCode = this.regionCode, + status = status +) + +data class UserCreateResponse( + val id: Long, + val name: String +) diff --git a/src/test/kotlin/roomescape/user/UserApiTest.kt b/src/test/kotlin/roomescape/user/UserApiTest.kt new file mode 100644 index 00000000..5bb1657f --- /dev/null +++ b/src/test/kotlin/roomescape/user/UserApiTest.kt @@ -0,0 +1,147 @@ +package roomescape.user + +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import roomescape.common.exception.CommonErrorCode +import roomescape.member.business.SIGNUP +import roomescape.member.exception.UserErrorCode +import roomescape.member.infrastructure.persistence.* +import roomescape.member.web.MIN_PASSWORD_LENGTH +import roomescape.member.web.UserCreateRequest +import roomescape.util.FunSpecSpringbootTest +import roomescape.util.UserFixture +import roomescape.util.runTest + +class UserApiTest( + private val userRepository: UserRepository, + private val userStatusHistoryRepository: UserStatusHistoryRepository +) : FunSpecSpringbootTest() { + + init { + context("회원가입 및 상태 이력을 저장한다.") { + val request = UserFixture.createRequest + + test("정상 응답") { + runTest( + using = { + body(request) + }, + on = { + post("/users") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.name", equalTo(request.name)) + } + ).also { + val user: UserEntity = userRepository.findByIdOrNull(it.extract().path("data.id")) + ?: throw AssertionError("Unexpected Exception Occurred.") + val history: UserStatusHistoryEntity = userStatusHistoryRepository.findAll()[0] + ?: throw AssertionError("Unexpected Exception Occurred.") + + assertSoftly(user) { createdUser -> + createdUser.email shouldBe request.email + createdUser.status shouldBe UserStatus.ACTIVE + } + + assertSoftly(history) { history -> + history.userId shouldBe user.id + history.status shouldBe UserStatus.ACTIVE + history.reason shouldBe SIGNUP + } + } + } + + test("이미 사용중인 이메일을 입력하면 실패한다.") { + val request = UserFixture.createRequest.also { + signup(it) + } + + runTest( + using = { + body(request) + }, + on = { + post("/users") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(UserErrorCode.EMAIL_ALREADY_EXISTS.errorCode)) + } + ) + } + + test("이미 사용중인 휴대폰 번호를 입력하면 실패한다.") { + val createdRequest = UserFixture.createRequest.also { signup(it) } + + runTest( + using = { + body(createdRequest.copy(email = "another@example.com")) + }, + on = { + post("/users") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(UserErrorCode.PHONE_ALREADY_EXISTS.errorCode)) + } + ) + } + + context("요청 형식이 잘못되면 실패한다.") { + val commonRequest = UserFixture.createRequest + + fun runCommonTest(request: UserCreateRequest) { + runTest( + using = { + body(request) + }, + on = { + post("/users") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode)) + } + ) + } + + test("빈 이름") { runCommonTest(commonRequest.copy(name = "")) } + + test("이메일 형식") { runCommonTest(commonRequest.copy(email = "account123")) } + test("빈 이메일") { runCommonTest(commonRequest.copy(email = "")) } + + test("${MIN_PASSWORD_LENGTH}글자 미만 비밀번호.") { + runCommonTest(commonRequest.copy(password = "a".repeat((MIN_PASSWORD_LENGTH - 1)))) + } + + test("010123(4)5678 형식이 아닌 전화번호") { runCommonTest(commonRequest.copy(phone = "01112345678")) } + test("빈 전화번호") { runCommonTest(commonRequest.copy(phone = "")) } + } + } + } + + private fun signup(request: UserCreateRequest): UserEntity { + val userId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(request) + } When { + post("/users") + } Then { + statusCode(HttpStatus.OK.value()) + } Extract { + path("data.id") + } + + return userRepository.findByIdOrNull(userId) + ?: throw AssertionError("Unexpected Exception Occurred.") + } +} -- 2.47.2 From a70a03294697965d14d7b743a6b531177564fd56 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:49:46 +0900 Subject: [PATCH 21/73] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=97=90=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/admin/business/AdminService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index ac121aee..dcb513e5 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -36,11 +36,11 @@ class AdminService( return adminRepository.findByAccount(account) ?.let { - log.info { "[AdminService.findByAccount] 관리자 조회 완료: id=${it.id}" } + log.info { "[AdminService.findByAccount] 관리자 조회 완료: account=${account}, id=${it.id}" } AdminLoginCredentials(it.id, it.password, it.permissionLevel) } ?: run { - log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패" } + log.info { "[AdminService.findInfoByAccount] 관리자 조회 실패: account=${account}" } throw AdminException(AdminErrorCode.ADMIN_NOT_FOUND) } } -- 2.47.2 From a6a82d7fd9831380f0aa9d985be569c2b4ca2bb5 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:50:34 +0900 Subject: [PATCH 22/73] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20API=EC=97=90=EC=84=9C=EC=9D=98=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=A4=91=EB=B3=B5=20DB?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt index 38151ccd..d0085688 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt @@ -33,9 +33,7 @@ class AuthControllerV2( override fun checkLogin( @CurrentUser user: CurrentUserContext, ): ResponseEntity> { - val response = authService.checkLogin(user) - - return ResponseEntity.ok(CommonApiResponse(response)) + return ResponseEntity.ok(CommonApiResponse(user)) } @PostMapping("/logout") -- 2.47.2 From 4ae9aa59112fdc71a40b81d5543fc5e5d397ef62 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:51:04 +0900 Subject: [PATCH 23/73] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=EC=97=90=20=EC=98=88=EC=83=81?= =?UTF-8?q?=EC=B9=98=20=EB=AA=BB=ED=95=9C=20=EC=98=88=EC=99=B8=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt index 1fdc7f06..40781ac5 100644 --- a/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt +++ b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt @@ -14,4 +14,6 @@ enum class AuthErrorCode( ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."), LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."), MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."), + + TEMPORARY_AUTH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "A999", "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."); } -- 2.47.2 From ea45673ef4557b25019caa9115996cebf8d08eab Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:51:44 +0900 Subject: [PATCH 24/73] =?UTF-8?q?refactor:=20ArgumentResolver=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/web/support/resolver/CurrentUserContextResolver.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt index 662537db..a5024aa1 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt @@ -46,8 +46,7 @@ class CurrentUserContextResolver( return authService.findContextById(id.toLong(), type) } catch (e: Exception) { log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } - val errorCode = AuthErrorCode.MEMBER_NOT_FOUND - throw AuthException(errorCode, e.message ?: errorCode.message) + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) } } } -- 2.47.2 From e4f6ffe53df2b80517f5157bd3d7fef6cb9b4e13 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:52:26 +0900 Subject: [PATCH 25/73] =?UTF-8?q?refactor:=20JwtUtils=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EB=B6=80=EB=B6=84=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC=20&=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtUtils.kt | 60 +++++++------------ .../kotlin/roomescape/auth/JwtUtilsTest.kt | 48 ++++++++++++--- 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt index 88fd9b18..1ba9a96e 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -2,6 +2,7 @@ package roomescape.auth.infrastructure.jwt import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys @@ -43,56 +44,37 @@ class JwtUtils( } fun extractSubject(token: String?): String { - return runWithHandle { - log.debug { "[JwtUtils.extractSubject] subject 조회 시작: token=$token" } - - Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .payload - .subject - ?.also { - log.debug { "[JwtUtils.extractSubject] subject 조회 완료: subject=${it}" } - } - ?: run { - log.debug { "[JwtUtils.extractSubject] subject 조회 실패: token=${token}" } - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) - } + if (token.isNullOrBlank()) { + throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) } + val claims = extractAllClaims(token) + + return claims.subject ?: throw AuthException(AuthErrorCode.INVALID_TOKEN) } fun extractClaim(token: String?, key: String): String { - return runWithHandle { - log.debug { "[JwtUtils.extractClaim] claim 조회 시작: token=$token, claimKey=$key" } + if (token.isNullOrBlank()) { + throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) + } + val claims = extractAllClaims(token) - Jwts.parser() + return claims.get(key, String::class.java) ?: run { + log.warn { "[JwtUtils] Claim 조회 실패: key=$key" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } + } + + private fun extractAllClaims(token: String): Claims { + try { + return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get(key, String::class.java) - ?.also { - log.debug { "[JwtHandler.extractClaim] claim 조회 완료: claim=${it}" } - } - ?: run { - log.info { "[JwtUtils.extractClaim] claim=${key} 조회 실패: token=$token" } - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) - } - } - } - - private fun runWithHandle(block: () -> T): T { - try { - return block() - } catch (e: AuthException) { - throw e - } catch (_: IllegalArgumentException) { - throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) } catch (_: ExpiredJwtException) { throw AuthException(AuthErrorCode.EXPIRED_TOKEN) - } catch (e: Exception) { - log.warn { "[JwtUtils] 예외 발생: message=${e.message}" } + } catch (ex: Exception) { + log.warn { "[JwtUtils] 유효하지 않은 토큰 요청: ${ex.message}" } throw AuthException(AuthErrorCode.INVALID_TOKEN) } } diff --git a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt index 627ed8dd..f92c5c56 100644 --- a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt +++ b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt @@ -3,16 +3,17 @@ package roomescape.auth import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe -import org.junit.jupiter.api.assertThrows import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.common.config.next import roomescape.util.tsidFactory -class JwtUtilsTest( -) : FunSpec() { - private val jwtUtils: JwtUtils = JwtUtils(secretKeyString = "caSf+JhhY9J9VcZxDQ7SNNOEIAJSZ9onsFstGNv9bjPHmHoTTcX+5wway5+//SPi", tokenTtlSeconds = 5) +class JwtUtilsTest : FunSpec() { + private val jwtUtils: JwtUtils = JwtUtils( + secretKeyString = "caSf+JhhY9J9VcZxDQ7SNNOEIAJSZ9onsFstGNv9bjPHmHoTTcX+5wway5+//SPi", + tokenTtlSeconds = 5L + ) init { context("종합 테스트") { @@ -35,7 +36,7 @@ class JwtUtilsTest( val claim = mapOf("name" to "sangdol") val commonToken = jwtUtils.createToken(subject, claim) - context("subject를 가져올 때 null 토큰을 입력하면 실패한다.") { + test("subject를 가져올 때 null 토큰을 입력하면 실패한다.") { shouldThrow { jwtUtils.extractSubject(null) }.also { @@ -43,7 +44,7 @@ class JwtUtilsTest( } } - context("claim을 가져올 때 null 토큰을 입력하면 실패한다.") { + test("claim을 가져올 때 null 토큰을 입력하면 실패한다.") { shouldThrow { jwtUtils.extractClaim(token = null, key = "") }.also { @@ -51,13 +52,42 @@ class JwtUtilsTest( } } - context("토큰은 유효하나 claim이 없으면 실패한다.") { + test("claim에 입력된 key의 정보가 없으면 실패한다.") { shouldThrow { jwtUtils.extractClaim(token = commonToken, key = "abcde") }.also { - it.errorCode shouldBe AuthErrorCode.MEMBER_NOT_FOUND + it.errorCode shouldBe AuthErrorCode.INVALID_TOKEN + } + } + + test("토큰이 만료되면 실패한다.") { + val jwtUtil = JwtUtils( + secretKeyString = "caSf+JhhY9J9VcZxDQ7SNNOEIAJSZ9onsFstGNv9bjPHmHoTTcX+5wway5+//SPi", + tokenTtlSeconds = 0L + ) + + val token = jwtUtil.createToken("hello", mapOf("name" to "sangdol")) + + shouldThrow { + jwtUtil.extractSubject(token) + }.also { + it.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN + } + + shouldThrow { + jwtUtil.extractClaim(token, key = "name") + }.also { + it.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN + } + } + + test("토큰 형식이 잘못되면 실패한다.") { + shouldThrow { + jwtUtils.extractSubject("abcde") + }.also { + it.errorCode shouldBe AuthErrorCode.INVALID_TOKEN } } } } -} \ No newline at end of file +} -- 2.47.2 From 77de425fc1057093b76c72343b1d1d9da56ea290 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:52:58 +0900 Subject: [PATCH 26/73] =?UTF-8?q?feat:=20LoginHistoryRepository=EC=97=90?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20ID(PK)=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/LoginHistoryRepository.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt index 5f131468..8eef79a6 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/persistence/LoginHistoryRepository.kt @@ -2,4 +2,7 @@ package roomescape.auth.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -interface LoginHistoryRepository : JpaRepository \ No newline at end of file +interface LoginHistoryRepository : JpaRepository { + + fun findByPrincipalId(principalId: Long): List +} -- 2.47.2 From 81613562bcf4ff0805c8bda61c935720c0f17a29 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:53:36 +0900 Subject: [PATCH 27/73] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EC=8B=9C=20AuthService=EB=A1=9C=EC=9D=98=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=A0=84=ED=8C=8C=20=EB=B0=A9=EC=A7=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/business/LoginHistoryService.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt b/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt index bffedb50..af7a06dc 100644 --- a/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt +++ b/src/main/kotlin/roomescape/auth/business/LoginHistoryService.kt @@ -45,16 +45,20 @@ class LoginHistoryService( ) { log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 시작: id=${principalId}, type=${principalType}, success=${success}" } - LoginHistoryEntity( - id = tsidFactory.next(), - principalId = principalId, - principalType = principalType, - success = success, - ipAddress = context.ipAddress, - userAgent = context.userAgent, - ).also { - loginHistoryRepository.save(it) - log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" } + runCatching { + LoginHistoryEntity( + id = tsidFactory.next(), + principalId = principalId, + principalType = principalType, + success = success, + ipAddress = context.ipAddress, + userAgent = context.userAgent, + ).also { + loginHistoryRepository.save(it) + log.info { "[LoginHistoryService.createHistory] 로그인 이력 저장 완료: principalId=${principalId}, historyId=${it.id}" } + } + }.onFailure { + log.warn { "[LoginHistoryService] 로그인 이력 저장 중 예외 발생: message=${it.message} id=${principalId}, type=${principalType}, success=${success}, context=${context}" } } } } -- 2.47.2 From efa33a071fe4147f942e3ba9f751b595aad63514 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:55:10 +0900 Subject: [PATCH 28/73] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증 정보 및 비밀번호 검증 메서드 분리 - 로그인 성공 이력 저장에 실패하면 실패 이력에 저장하는 오류 수정 - 예외 타입별 처리 분리 --- .../roomescape/auth/business/AuthServiceV2.kt | 86 +++++++++++-------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt index 591ddb44..7fd04dfb 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt @@ -33,50 +33,34 @@ class AuthServiceV2( request: LoginRequestV2, context: LoginContext ): LoginSuccessResponse { - log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}" } + log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } - val extraClaims: MutableMap = mutableMapOf() - - val credentials: LoginCredentials = when (request.principalType) { - PrincipalType.ADMIN -> { - adminService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) - } - } - - PrincipalType.USER -> { - userService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) - } - } - } + val (credentials, extraClaims) = getCredentials(request) try { - if (credentials.password != request.password) { - log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } - throw AuthException(AuthErrorCode.LOGIN_FAILED) - } + verifyPasswordOrThrow(request, credentials) val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) - return LoginSuccessResponse(accessToken) - .also { - log.info { "[AuthService.login] 관리자 로그인 완료: account = ${request.account}, id=${credentials.id}" } - loginHistoryService.createSuccessHistory(credentials.id, PrincipalType.ADMIN, context) - } + + loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) + + return LoginSuccessResponse(accessToken).also { + log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } + } + } catch (e: Exception) { - log.warn { "[AuthService.login] 관리자 로그인 실패: account = ${request.account}, message=${e.message}" } - loginHistoryService.createFailureHistory(credentials.id, PrincipalType.ADMIN, context) + loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) - throw e - } - } + when (e) { + is AuthException -> { + log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" } + throw e + } - @Transactional(readOnly = true) - fun checkLogin(context: CurrentUserContext): CurrentUserContext { - return findContextById(context.id, context.type).also { - if (it != context) { - throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + else -> { + log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + } } } } @@ -97,4 +81,34 @@ class AuthServiceV2( log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } } } + + private fun verifyPasswordOrThrow( + request: LoginRequestV2, + credentials: LoginCredentials + ) { + if (credentials.password != request.password) { + log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } + throw AuthException(AuthErrorCode.LOGIN_FAILED) + } + } + + private fun getCredentials(request: LoginRequestV2): Pair> { + val extraClaims: MutableMap = mutableMapOf() + val credentials: LoginCredentials = when (request.principalType) { + PrincipalType.ADMIN -> { + adminService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) + } + } + + PrincipalType.USER -> { + userService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) + } + } + } + + return credentials to extraClaims + } } -- 2.47.2 From 3f7420698537a636cc2b0aebba6d72e5ba526ffc Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:56:57 +0900 Subject: [PATCH 29/73] =?UTF-8?q?refactor:=20region=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/util/DatabaseCleaner.kt | 22 ++++++------------- .../kotlin/roomescape/util/KotestConfig.kt | 3 +-- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt b/src/test/kotlin/roomescape/util/DatabaseCleaner.kt index b2b6ce43..4fdfe401 100644 --- a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt +++ b/src/test/kotlin/roomescape/util/DatabaseCleaner.kt @@ -21,12 +21,12 @@ class DatabaseCleaner( } } - fun clear() { + fun clear(mode: CleanerMode) { entityManager.clear() jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE") tables.forEach { - if (it == "region") { + if (mode == CleanerMode.EXCEPT_REGION && it == "region") { return@forEach } jdbcTemplate.execute("TRUNCATE TABLE $it RESTART IDENTITY") @@ -36,27 +36,19 @@ class DatabaseCleaner( } enum class CleanerMode { - AFTER_EACH_TEST, - AFTER_SPEC + EXCEPT_REGION, + ALL } -class DatabaseCleanerExtension( - private val mode: CleanerMode -) : AfterTestListener, AfterSpecListener { +class DatabaseCleanerExtension: AfterTestListener, AfterSpecListener { override suspend fun afterTest(testCase: TestCase, result: TestResult) { super.afterTest(testCase, result) - when (mode) { - CleanerMode.AFTER_EACH_TEST -> getCleaner().clear() - CleanerMode.AFTER_SPEC -> Unit - } + getCleaner().clear(CleanerMode.EXCEPT_REGION) } override suspend fun afterSpec(spec: Spec) { super.afterSpec(spec) - when (mode) { - CleanerMode.AFTER_EACH_TEST -> Unit - CleanerMode.AFTER_SPEC -> getCleaner().clear() - } + getCleaner().clear(CleanerMode.ALL) } private suspend fun getCleaner(): DatabaseCleaner { diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/util/KotestConfig.kt index 5342a5e3..3e92b362 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/util/KotestConfig.kt @@ -20,7 +20,6 @@ import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.util.CleanerMode.AFTER_EACH_TEST object KotestConfig : AbstractProjectConfig() { override fun extensions(): List = listOf(SpringExtension) @@ -29,7 +28,7 @@ object KotestConfig : AbstractProjectConfig() { @Import(TestConfig::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) abstract class FunSpecSpringbootTest : FunSpec({ - extension(DatabaseCleanerExtension(mode = AFTER_EACH_TEST)) + extension(DatabaseCleanerExtension()) }) { @Autowired private lateinit var memberRepository: MemberRepository -- 2.47.2 From 2e52785f7a781e101c77c440904f4e1e74696fdb Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:57:11 +0900 Subject: [PATCH 30/73] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20AuthUtil=EC=97=90=20=EA=B4=80=EB=A6=AC=EC=9E=90=20&?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/util/RestAssuredUtils.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index de99b716..79e7bc43 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -7,6 +7,8 @@ import io.restassured.module.kotlin.extensions.When import io.restassured.response.Response import io.restassured.response.ValidatableResponse import io.restassured.specification.RequestSpecification +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus import org.springframework.http.MediaType import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository @@ -15,6 +17,8 @@ import roomescape.auth.web.LoginRequestV2 import roomescape.common.config.next import roomescape.common.dto.PrincipalType import roomescape.member.infrastructure.persistence.* +import roomescape.member.web.UserCreateRequest +import roomescape.member.web.toEntity class AuthUtil( private val memberRepository: MemberRepository, @@ -59,7 +63,25 @@ class AuthUtil( MemberFixture.user.password ) ?: throw AssertionError("Unexpected Exception Occurred.") + fun createAdmin(admin: AdminEntity): AdminEntity { + return adminRepository.save(admin) + } + fun signup(request: UserCreateRequest): UserEntity { + val userId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + body(request) + } When { + post("/users") + } Then { + statusCode(HttpStatus.OK.value()) + } Extract { + path("data.id") + } + + return userRepository.findByIdOrNull(userId) + ?: throw AssertionError("Unexpected Exception Occurred.") + } fun adminLogin(admin: AdminEntity): String { if (adminRepository.findByAccount(admin.account) == null) { -- 2.47.2 From af901770dd7cdfbc038d3ccc673b709338db1b93 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 20:57:45 +0900 Subject: [PATCH 31/73] =?UTF-8?q?test:=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80(=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EA=B2=BD=EC=9A=B0=20/=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A0=A5=20=EC=8B=A4=ED=8C=A8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/AuthApiTest.kt | 229 ++++++++++++++++++ .../auth/FailOnSaveLoginHistoryTest.kt | 64 +++++ 2 files changed, 293 insertions(+) create mode 100644 src/test/kotlin/roomescape/auth/AuthApiTest.kt create mode 100644 src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt diff --git a/src/test/kotlin/roomescape/auth/AuthApiTest.kt b/src/test/kotlin/roomescape/auth/AuthApiTest.kt new file mode 100644 index 00000000..49142f29 --- /dev/null +++ b/src/test/kotlin/roomescape/auth/AuthApiTest.kt @@ -0,0 +1,229 @@ +package roomescape.auth + +import com.ninjasquad.springmockk.SpykBean +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.restassured.response.ValidatableResponse +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.http.HttpStatus +import roomescape.admin.exception.AdminErrorCode +import roomescape.auth.business.CLAIM_PERMISSION_KEY +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.infrastructure.persistence.LoginHistoryRepository +import roomescape.auth.web.LoginRequestV2 +import roomescape.common.dto.PrincipalType +import roomescape.member.exception.UserErrorCode +import roomescape.member.infrastructure.persistence.UserEntity +import roomescape.util.AdminFixture +import roomescape.util.FunSpecSpringbootTest +import roomescape.util.UserFixture +import roomescape.util.runTest + +class AuthApiTest( + @SpykBean private val jwtUtils: JwtUtils, + private val loginHistoryRepository: LoginHistoryRepository +) : FunSpecSpringbootTest() { + + init { + context("로그인을 시도한다.") { + context("성공 응답") { + test("관리자") { + val admin = authUtil.createAdmin(AdminFixture.default) + runLoginSuccessTest( + id = admin.id, + account = admin.account, + password = admin.password, + type = PrincipalType.ADMIN, + ) { + val token: String = it.extract().path("data.accessToken") + jwtUtils.extractSubject(token) shouldBe admin.id.toString() + jwtUtils.extractClaim(token, CLAIM_PERMISSION_KEY) shouldBe admin.permissionLevel.name + } + } + + test("회원") { + val user: UserEntity = authUtil.signup(UserFixture.createRequest) + + runLoginSuccessTest( + id = user.id, + account = user.email, + password = user.password, + type = PrincipalType.USER, + ) { + val token: String = it.extract().path("data.accessToken") + jwtUtils.extractSubject(token) shouldBe user.id.toString() + } + } + } + + context("실패 응답") { + test("비밀번호가 틀린 경우") { + val admin = authUtil.createAdmin(AdminFixture.default) + val request = LoginRequestV2(admin.account, "wrong_password", PrincipalType.ADMIN) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.UNAUTHORIZED.value()) + body("code", equalTo(AuthErrorCode.LOGIN_FAILED.errorCode)) + } + ).also { + assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + it.success shouldBe false + it.principalType shouldBe PrincipalType.ADMIN + } + } + } + + test("토큰 생성 과정에서 오류가 발생하는 경우") { + val admin = authUtil.createAdmin(AdminFixture.default) + val request = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) + + every { + jwtUtils.createToken(any(), any()) + } throws RuntimeException("토큰 생성 실패") + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.INTERNAL_SERVER_ERROR.value()) + body("code", equalTo(AuthErrorCode.TEMPORARY_AUTH_ERROR.errorCode)) + } + ).also { + assertSoftly(loginHistoryRepository.findByPrincipalId(admin.id)[0]) { + it.success shouldBe false + it.principalType shouldBe PrincipalType.ADMIN + } + } + } + + context("계정이 일치하지 않으면 로그인 실패 이력을 남기지 않는다.") { + test("회원") { + val user = authUtil.signup(UserFixture.createRequest) + val invalidEmail = "test@email.com".also { + it shouldNotBe user.email + } + + val request = LoginRequestV2(invalidEmail, user.password, PrincipalType.USER) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(UserErrorCode.USER_NOT_FOUND.errorCode)) + } + ).also { + loginHistoryRepository.findAll() shouldHaveSize 0 + } + } + + test("관리자") { + val admin = authUtil.createAdmin(AdminFixture.default) + val invalidAccount = "invalid".also { + it shouldNotBe admin.account + } + + val request = LoginRequestV2(invalidAccount, admin.password, PrincipalType.ADMIN) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(AdminErrorCode.ADMIN_NOT_FOUND.errorCode)) + } + ).also { + loginHistoryRepository.findAll() shouldHaveSize 0 + } + } + } + } + } + + context("로그인 상태를 확인한다.") { + test("성공 응답") { + val token = authUtil.defaultUserLogin() + + runTest( + token = token, + on = { + get("/auth/login/check") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val name: String = it.extract().path("data.name") + val type: String = it.extract().path("data.type") + + name.isBlank() shouldBe false + type shouldBe PrincipalType.USER.name + } + } + + test("로그인 상태가 아니면 실패한다.") { + runTest( + on = { + get("/auth/login/check") + }, + expect = { + statusCode(HttpStatus.UNAUTHORIZED.value()) + } + ) + } + } + } + + private fun runLoginSuccessTest( + id: Long, + account: String, + password: String, + type: PrincipalType, + extraAssertions: ((ValidatableResponse) -> Unit)? = null + ) { + val request = LoginRequestV2(account, password, type) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + extraAssertions?.invoke(it) + + assertSoftly(loginHistoryRepository.findByPrincipalId(id)) { history -> + history shouldHaveSize (1) + history[0].success shouldBe true + history[0].principalType shouldBe type + } + } + } +} diff --git a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt new file mode 100644 index 00000000..750a3ee4 --- /dev/null +++ b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt @@ -0,0 +1,64 @@ +package roomescape.auth + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.clearMocks +import io.mockk.every +import org.springframework.http.HttpStatus +import roomescape.auth.infrastructure.persistence.LoginHistoryRepository +import roomescape.auth.web.LoginRequestV2 +import roomescape.common.dto.PrincipalType +import roomescape.util.AdminFixture +import roomescape.util.FunSpecSpringbootTest +import roomescape.util.UserFixture +import roomescape.util.runTest + +class FailOnSaveLoginHistoryTest( + @MockkBean private val loginHistoryRepository: LoginHistoryRepository +) : FunSpecSpringbootTest() { + + init { + context("로그인 이력 저장 과정에서 예외가 발생해도 로그인 작업 자체는 정상 처리된다.") { + beforeTest { + clearMocks(loginHistoryRepository) + + every { + loginHistoryRepository.save(any()) + } throws RuntimeException("intended exception") + } + + test("회원") { + val user = authUtil.signup(UserFixture.createRequest) + val request = LoginRequestV2(user.email, user.password, PrincipalType.USER) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + } + + test("관리자") { + val admin = authUtil.createAdmin(AdminFixture.default) + val request = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) + + runTest( + using = { + body(request) + }, + on = { + post("/auth/login") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + } + } + } +} -- 2.47.2 From 87a273971e40075c1ade64e5c9790005ce5d4205 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 21:22:19 +0900 Subject: [PATCH 32/73] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=EC=9D=98=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=ED=99=95=EC=9E=A5=EC=84=B1=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EB=A0=A4=ED=95=B4=20=EC=9D=B4=EB=A6=84=20=EB=BF=90=EB=A7=8C=20?= =?UTF-8?q?=EC=95=84=EB=8B=88=EB=9D=BC=20id=EA=B9=8C=EC=A7=80=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=98=EB=8A=94=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A1=9C=EC=A7=81=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/admin/business/AdminService.kt | 12 ++++++++++++ src/main/kotlin/roomescape/common/dto/CommonAuth.kt | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/src/main/kotlin/roomescape/admin/business/AdminService.kt b/src/main/kotlin/roomescape/admin/business/AdminService.kt index dcb513e5..21f789be 100644 --- a/src/main/kotlin/roomescape/admin/business/AdminService.kt +++ b/src/main/kotlin/roomescape/admin/business/AdminService.kt @@ -11,6 +11,7 @@ import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.common.dto.AdminLoginCredentials import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.OperatorInfo import roomescape.common.dto.PrincipalType private val log: KLogger = KotlinLogging.logger {} @@ -45,6 +46,17 @@ class AdminService( } } + @Transactional(readOnly = true) + fun findOperatorById(id: Long): OperatorInfo { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 시작: id=${id}" } + + val admin: AdminEntity = findOrThrow(id) + + return OperatorInfo(admin.id, admin.name).also { + log.info { "[AdminService.findOperatorById] 작업자 정보 조회 완료: id=${admin.id}, name=${admin.name}" } + } + } + private fun findOrThrow(id: Long): AdminEntity { log.info { "[AdminService.findOrThrow] 조회 시작: id=${id}" } diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index 139f4429..de7ab182 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -27,3 +27,8 @@ data class CurrentUserContext( enum class PrincipalType { USER, ADMIN } + +data class OperatorInfo( + val id: Long, + val name: String +) -- 2.47.2 From 3b6e7ba7a6735e8c5a631e3106a1622b8ad06206 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 21:22:43 +0900 Subject: [PATCH 33/73] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=9D=98=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B8=EC=9D=B8=EC=9D=98=20=EC=A0=95=EB=B3=B4(?= =?UTF-8?q?=EC=9D=B4=EB=A6=84,=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8)?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=A0=20=EB=95=8C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=A0=20=EB=B3=84=EB=8F=84=EC=9D=98=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/member/business/UserService.kt | 13 +++++++++++++ src/main/kotlin/roomescape/member/web/UserDTO.kt | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/src/main/kotlin/roomescape/member/business/UserService.kt b/src/main/kotlin/roomescape/member/business/UserService.kt index 86804401..c2da722a 100644 --- a/src/main/kotlin/roomescape/member/business/UserService.kt +++ b/src/main/kotlin/roomescape/member/business/UserService.kt @@ -13,6 +13,7 @@ import roomescape.common.dto.UserLoginCredentials import roomescape.member.exception.UserErrorCode import roomescape.member.exception.UserException import roomescape.member.infrastructure.persistence.* +import roomescape.member.web.UserContactRetrieveResponse import roomescape.member.web.UserCreateRequest import roomescape.member.web.UserCreateResponse import roomescape.member.web.toEntity @@ -54,6 +55,18 @@ class UserService( } } + @Transactional(readOnly = true) + fun findContactById(id: Long) : UserContactRetrieveResponse { + log.info { "[UserService.findContactById] 회원 연락 정보 조회 시작: id=${id}" } + + val user = findOrThrow(id) + + return UserContactRetrieveResponse(user.id, user.name, user.phone) + .also { + log.info { "[UserService.findContactById] 회원 연락 정보 조회 완료: id=${id}, name=${it.name}" } + } + } + @Transactional fun signup(request: UserCreateRequest): UserCreateResponse { log.info { "[UserService.signup] 회원가입 시작: request:$request" } diff --git a/src/main/kotlin/roomescape/member/web/UserDTO.kt b/src/main/kotlin/roomescape/member/web/UserDTO.kt index ed9fe940..dbe421ad 100644 --- a/src/main/kotlin/roomescape/member/web/UserDTO.kt +++ b/src/main/kotlin/roomescape/member/web/UserDTO.kt @@ -41,3 +41,9 @@ data class UserCreateResponse( val id: Long, val name: String ) + +data class UserContactRetrieveResponse( + val id: Long, + val name: String, + val phone: String +) -- 2.47.2 From 26910f1d14cd90878ea520fc5ca095669f07eeae Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 12 Sep 2025 21:27:28 +0900 Subject: [PATCH 34/73] =?UTF-8?q?refactor:=20=ED=85=8C=EB=A7=88=20/=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=97=90=EC=84=9C=EC=9D=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1(=EC=88=98=EC=A0=95)=EC=9D=B8=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20OperatorInfo=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/business/ScheduleService.kt | 7 ++++--- .../kotlin/roomescape/schedule/web/ScheduleDto.kt | 7 ++++--- .../kotlin/roomescape/theme/business/ThemeService.kt | 9 +++++---- src/main/kotlin/roomescape/theme/web/ThemeDto.kt | 11 ++++++----- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index 2d8ce9e3..9c5efbb5 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -7,6 +7,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService import roomescape.common.config.next import roomescape.member.business.MemberService import roomescape.schedule.exception.ScheduleErrorCode @@ -23,7 +24,7 @@ class ScheduleService( private val scheduleRepository: ScheduleRepository, private val scheduleValidator: ScheduleValidator, private val tsidFactory: TsidFactory, - private val memberService: MemberService + private val adminService: AdminService ) { @Transactional(readOnly = true) @@ -53,8 +54,8 @@ class ScheduleService( val schedule: ScheduleEntity = findOrThrow(id) - val createdBy = memberService.findSummaryById(schedule.createdBy).name - val updatedBy = memberService.findSummaryById(schedule.updatedBy).name + val createdBy = adminService.findOperatorById(schedule.createdBy) + val updatedBy = adminService.findOperatorById(schedule.updatedBy) return schedule.toDetailRetrieveResponse(createdBy, updatedBy) .also { diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt index 2812bfaf..15360615 100644 --- a/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt @@ -1,5 +1,6 @@ package roomescape.schedule.web +import roomescape.common.dto.OperatorInfo import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleStatus import java.time.LocalDate @@ -49,12 +50,12 @@ data class ScheduleDetailRetrieveResponse( val time: LocalTime, val status: ScheduleStatus, val createdAt: LocalDateTime, - val createdBy: String, + val createdBy: OperatorInfo, val updatedAt: LocalDateTime, - val updatedBy: String, + val updatedBy: OperatorInfo, ) -fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String) = ScheduleDetailRetrieveResponse( +fun ScheduleEntity.toDetailRetrieveResponse(createdBy: OperatorInfo, updatedBy: OperatorInfo) = ScheduleDetailRetrieveResponse( id = this.id, date = this.date, time = this.time, diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index a24be7cb..56ae0271 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -6,6 +6,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService import roomescape.common.config.next import roomescape.member.business.MemberService import roomescape.theme.exception.ThemeErrorCode @@ -19,9 +20,9 @@ private val log: KLogger = KotlinLogging.logger {} @Service class ThemeService( private val themeRepository: ThemeRepository, + private val themeValidator: ThemeValidator, private val tsidFactory: TsidFactory, - private val memberService: MemberService, - private val themeValidator: ThemeValidator + private val adminService: AdminService ) { @Transactional(readOnly = true) fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeSummaryListResponse { @@ -66,8 +67,8 @@ class ThemeService( val theme: ThemeEntity = findOrThrow(id) - val createdBy = memberService.findSummaryById(theme.createdBy).name - val updatedBy = memberService.findSummaryById(theme.updatedBy).name + val createdBy = adminService.findOperatorById(theme.createdBy) + val updatedBy = adminService.findOperatorById(theme.updatedBy) return theme.toAdminThemeDetailResponse(createdBy, updatedBy) .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt index 8f5fb270..a1cf87a8 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt @@ -1,5 +1,6 @@ package roomescape.theme.web +import roomescape.common.dto.OperatorInfo import roomescape.theme.infrastructure.persistence.Difficulty import roomescape.theme.infrastructure.persistence.ThemeEntity import java.time.LocalDateTime @@ -103,12 +104,12 @@ data class AdminThemeDetailRetrieveResponse( val expectedMinutesTo: Short, val isOpen: Boolean, val createdAt: LocalDateTime, - val createdBy: String, + val createdBy: OperatorInfo, val updatedAt: LocalDateTime, - val updatedBy: String, + val updatedBy: OperatorInfo, ) -fun ThemeEntity.toAdminThemeDetailResponse(createUserName: String, updateUserName: String) = +fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: OperatorInfo) = AdminThemeDetailRetrieveResponse( id = this.id, name = this.name, @@ -123,9 +124,9 @@ fun ThemeEntity.toAdminThemeDetailResponse(createUserName: String, updateUserNam expectedMinutesTo = this.expectedMinutesTo, isOpen = this.isOpen, createdAt = this.createdAt, - createdBy = createUserName, + createdBy = createdBy, updatedAt = this.updatedAt, - updatedBy = updateUserName + updatedBy = updatedBy ) data class ThemeListRetrieveRequest( -- 2.47.2 From 2fc1cabe0e254fbbe3b27bd0ffada0f417571af7 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:46:22 +0900 Subject: [PATCH 35/73] =?UTF-8?q?refactor:=20JwtUtils=EC=97=90=20Inercepto?= =?UTF-8?q?r=20/=20Resolver=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20null=20claim=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/jwt/JwtUtils.kt | 31 +++++++++++++++---- .../support/interceptors/AdminInterceptor.kt | 31 ++++++++++++------- .../interceptors/AuthenticatedInterceptor.kt | 10 ++---- .../support/interceptors/UserInterceptor.kt | 16 +++------- .../resolver/CurrentUserContextResolver.kt | 8 ++--- 5 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt index 1ba9a96e..8e74a755 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtUtils.kt @@ -6,10 +6,14 @@ import io.jsonwebtoken.Claims import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys +import org.slf4j.MDC import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException +import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY +import roomescape.common.dto.PrincipalType import java.util.* import javax.crypto.SecretKey @@ -43,25 +47,40 @@ class JwtUtils( } } + fun extractIdAndType(token: String?): Pair { + val id: Long = extractSubject(token) + .also { MDC.put(MDC_PRINCIPAL_ID_KEY, it) } + .toLong() + + val type: PrincipalType = extractClaim(token, CLAIM_TYPE_KEY) + ?.let { PrincipalType.valueOf(it) } + ?: run { + log.info { "[JwtUtils.extractIdAndType] 회원 타입 조회 실패. id=$id" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + + return id to type + } + fun extractSubject(token: String?): String { if (token.isNullOrBlank()) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) } val claims = extractAllClaims(token) - return claims.subject ?: throw AuthException(AuthErrorCode.INVALID_TOKEN) + return claims.subject ?: run { + log.info { "[JwtUtils.extractSubject] subject를 찾을 수 없음.: token = ${token}" } + throw AuthException(AuthErrorCode.INVALID_TOKEN) + } } - fun extractClaim(token: String?, key: String): String { + fun extractClaim(token: String?, key: String): String? { if (token.isNullOrBlank()) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) } val claims = extractAllClaims(token) - return claims.get(key, String::class.java) ?: run { - log.warn { "[JwtUtils] Claim 조회 실패: key=$key" } - throw AuthException(AuthErrorCode.INVALID_TOKEN) - } + return claims.get(key, String::class.java) } private fun extractAllClaims(token: String): Claims { diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt index 6361fdca..b9f0cc63 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AdminInterceptor.kt @@ -4,7 +4,6 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor @@ -14,8 +13,8 @@ import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.AdminOnly -import roomescape.auth.web.support.MDC_MEMBER_ID_KEY import roomescape.auth.web.support.accessToken +import roomescape.common.dto.PrincipalType private val log: KLogger = KotlinLogging.logger {} @@ -35,20 +34,28 @@ class AdminInterceptor( val annotation: AdminOnly = handler.getMethodAnnotation(AdminOnly::class.java) ?: return true val token: String? = request.accessToken() - val adminId = jwtUtils.extractSubject(token).also { MDC.put(MDC_MEMBER_ID_KEY, it) } + val (id, type) = jwtUtils.extractIdAndType(token) - jwtUtils.extractClaim( - token = token, key = CLAIM_PERMISSION_KEY - ).also { - val permission = AdminPermissionLevel.valueOf(it) - - if (!permission.hasPrivilege(annotation.privilege)) { - log.warn { "[AuthInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) + val permission: AdminPermissionLevel = jwtUtils.extractClaim(token, key = CLAIM_PERMISSION_KEY) + ?.let { + AdminPermissionLevel.valueOf(it) } - log.info { "[AuthInterceptor] 인증 완료. adminId=$adminId, permission=${permission}" } + ?: run { + if (type != PrincipalType.ADMIN) { + log.warn { "[AdminInterceptor] 회원의 관리자 API 접근: id=${id}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) + } + log.warn { "[AdminInterceptor] 토큰에서 이용자 권한이 조회되지 않음: id=${id}" } + throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) + } + + if (!permission.hasPrivilege(annotation.privilege)) { + log.warn { "[AdminInterceptor] 관리자 권한 부족: required=${annotation.privilege} / current=${permission}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) } + log.info { "[AdminInterceptor] 인증 완료. adminId=$id, permission=${permission}" } + return true } } diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt index ff248ff1..90ee6b45 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt @@ -4,17 +4,13 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor import roomescape.auth.business.AuthServiceV2 -import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.Authenticated -import roomescape.auth.web.support.MDC_MEMBER_ID_KEY import roomescape.auth.web.support.accessToken -import roomescape.common.dto.PrincipalType private val log: KLogger = KotlinLogging.logger {} @@ -34,12 +30,10 @@ class AuthenticatedInterceptor( } val token: String? = request.accessToken() - - val id = jwtUtils.extractSubject(token).also { MDC.put(MDC_MEMBER_ID_KEY, it) } - val type = jwtUtils.extractClaim(token, CLAIM_TYPE_KEY) + val (id, type) = jwtUtils.extractIdAndType(token) try { - authService.findContextById(id.toLong(), PrincipalType.valueOf(type)) + authService.findContextById(id, type) log.info { "[AuthenticatedInterceptor] 인증 완료. id=$id, type=${type}" } return true diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt index c350f2ec..ac42cd0f 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/UserInterceptor.kt @@ -4,15 +4,12 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor -import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils -import roomescape.auth.web.support.MDC_MEMBER_ID_KEY import roomescape.auth.web.support.UserOnly import roomescape.auth.web.support.accessToken import roomescape.common.dto.PrincipalType @@ -34,17 +31,14 @@ class UserInterceptor( } val token: String? = request.accessToken() - val userId = jwtUtils.extractSubject(token).also { id -> MDC.put(MDC_MEMBER_ID_KEY, id) } + val (id, type) = jwtUtils.extractIdAndType(token) - jwtUtils.extractClaim(token, CLAIM_TYPE_KEY).also { - if (it != PrincipalType.USER.name) { - log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${userId}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) - } + if (type != PrincipalType.USER) { + log.warn { "[UserInterceptor] 관리자의 회원 API 접근: id=${id}" } + throw AuthException(AuthErrorCode.ACCESS_DENIED) } - log.info { "[AuthInterceptor] 인증 완료. userId=$userId" } - + log.info { "[UserInterceptor] 인증 완료. userId=$id" } return true } } diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt index a5024aa1..a4ca2652 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt @@ -10,13 +10,11 @@ import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer import roomescape.auth.business.AuthServiceV2 -import roomescape.auth.business.CLAIM_TYPE_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.accessToken -import roomescape.common.dto.PrincipalType private val log: KLogger = KotlinLogging.logger {} @@ -40,14 +38,12 @@ class CurrentUserContextResolver( val token: String? = request.accessToken() try { - val id: String = jwtUtils.extractSubject(token) - val type: PrincipalType = PrincipalType.valueOf(jwtUtils.extractClaim(token, CLAIM_TYPE_KEY)) + val (id, type) = jwtUtils.extractIdAndType(token) - return authService.findContextById(id.toLong(), type) + return authService.findContextById(id, type) } catch (e: Exception) { log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } throw AuthException(AuthErrorCode.MEMBER_NOT_FOUND) } } } - -- 2.47.2 From 8d86dd8a709ee4414c30209770d5465490419128 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:47:47 +0900 Subject: [PATCH 36/73] =?UTF-8?q?refactor:=20MDC=EC=97=90=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=20=ED=9A=8C=EC=9B=90=20ID=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/common/config/JpaConfig.kt | 4 ++-- src/main/kotlin/roomescape/common/dto/CommonAuth.kt | 2 ++ .../roomescape/common/log/ApiLogMessageConverter.kt | 12 ++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/roomescape/common/config/JpaConfig.kt b/src/main/kotlin/roomescape/common/config/JpaConfig.kt index 842cd0da..8944e26a 100644 --- a/src/main/kotlin/roomescape/common/config/JpaConfig.kt +++ b/src/main/kotlin/roomescape/common/config/JpaConfig.kt @@ -5,7 +5,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.domain.AuditorAware import org.springframework.data.jpa.repository.config.EnableJpaAuditing -import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY import java.util.* @Configuration @@ -18,7 +18,7 @@ class JpaConfig { class MdcAuditorAware : AuditorAware { override fun getCurrentAuditor(): Optional { - val memberIdStr: String? = MDC.get(MDC_MEMBER_ID_KEY) + val memberIdStr: String? = MDC.get(MDC_PRINCIPAL_ID_KEY) if (memberIdStr == null) { return Optional.empty() diff --git a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt index de7ab182..7d67a872 100644 --- a/src/main/kotlin/roomescape/common/dto/CommonAuth.kt +++ b/src/main/kotlin/roomescape/common/dto/CommonAuth.kt @@ -2,6 +2,8 @@ package roomescape.common.dto import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +const val MDC_PRINCIPAL_ID_KEY: String = "principal_id" + abstract class LoginCredentials { abstract val id: Long abstract val password: String diff --git a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt index dd41a26e..85e042be 100644 --- a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt +++ b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt @@ -3,7 +3,7 @@ package roomescape.common.log import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.http.HttpServletRequest import org.slf4j.MDC -import roomescape.auth.web.support.MDC_MEMBER_ID_KEY +import roomescape.common.dto.MDC_PRINCIPAL_ID_KEY enum class LogType { INCOMING_HTTP_REQUEST, @@ -34,8 +34,8 @@ class ApiLogMessageConverter( controllerPayload: Map, ): String { val payload: MutableMap = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) - val memberId: Long? = MDC.get(MDC_MEMBER_ID_KEY)?.toLong() - if (memberId != null) payload["member_id"] = memberId else payload["member_id"] = "NONE" + val memberId: Long? = MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLong() + if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE" payload.putAll(controllerPayload) @@ -48,9 +48,9 @@ class ApiLogMessageConverter( payload["endpoint"] = request.endpoint payload["status_code"] = request.httpStatus - MDC.get(MDC_MEMBER_ID_KEY)?.toLongOrNull() - ?.let { payload["member_id"] = it } - ?: run { payload["member_id"] = "NONE" } + MDC.get(MDC_PRINCIPAL_ID_KEY)?.toLongOrNull() + ?.let { payload["principal_id"] = it } + ?: run { payload["principal_id"] = "NONE" } request.startTime?.let { payload["duration_ms"] = System.currentTimeMillis() - it } request.body?.let { payload["response_body"] = it } -- 2.47.2 From 1ddf812d1c6d6ee1122f1f163bfb134f5b0eb198 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:48:57 +0900 Subject: [PATCH 37/73] =?UTF-8?q?feat:=20RestAssuredUtils=EC=97=90=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/util/RestAssuredUtils.kt | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index 79e7bc43..bfe87932 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -7,7 +7,9 @@ import io.restassured.module.kotlin.extensions.When import io.restassured.response.Response import io.restassured.response.ValidatableResponse import io.restassured.specification.RequestSpecification +import org.hamcrest.CoreMatchers.equalTo import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import org.springframework.http.MediaType import roomescape.admin.infrastructure.persistence.AdminEntity @@ -16,9 +18,9 @@ import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequestV2 import roomescape.common.config.next import roomescape.common.dto.PrincipalType +import roomescape.common.exception.ErrorCode import roomescape.member.infrastructure.persistence.* import roomescape.member.web.UserCreateRequest -import roomescape.member.web.toEntity class AuthUtil( private val memberRepository: MemberRepository, @@ -87,10 +89,11 @@ class AuthUtil( if (adminRepository.findByAccount(admin.account) == null) { adminRepository.save(admin) } + val requestBody = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) return Given { contentType(MediaType.APPLICATION_JSON_VALUE) - body(LoginRequestV2(account = admin.account, password = admin.password, principalType = PrincipalType.ADMIN)) + body(requestBody) } When { post("/auth/login") } Then { @@ -139,6 +142,37 @@ fun runTest( } } +fun runExceptionTest( + token: String? = null, + method: HttpMethod, + requestBody: Any? = null, + endpoint: String, + expectedErrorCode: ErrorCode +): ValidatableResponse { + return runTest( + token = token, + using = { + requestBody?.let { body(requestBody) } ?: this + }, + on = { + when (method) { + HttpMethod.GET -> get(endpoint) + HttpMethod.POST -> post(endpoint) + HttpMethod.PATCH -> patch(endpoint) + HttpMethod.DELETE -> delete(endpoint) + + else -> { + throw AssertionError("Unsupported HTTP method: $method") + } + } + }, + expect = { + statusCode(expectedErrorCode.httpStatus.value()) + body("code", equalTo(expectedErrorCode.errorCode)) + } + ) +} + /** * @param props: RestAssured 응답 Body 에서 존재 & Null 여부를 확인할 프로퍼티 이름 */ -- 2.47.2 From ee9d8cd9f055dc47f24c185ce288beba805780b9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:49:51 +0900 Subject: [PATCH 38/73] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20/=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=EC=97=90=EC=84=9C=EC=9D=98=20createdBy,=20up?= =?UTF-8?q?datedBy=20=EC=99=B8=EB=9E=98=ED=82=A4=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/schema-h2.sql | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 978bbae1..ec8ab571 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -88,10 +88,7 @@ create table if not exists theme ( created_at timestamp not null, created_by bigint not null, updated_at timestamp not null, - updated_by bigint not null, - - constraint fk_theme__created_by foreign key (created_by) references members (member_id), - constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) + updated_by bigint not null ); create table if not exists schedule ( @@ -106,8 +103,6 @@ create table if not exists schedule ( updated_by bigint not null, constraint uk_schedule__date_time_theme_id unique (date, time, theme_id), - constraint fk_schedule__created_by foreign key (created_by) references members (member_id), - constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id), constraint fk_schedule__theme_id foreign key (theme_id) references theme (id) ); -- 2.47.2 From 3bed383218f95147d60e6bf8e18990f86f457dee Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:50:58 +0900 Subject: [PATCH 39/73] =?UTF-8?q?test:=20=EC=9D=BC=EC=A0=95(schedule)=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=83=88=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=90=9C=20=ED=9A=8C=EC=9B=90=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B6=8C=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/ScheduleApiTest.kt | 679 +++++++++--------- 1 file changed, 340 insertions(+), 339 deletions(-) diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index e8164fee..72573521 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -4,22 +4,17 @@ import io.kotest.matchers.date.shouldBeAfter import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import io.restassured.module.kotlin.extensions.Extract -import io.restassured.module.kotlin.extensions.Given -import io.restassured.module.kotlin.extensions.When -import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.notNullValue import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus -import org.springframework.http.MediaType +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.auth.exception.AuthErrorCode -import roomescape.member.infrastructure.persistence.Role import roomescape.schedule.exception.ScheduleErrorCode import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleStatus -import roomescape.schedule.web.ScheduleCreateRequest import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.util.* import roomescape.util.ScheduleFixture.createRequest @@ -31,162 +26,47 @@ class ScheduleApiTest( ) : FunSpecSpringbootTest() { init { - context("관리자가 아니면 접근할 수 없다.") { - lateinit var token: String - - beforeTest { - token = authUtil.loginAsUser() - } - - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.FORBIDDEN.value()) - body( - "code", - equalTo(AuthErrorCode.ACCESS_DENIED.errorCode) - ) - } - - test("일정 상세: GET /schedules/{id}") { - runTest( - token = token, - on = { - get("/schedules/$INVALID_PK") - }, - expect = commonAssertion - ) - } - - test("일정 생성: POST /schedules") { - runTest( - token = token, - using = { - body(createRequest) - }, - on = { - get("/schedules/$INVALID_PK") - }, - expect = commonAssertion - ) - } - - test("일정 수정: PATCH /schedules/{id}") { - val createdSchedule: ScheduleEntity = - createDummySchedule( - createRequest - ) - - runTest( - token = token, - using = { - body(createRequest) - }, - on = { - patch("/schedules/${createdSchedule.id}") - }, - expect = commonAssertion - ) - } - - test("일정 삭제: DELETE /schedules/{id}") { - val createdSchedule: ScheduleEntity = - createDummySchedule( - createRequest - ) - - runTest( - token = token, - on = { - delete("/schedules/${createdSchedule.id}") - }, - expect = commonAssertion - ) - } - } - - context("일반 회원도 접근할 수 있다.") { - lateinit var token: String - - beforeTest { - token = authUtil.loginAsUser() - } - - test("예약 가능 테마 조회: GET /schedules/themes?date={date}") { - val date = LocalDate.now().plusDays(1) - val time = LocalTime.now() - val createdSchedule: ScheduleEntity = - createDummySchedule( - createRequest.copy( - date = date, - time = time - ) - ) - - runTest( - token = token, - on = { - get("/schedules/themes?date=$date") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themeIds.size()", equalTo(1)) - body("data.themeIds[0]", equalTo(createdSchedule.themeId)) - } - ) - } - - test("동일한 날짜, 테마에 대한 시간 조회: GET /schedules?date={date}&themeId={themeId}") { - val date = LocalDate.now().plusDays(1) - val time = LocalTime.now() - - val createdSchedule: ScheduleEntity = - createDummySchedule( - createRequest.copy( - date = date, - time = time - ) - ) - createDummySchedule( - createRequest.copy( - date = date.plusDays(1L), - time = time, - themeId = createdSchedule.themeId - ) - ) - - runTest( - token = token, - on = { - get("/schedules?date=$date&themeId=${createdSchedule.themeId}") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(1)) - body("data.schedules[0].id", equalTo(createdSchedule.id)) - assertProperties( - props = setOf("id", "time", "status"), - propsNameIfList = "schedules" - ) - } - ) - } - } context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") { + val date = LocalDate.now().plusDays(1) + val endpoint = "/schedules/themes?date=$date" + + context("권한이 없으면 접근할 수 없다.") { + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + } + test("정상 응답") { - val date = LocalDate.now().plusDays(1) + val adminToken = authUtil.defaultAdminLogin() + for (i in 1..10) { - createDummySchedule( - createRequest.copy( - date = date, - time = LocalTime.now().plusMinutes(i.toLong()) + dummyInitializer.createSchedule( + adminToken = adminToken, + request = createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()) ) ) } runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), on = { - get("/schedules/themes?date=$date") + get(endpoint) }, expect = { statusCode(HttpStatus.OK.value()) @@ -197,26 +77,49 @@ class ScheduleApiTest( } context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules?date=2025-09-12&themeId=12345" + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + } + test("정상 응답") { val date = LocalDate.now().plusDays(1) - val createdSchedule = createDummySchedule( - createRequest.copy( - date = date, - time = LocalTime.now() - ) + val adminToken = authUtil.defaultAdminLogin() + val createdSchedule = dummyInitializer.createSchedule( + adminToken = adminToken, + request = createRequest.copy(date = date, time = LocalTime.now()) ) + for (i in 1..10) { - createDummySchedule( - createRequest.copy( - date = date, - time = LocalTime.now().plusMinutes(i.toLong()), - themeId = createdSchedule.themeId + dummyInitializer.createSchedule( + adminToken = adminToken, + request = createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()), + themeId = createdSchedule.themeId ) ) } runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), on = { get("/schedules?date=$date&themeId=${createdSchedule.themeId}") }, @@ -233,11 +136,48 @@ class ScheduleApiTest( } context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules/${INVALID_PK}" + + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + test("권한이 ${AdminPermissionLevel.READ_SUMMARY}인 관리자") { + val admin = AdminFixture.create(permissionLevel = AdminPermissionLevel.READ_SUMMARY) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 응답") { - val createdSchedule = createDummySchedule(createRequest) + val token = authUtil.defaultAdminLogin() + + val createdSchedule = dummyInitializer.createSchedule( + adminToken = token, + request = createRequest + ) runTest( - token = authUtil.loginAsAdmin(), + token = token, on = { get("/schedules/${createdSchedule.id}") }, @@ -253,49 +193,67 @@ class ScheduleApiTest( } ).also { it.extract().path("data.createdAt") shouldNotBeNull {} - it.extract().path("data.createdBy") shouldNotBeNull {} - it.extract().path("data.createdAt") shouldNotBeNull {} - it.extract().path("data.createdAt") shouldNotBeNull {} + it.extract().path>("data.createdBy") shouldNotBeNull {} + it.extract().path("data.updatedAt") shouldNotBeNull {} + it.extract().path>("data.updatedBy") shouldNotBeNull {} } } test("일정이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsAdmin(), - on = { - get("/schedules/$INVALID_PK") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body( - "code", - equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode) - ) - } + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.GET, + endpoint = "/schedules/$INVALID_PK", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } } context("일정을 생성한다.") { - lateinit var token: String + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules" - beforeTest { - token = authUtil.loginAsAdmin() + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } test("정상 생성 및 감사 정보 확인") { - /** - * FK 제약조건 해소를 위한 테마 생성 API 호출 및 ID 획득 - */ - val themeId: Long = Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - header("Authorization", "Bearer $token") - body(ThemeFixture.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) - } When { - post("/admin/themes") - } Extract { - path("data.id") - } + val token = authUtil.defaultAdminLogin() + + val themeId: Long = dummyInitializer.createTheme( + adminToken = token, + request = ThemeFixture.createRequest.copy(name = "theme-${System.currentTimeMillis()}") + ).id /** * 생성 테스트 @@ -328,62 +286,70 @@ class ScheduleApiTest( } test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { + val token = authUtil.defaultAdminLogin() val date = LocalDate.now().plusDays(1) val time = LocalTime.of(10, 0) - val alreadyCreated: ScheduleEntity = createDummySchedule( - createRequest.copy( - date = date, time = time - ) + val alreadyCreated: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = token, + request = createRequest.copy(date = date, time = time) ) - runTest( + val body = createRequest.copy(date = date, time = time, themeId = alreadyCreated.themeId) + + runExceptionTest( token = token, - using = { - body( - createRequest.copy( - date = date, time = time, themeId = alreadyCreated.themeId - ) - ) - }, - on = { - post("/schedules") - }, - expect = { - statusCode(HttpStatus.CONFLICT.value()) - body("code", equalTo(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS.errorCode)) - } + method = HttpMethod.POST, + endpoint = "/schedules", + requestBody = body, + expectedErrorCode = ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS ) } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - runTest( + val token = authUtil.defaultAdminLogin() + val body = createRequest.copy(LocalDate.now(), LocalTime.now().minusMinutes(1)) + + runExceptionTest( token = token, - using = { - body( - createRequest.copy( - date = LocalDate.now(), - time = LocalTime.now().minusMinutes(1) - ) - ) - }, - on = { - post("/schedules") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) - } + method = HttpMethod.POST, + endpoint = "/schedules", + requestBody = body, + expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME ) } } context("일정을 잠시 Hold 상태로 변경하여 중복 예약을 방지한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules/${INVALID_PK}/hold" + + test("비회원") { + runExceptionTest( + method = HttpMethod.PATCH, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.PATCH, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태이면 정상 응답") { - val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = authUtil.defaultAdminLogin(), + request = createRequest + ) runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), on = { patch("/schedules/${createdSchedule.id}/hold") }, @@ -399,32 +365,31 @@ class ScheduleApiTest( } test("예약이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsUser(), - on = { - patch("/schedules/$INVALID_PK/hold") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.PATCH, + endpoint = "/schedules/$INVALID_PK/hold", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } test("해당 일정이 ${ScheduleStatus.AVAILABLE} 상태가 아니면 실패한다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + val adminToken = authUtil.defaultAdminLogin() + + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = adminToken, + request = createRequest + ) /* * 테스트를 위해 수정 API를 호출하여 상태를 HOLD 상태로 변경 * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( - token = authUtil.loginAsAdmin(), + token = adminToken, using = { body( - ScheduleUpdateRequest( - status = ScheduleStatus.HOLD - ) + ScheduleUpdateRequest(status = ScheduleStatus.HOLD) ) }, on = { @@ -435,15 +400,11 @@ class ScheduleApiTest( } ) - runTest( - token = authUtil.loginAsUser(), - on = { - patch("/schedules/${createdSchedule.id}/hold") - }, - expect = { - statusCode(HttpStatus.CONFLICT.value()) - body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.PATCH, + endpoint = "/schedules/${createdSchedule.id}/hold", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE ) } } @@ -454,14 +415,55 @@ class ScheduleApiTest( status = ScheduleStatus.BLOCKED ) + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules/${INVALID_PK}" + + test("비회원") { + runExceptionTest( + method = HttpMethod.PATCH, + requestBody = updateRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.PATCH, + requestBody = updateRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.PATCH, + requestBody = updateRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + } + test("정상 수정 및 감사 정보 변경 확인") { - val createdSchedule: ScheduleEntity = createDummySchedule( - createRequest.copy( + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = authUtil.defaultAdminLogin(), + request = createRequest.copy( date = LocalDate.now().plusDays(1), time = LocalTime.now().plusMinutes(1), ) ) - val otherAdminToken = authUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + + val otherAdminToken = authUtil.adminLogin( + AdminFixture.create(account = "otherAdmin", phone = "01099999999") + ) runTest( token = otherAdminToken, @@ -487,10 +489,15 @@ class ScheduleApiTest( } test("입력값이 없으면 수정하지 않는다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + val token = authUtil.defaultAdminLogin() + + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = token, + request = createRequest + ) runTest( - token = authUtil.loginAsAdmin(), + token = token, using = { body(ScheduleUpdateRequest()) }, @@ -509,52 +516,78 @@ class ScheduleApiTest( } test("일정이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsAdmin(), - using = { - body(updateRequest) - }, - on = { - patch("/schedules/$INVALID_PK") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.PATCH, + requestBody = updateRequest, + endpoint = "/schedules/${INVALID_PK}", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_FOUND ) } test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { - val createdSchedule: ScheduleEntity = createDummySchedule( - createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) + val token = authUtil.defaultAdminLogin() + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = token, + request = + + createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) ) - runTest( - token = authUtil.loginAsAdmin(), - using = { - body( - updateRequest.copy( - time = LocalTime.now().minusMinutes(1) - ) - ) - }, - on = { - patch("/schedules/${createdSchedule.id}") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) - } + runExceptionTest( + token = token, + method = HttpMethod.PATCH, + requestBody = updateRequest.copy(time = LocalTime.now().minusMinutes(1)), + endpoint = "/schedules/${createdSchedule.id}", + expectedErrorCode = ScheduleErrorCode.PAST_DATE_TIME ) } } context("일정을 삭제한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/schedules/${INVALID_PK}" + + test("비회원") { + runExceptionTest( + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + } + test("정상 삭제") { - val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + val token = authUtil.defaultAdminLogin() + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = token, + request = createRequest + ) runTest( - token = authUtil.loginAsAdmin(), + token = token, on = { delete("/schedules/${createdSchedule.id}") }, @@ -567,19 +600,22 @@ class ScheduleApiTest( } test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { - val createdSchedule: ScheduleEntity = createDummySchedule(createRequest) + val token = authUtil.defaultAdminLogin() + + val createdSchedule: ScheduleEntity = dummyInitializer.createSchedule( + adminToken = token, + request = createRequest + ) /* * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경 * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. */ runTest( - token = authUtil.loginAsAdmin(), + token = token, using = { body( - ScheduleUpdateRequest( - status = ScheduleStatus.RESERVED - ) + ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) ) }, on = { @@ -593,48 +629,13 @@ class ScheduleApiTest( /** * 삭제 테스트 */ - runTest( - token = authUtil.loginAsAdmin(), - on = { - delete("/schedules/${createdSchedule.id}") - }, - expect = { - statusCode(HttpStatus.CONFLICT.value()) - body("code", equalTo(ScheduleErrorCode.SCHEDULE_IN_USE.errorCode)) - } + runExceptionTest( + token = token, + method = HttpMethod.DELETE, + endpoint = "/schedules/${createdSchedule.id}", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_IN_USE ) } } } - - fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity { - val token = authUtil.loginAsAdmin() - - val themeId: Long = if (request.themeId > 1L) { - request.themeId - } else { - Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - header("Authorization", "Bearer $token") - body(ThemeFixture.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) - } When { - post("/admin/themes") - } Extract { - path("data.id") - } - } - - val createdScheduleId: Long = Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - header("Authorization", "Bearer $token") - body(request.copy(themeId = themeId)) - } When { - post("/schedules") - } Extract { - path("data.id") - } - - return scheduleRepository.findByIdOrNull(createdScheduleId) - ?: throw RuntimeException("unreachable line") - } -} \ No newline at end of file +} -- 2.47.2 From 741888f156e06037bf2f01247cb89db3a1ab0169 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 11:51:05 +0900 Subject: [PATCH 40/73] =?UTF-8?q?test:=20=ED=85=8C=EB=A7=88=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=90=9C=20=ED=9A=8C=EC=9B=90=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B6=8C=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/theme/ThemeApiTest.kt | 707 ++++++++++-------- 1 file changed, 408 insertions(+), 299 deletions(-) diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index c96fa6a1..fd9e29a6 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -8,9 +8,10 @@ import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.notNullValue import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.auth.exception.AuthErrorCode -import roomescape.member.infrastructure.persistence.Role import roomescape.theme.business.MIN_DURATION import roomescape.theme.business.MIN_PARTICIPANTS import roomescape.theme.business.MIN_PRICE @@ -19,11 +20,8 @@ import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.ThemeListRetrieveRequest import roomescape.theme.web.ThemeUpdateRequest -import roomescape.util.FunSpecSpringbootTest -import roomescape.util.INVALID_PK +import roomescape.util.* import roomescape.util.ThemeFixture.createRequest -import roomescape.util.assertProperties -import roomescape.util.runTest import kotlin.random.Random class ThemeApiTest( @@ -31,115 +29,55 @@ class ThemeApiTest( ) : FunSpecSpringbootTest() { init { - context("관리자가 아니면 접근할 수 없다.") { - lateinit var token: String - - beforeTest { - token = authUtil.loginAsUser() - } - - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.FORBIDDEN.value()) - body("code", equalTo(AuthErrorCode.ACCESS_DENIED.errorCode)) - } - - test("테마 생성: POST /admin/themes") { - runTest( - token = token, - using = { - body(createRequest) - }, - on = { - post("/admin/themes") - }, - expect = commonAssertion - ) - } - - test("테마 조회: GET /admin/themes") { - runTest( - token = token, - on = { - get("/admin/themes") - }, - expect = commonAssertion - ) - } - - test("테마 상세 조회: GET /admin/themes/{id}") { - runTest( - token = token, - on = { - get("/admin/themes/$INVALID_PK") - }, - expect = commonAssertion - ) - } - - test("테마 수정: PATCH /admin/themes/{id}") { - runTest( - token = token, - using = { - body(createRequest) - }, - on = { - patch("/admin/themes/$INVALID_PK") - }, - expect = commonAssertion - ) - } - - test("테마 삭제: DELETE /admin/themes/{id}") { - runTest( - token = token, - on = { - delete("/admin/themes/$INVALID_PK") - }, - expect = commonAssertion - ) - } - } - - context("일반 회원도 접근할 수 있다.") { - test("테마 조회: GET /v2/themes") { - val token = authUtil.loginAsUser() - - dummyInitializer.createTheme( - adminToken = authUtil.loginAsAdmin(), - request = createRequest.copy(name = "test123", isOpen = true) - ) - - runTest( - token = token, - on = { - get("/v2/themes") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(1)) - body("data.themes[0].name", equalTo("test123")) - } - ) - } - } - context("테마를 생성한다.") { - val apiPath = "/admin/themes" + val endpoint = "/admin/themes" - lateinit var token: String + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } - beforeTest { - token = authUtil.loginAsAdmin() + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.POST, + requestBody = createRequest, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } } + test("정상 생성 및 감사 정보 확인") { + val token = authUtil.defaultAdminLogin() + runTest( token = token, using = { body(createRequest) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.CREATED.value()) @@ -159,6 +97,7 @@ class ThemeApiTest( } test("이미 동일한 이름의 테마가 있으면 실패한다.") { + val token = authUtil.defaultAdminLogin() val commonName = "test123" dummyInitializer.createTheme( adminToken = token, @@ -171,7 +110,7 @@ class ThemeApiTest( body(createRequest.copy(name = commonName)) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -181,13 +120,14 @@ class ThemeApiTest( } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(price = (MIN_PRICE - 1))) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -203,39 +143,42 @@ class ThemeApiTest( } test("field: availableMinutes") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort())) }, on = { - post(apiPath) + post(endpoint) }, expect = commonAssertion ) } test("field: expectedMinutesFrom") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) }, on = { - post(apiPath) + post(endpoint) }, expect = commonAssertion ) } test("field: expectedMinutesTo") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) }, on = { - post(apiPath) + post(endpoint) }, expect = commonAssertion ) @@ -244,13 +187,14 @@ class ThemeApiTest( context("시간 범위가 잘못 지정되면 실패한다.") { test("최소 예상 시간 > 최대 예상 시간") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -260,6 +204,7 @@ class ThemeApiTest( } test("최대 예상 시간 > 이용 가능 시간") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { @@ -272,7 +217,7 @@ class ThemeApiTest( ) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -289,26 +234,28 @@ class ThemeApiTest( } test("field: minParticipants") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) }, on = { - post(apiPath) + post(endpoint) }, expect = commonAssertion ) } test("field: maxParticipants") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) }, on = { - post(apiPath) + post(endpoint) }, expect = commonAssertion ) @@ -317,13 +264,14 @@ class ThemeApiTest( context("인원 범위가 잘못 지정되면 실패한다.") { test("최소 인원 > 최대 인원") { + val token = authUtil.defaultAdminLogin() runTest( token = token, using = { body(createRequest.copy(minParticipants = 10, maxParticipants = 9)) }, on = { - post(apiPath) + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -335,26 +283,41 @@ class ThemeApiTest( } context("입력된 모든 ID에 대한 테마를 조회한다.") { - val themeIds = mutableListOf() + val endpoint = "/themes/retrieve" - beforeTest { - for (i in 1..3) { - dummyInitializer.createTheme( - adminToken = authUtil.loginAsAdmin(), - request = createRequest.copy(name = "test$i") - ).also { - themeIds.add(it.id) - } + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + requestBody = ThemeListRetrieveRequest(themeIds = listOf()), + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + requestBody = ThemeListRetrieveRequest(themeIds = listOf()), + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) } } - afterTest { - themeIds.clear() - } - test("정상 응답") { + val adminToken = authUtil.defaultAdminLogin() + val themeSize = 3 + val themeIds = mutableListOf() + + for (i in 1..themeSize) { + dummyInitializer.createTheme(adminToken, createRequest.copy(name = "test$i")) + .also { themeIds.add(it.id) } + } + runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(ThemeListRetrieveRequest(themeIds)) }, @@ -363,16 +326,24 @@ class ThemeApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(3)) + body("data.themes.size()", equalTo(themeSize)) } ) } test("없는 테마가 있으면 생략한다.") { - themeIds.add(INVALID_PK) + val token = authUtil.defaultAdminLogin() + val themeSize = 3 + val themeIds = mutableListOf() + for (i in 1..themeSize) { + dummyInitializer.createTheme(token, createRequest.copy(name = "test$i")) + .also { themeIds.add(it.id) } + } + + themeIds.add(INVALID_PK) runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(ThemeListRetrieveRequest(themeIds)) }, @@ -381,35 +352,50 @@ class ThemeApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(3)) + body("data.themes.size()", equalTo(themeSize)) } ) } } - context("모든 테마를 조회한다.") { - lateinit var token: String + context("관리자가 모든 테마를 조회한다.") { + val endpoint = "/admin/themes" + val requests = listOf( + createRequest.copy(name = "open", isOpen = true), + createRequest.copy(name = "close", isOpen = false) + ) - beforeTest { - token = authUtil.loginAsAdmin() - dummyInitializer.createTheme( - adminToken = token, - request = createRequest.copy(name = "open", isOpen = true) - ) - dummyInitializer.createTheme( - adminToken = token, - request = createRequest.copy(name = "close", isOpen = false) - ) + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } } - test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") { + + test("비공개 테마까지 포함하여 간단한 정보만 조회된다.") { + val token = authUtil.defaultAdminLogin() + requests.forEach { dummyInitializer.createTheme(token, it) } + runTest( token = token, on = { get("/admin/themes") }, expect = { - body("data.themes.size()", equalTo(2)) + body("data.themes.size()", equalTo(requests.size)) assertProperties( props = setOf("id", "name", "difficulty", "price", "isOpen"), propsNameIfList = "themes", @@ -417,10 +403,41 @@ class ThemeApiTest( } ) } + } + + context("예약 페이지에서 테마를 조회한다.") { + val endpoint = "/v2/themes" + + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + + test("공개된 테마의 전체 정보가 조회된다.") { + val token = authUtil.defaultAdminLogin() + listOf( + createRequest.copy(name = "open", isOpen = true), + createRequest.copy(name = "close", isOpen = false) + ).forEach { + dummyInitializer.createTheme(token, it) + } - test("예약 페이지에서는 공개된 테마의 전체 정보가 조회된다.") { runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), on = { get("/v2/themes") }, @@ -441,8 +458,40 @@ class ThemeApiTest( } context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/admin/themes/$INVALID_PK" + + test("비회원") { + runExceptionTest( + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + test("권한이 ${AdminPermissionLevel.READ_SUMMARY}인 관리자") { + val admin = AdminFixture.create(permissionLevel = AdminPermissionLevel.READ_SUMMARY) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.GET, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 응답") { - val token = authUtil.loginAsAdmin() + val token = authUtil.defaultAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -469,22 +518,53 @@ class ThemeApiTest( } test("테마가 없으면 실패한다.") { - runTest( - token = authUtil.loginAsAdmin(), - on = { - get("/admin/themes/$INVALID_PK") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.GET, + endpoint = "/admin/themes/$INVALID_PK", + expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND ) } } context("테마를 삭제한다.") { + + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/admin/themes/${INVALID_PK}" + + test("비회원") { + runExceptionTest( + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.DELETE, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + } + test("정상 삭제") { - val token = authUtil.loginAsAdmin() + val token = authUtil.defaultAdminLogin() val createdTheme = dummyInitializer.createTheme( adminToken = token, request = createRequest @@ -504,37 +584,64 @@ class ThemeApiTest( } test("테마가 없으면 실패한다.") { - runTest( - token = authUtil.loginAsAdmin(), - on = { - delete("/admin/themes/$INVALID_PK") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.DELETE, + endpoint = "/admin/themes/$INVALID_PK", + expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND ) } } context("테마를 수정한다.") { - lateinit var token: String - lateinit var createdTheme: ThemeEntity - lateinit var apiPath: String + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/admin/themes/${INVALID_PK}" + val request = ThemeUpdateRequest(name = "hello") + + test("비회원") { + runExceptionTest( + method = HttpMethod.PATCH, + endpoint = endpoint, + requestBody = request, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("회원") { + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.PATCH, + endpoint = endpoint, + requestBody = request, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + + listOf(AdminPermissionLevel.READ_SUMMARY, AdminPermissionLevel.READ_ALL).forEach { + test("권한이 ${it}인 관리자") { + val admin = AdminFixture.create(permissionLevel = it) + + runExceptionTest( + token = authUtil.adminLogin(admin), + method = HttpMethod.PATCH, + endpoint = endpoint, + requestBody = request, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + } val updateRequest = ThemeUpdateRequest(name = "modified") - beforeTest { - token = authUtil.loginAsAdmin() - createdTheme = dummyInitializer.createTheme( - adminToken = token, + test("정상 수정 및 감사 정보 변경 확인") { + val createdTheme: ThemeEntity = dummyInitializer.createTheme( + adminToken = authUtil.defaultAdminLogin(), request = createRequest.copy(name = "theme-${Random.nextInt()}") ) - apiPath = "/admin/themes/${createdTheme.id}" - } - - test("정상 수정 및 감사 정보 변경 확인") { - val otherAdminToken = authUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + val otherAdminToken: String = authUtil.adminLogin( + AdminFixture.create(account = "hello", phone = "0101828402") + ) runTest( token = otherAdminToken, @@ -542,7 +649,7 @@ class ThemeApiTest( body(updateRequest) }, on = { - patch(apiPath) + patch("/admin/themes/${createdTheme.id}") }, expect = { statusCode(HttpStatus.OK.value()) @@ -558,13 +665,18 @@ class ThemeApiTest( } test("입력값이 없으면 수정하지 않는다.") { + val createdTheme: ThemeEntity = dummyInitializer.createTheme( + adminToken = authUtil.defaultAdminLogin(), + request = createRequest.copy(name = "theme-${Random.nextInt()}") + ) + runTest( - token = authUtil.loginAsAdmin(), + token = authUtil.defaultAdminLogin(), using = { body(ThemeUpdateRequest()) }, on = { - patch(apiPath) + patch("/admin/themes/${createdTheme.id}") }, expect = { statusCode(HttpStatus.OK.value()) @@ -578,170 +690,167 @@ class ThemeApiTest( } test("테마가 없으면 실패한다.") { - runTest( - token = token, - using = { - body(updateRequest) - }, - on = { - patch("/admin/themes/$INVALID_PK") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ThemeErrorCode.THEME_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.PATCH, + endpoint = "/admin/themes/$INVALID_PK", + requestBody = updateRequest, + expectedErrorCode = ThemeErrorCode.THEME_NOT_FOUND ) } test("금액이 ${MIN_PRICE}원 미만이면 실패한다.") { - runTest( - token = token, - using = { - body(updateRequest.copy(price = (MIN_PRICE - 1))) - }, - on = { - patch("/admin/themes/${createdTheme.id}") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.PRICE_BELOW_MINIMUM.errorCode)) - } + val adminToken = authUtil.defaultAdminLogin() + val createdTheme: ThemeEntity = dummyInitializer.createTheme( + adminToken = adminToken, + request = createRequest.copy(name = "theme-${Random.nextInt()}") + ) + + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(price = (MIN_PRICE - 1)), + expectedErrorCode = ThemeErrorCode.PRICE_BELOW_MINIMUM ) } context("입력된 시간이 ${MIN_DURATION}분 미만이면 실패한다.") { - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.DURATION_BELOW_MINIMUM.errorCode)) + lateinit var adminToken: String + lateinit var createdTheme: ThemeEntity + + beforeTest { + adminToken = authUtil.defaultAdminLogin() + createdTheme = dummyInitializer.createTheme( + adminToken = adminToken, + request = createRequest.copy(name = "theme-${Random.nextInt()}") + ) } test("field: availableMinutes") { - runTest( - token = token, - using = { - body(updateRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort())) - }, - on = { - patch(apiPath) - }, - expect = commonAssertion + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } test("field: expectedMinutesFrom") { - runTest( - token = token, - using = { - body(updateRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) - }, - on = { - patch(apiPath) - }, - expect = commonAssertion + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } test("field: expectedMinutesTo") { - runTest( - token = token, - using = { - body(updateRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) - }, - on = { - patch(apiPath) - }, - expect = commonAssertion + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort()), + expectedErrorCode = ThemeErrorCode.DURATION_BELOW_MINIMUM ) } } context("시간 범위가 잘못 지정되면 실패한다.") { - test("최소 예상 시간 > 최대 예상 시간") { - runTest( - token = token, - using = { - body(updateRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) - }, - on = { - patch(apiPath) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME.errorCode)) - } + lateinit var adminToken: String + lateinit var createdTheme: ThemeEntity + + beforeTest { + adminToken = authUtil.defaultAdminLogin() + createdTheme = dummyInitializer.createTheme( + adminToken = adminToken, + request = createRequest.copy(name = "theme-${Random.nextInt()}") ) } + test("최소 예상 시간 > 최대 예상 시간") { + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99), + expectedErrorCode = ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME + ) + } + + test("최대 예상 시간 > 이용 가능 시간") { - runTest( - token = token, - using = { - body( - updateRequest.copy( - availableMinutes = 100, - expectedMinutesFrom = 101, - expectedMinutesTo = 101 - ) - ) - }, - on = { - patch(apiPath) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME.errorCode)) - } + val body = updateRequest.copy( + availableMinutes = 100, + expectedMinutesFrom = 101, + expectedMinutesTo = 101 + ) + + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = body, + expectedErrorCode = ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME ) } } + context("입력된 인원이 ${MIN_PARTICIPANTS}명 미만이면 실패한다.") { - val commonAssertion: ValidatableResponse.() -> Unit = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM.errorCode)) + lateinit var adminToken: String + lateinit var createdTheme: ThemeEntity + + beforeTest { + adminToken = authUtil.defaultAdminLogin() + createdTheme = dummyInitializer.createTheme( + adminToken = adminToken, + request = createRequest.copy(name = "theme-${Random.nextInt()}") + ) } test("field: minParticipants") { - runTest( - token = token, - using = { - body(updateRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) - }, - on = { - patch(apiPath) - }, - expect = commonAssertion + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort()), + expectedErrorCode = ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM ) } test("field: maxParticipants") { - runTest( - token = token, - using = { - body(updateRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) - }, - on = { - patch(apiPath) - }, - expect = commonAssertion + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort()), + expectedErrorCode = ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM ) } } context("인원 범위가 잘못 지정되면 실패한다.") { + lateinit var adminToken: String + lateinit var createdTheme: ThemeEntity + + beforeTest { + adminToken = authUtil.defaultAdminLogin() + createdTheme = dummyInitializer.createTheme( + adminToken = adminToken, + request = createRequest.copy(name = "theme-${Random.nextInt()}") + ) + } + test("최소 인원 > 최대 인원") { - runTest( - token = token, - using = { - body(updateRequest.copy(minParticipants = 10, maxParticipants = 9)) - }, - on = { - patch(apiPath) - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT.errorCode)) - } + runExceptionTest( + token = adminToken, + method = HttpMethod.PATCH, + endpoint = "/admin/themes/${createdTheme.id}", + requestBody = updateRequest.copy(minParticipants = 10, maxParticipants = 9), + expectedErrorCode = ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT ) } } -- 2.47.2 From e0972550d4e341f4ef20dd951c4a7ada3c39afd8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:28:34 +0900 Subject: [PATCH 41/73] =?UTF-8?q?refactor:=20Reservation=EC=9D=98=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20ID=20=EC=BB=AC=EB=9F=BC=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(member=5Fid=20->=20user=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/business/PaymentService.kt | 4 ++-- .../roomescape/payment/business/PaymentWriter.kt | 4 ++-- .../infrastructure/persistence/ReservationEntity.kt | 2 +- .../persistence/ReservationRepository.kt | 2 +- .../roomescape/reservation/web/ReservationDto.kt | 4 ++-- src/main/resources/schema/schema-h2.sql | 10 ++++------ src/test/kotlin/roomescape/util/DummyInitializer.kt | 4 ++-- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/business/PaymentService.kt b/src/main/kotlin/roomescape/payment/business/PaymentService.kt index 8885dc18..05d4ca5e 100644 --- a/src/main/kotlin/roomescape/payment/business/PaymentService.kt +++ b/src/main/kotlin/roomescape/payment/business/PaymentService.kt @@ -44,7 +44,7 @@ class PaymentService( } } - fun cancel(memberId: Long, request: PaymentCancelRequest) { + fun cancel(userId: Long, request: PaymentCancelRequest) { val payment: PaymentEntity = findByReservationIdOrThrow(request.reservationId) val clientCancelResponse: PaymentClientCancelResponse = paymentClient.cancel( @@ -55,7 +55,7 @@ class PaymentService( transactionExecutionUtil.withNewTransaction(isReadOnly = false) { paymentWriter.cancel( - memberId = memberId, + userId = userId, payment = payment, requestedAt = request.requestedAt, cancelResponse = clientCancelResponse diff --git a/src/main/kotlin/roomescape/payment/business/PaymentWriter.kt b/src/main/kotlin/roomescape/payment/business/PaymentWriter.kt index 52f9de11..60a7473f 100644 --- a/src/main/kotlin/roomescape/payment/business/PaymentWriter.kt +++ b/src/main/kotlin/roomescape/payment/business/PaymentWriter.kt @@ -59,7 +59,7 @@ class PaymentWriter( } fun cancel( - memberId: Long, + userId: Long, payment: PaymentEntity, requestedAt: LocalDateTime, cancelResponse: PaymentClientCancelResponse @@ -72,7 +72,7 @@ class PaymentWriter( id = tsidFactory.next(), paymentId = payment.id, cancelRequestedAt = requestedAt, - canceledBy = memberId + canceledBy = userId ).also { canceledPaymentRepository.save(it) log.debug { "[PaymentWriterV2.cancelPayment] 결제 취소 정보 저장 완료: payment.id=${payment.id}" } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index 48e68558..dfd87710 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -11,7 +11,7 @@ import roomescape.common.entity.AuditingBaseEntity class ReservationEntity( id: Long, - val memberId: Long, + val userId: Long, val scheduleId: Long, val reserverName: String, val reserverContact: String, diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index a36616a6..aba5f37f 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -4,5 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface ReservationRepository : JpaRepository { - fun findAllByMemberId(memberId: Long): List + fun findAllByUserId(userId: Long): List } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt index 43352f3e..5e5050cb 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt @@ -19,9 +19,9 @@ data class PendingReservationCreateRequest( val requirement: String ) -fun PendingReservationCreateRequest.toEntity(id: Long, memberId: Long) = ReservationEntity( +fun PendingReservationCreateRequest.toEntity(id: Long, userId: Long) = ReservationEntity( id = id, - memberId = memberId, + userId = userId, scheduleId = this.scheduleId, reserverName = this.reserverName, reserverContact = this.reserverContact, diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index ec8ab571..4551306b 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -108,7 +108,7 @@ create table if not exists schedule ( create table if not exists reservation ( id bigint primary key, - member_id bigint not null, + user_id bigint not null, schedule_id bigint not null, reserver_name varchar(30) not null, reserver_contact varchar(30) not null, @@ -120,7 +120,7 @@ create table if not exists reservation ( updated_at timestamp not null, updated_by bigint not null, - constraint fk_reservation__member_id foreign key (member_id) references members (member_id), + constraint fk_reservation__user_id foreign key (user_id) references users (id), constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id) ); @@ -133,8 +133,7 @@ create table if not exists canceled_reservation ( status varchar(30) not null, constraint uk_canceled_reservations__reservation_id unique (reservation_id), - constraint fk_canceled_reservations__reservation_id foreign key (reservation_id) references reservation (id), - constraint fk_canceled_reservations__canceled_by foreign key (canceled_by) references members (member_id) + constraint fk_canceled_reservations__reservation_id foreign key (reservation_id) references reservation (id) ); create table if not exists payment ( @@ -208,6 +207,5 @@ create table if not exists canceled_payment( easypay_discount_amount integer not null, constraint uk_canceled_payment__paymentId unique (payment_id), - constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id), - constraint fk_canceled_payment__canceledBy foreign key (canceled_by) references members(member_id) + constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id) ); diff --git a/src/test/kotlin/roomescape/util/DummyInitializer.kt b/src/test/kotlin/roomescape/util/DummyInitializer.kt index cef0eab6..f9d3368c 100644 --- a/src/test/kotlin/roomescape/util/DummyInitializer.kt +++ b/src/test/kotlin/roomescape/util/DummyInitializer.kt @@ -184,7 +184,7 @@ class DummyInitializer( } fun cancelPayment( - memberId: Long, + userId: Long, reservationId: Long, cancelReason: String, ): CanceledPaymentEntity { @@ -197,7 +197,7 @@ class DummyInitializer( ) return paymentWriter.cancel( - memberId, + userId, payment, requestedAt = LocalDateTime.now(), clientCancelResponse -- 2.47.2 From 53d82902cad2355929ade2c21dc49756c057dc8f Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:28:59 +0900 Subject: [PATCH 42/73] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=97=90=20=EC=8B=A0=EA=B7=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 40 +++++++++---------- .../reservation/docs/ReservationAPI.kt | 8 ++-- .../reservation/web/ReservationController.kt | 6 +-- .../reservation/web/ReservationDto.kt | 8 ++-- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index fc67d4cd..f59889e9 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -7,9 +7,10 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next -import roomescape.member.business.MemberService -import roomescape.member.infrastructure.persistence.Role -import roomescape.member.web.MemberSummaryRetrieveResponse +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.PrincipalType +import roomescape.member.business.UserService +import roomescape.member.web.UserContactRetrieveResponse import roomescape.payment.business.PaymentService import roomescape.payment.web.PaymentRetrieveResponse import roomescape.reservation.exception.ReservationErrorCode @@ -31,7 +32,7 @@ class ReservationService( private val reservationRepository: ReservationRepository, private val reservationValidator: ReservationValidator, private val scheduleService: ScheduleService, - private val memberService: MemberService, + private val userService: UserService, private val themeService: ThemeService, private val canceledReservationRepository: CanceledReservationRepository, private val tsidFactory: TsidFactory, @@ -40,14 +41,14 @@ class ReservationService( @Transactional fun createPendingReservation( - memberId: Long, + user: CurrentUserContext, request: PendingReservationCreateRequest ): PendingReservationCreateResponse { log.info { "[ReservationService.createPendingReservation] Pending 예약 생성 시작: schedule=${request.scheduleId}" } validateCanCreate(request) - val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), memberId = memberId) + val reservation: ReservationEntity = request.toEntity(id = tsidFactory.next(), userId = user.id) return PendingReservationCreateResponse(reservationRepository.save(reservation).id) .also { "[ReservationService.createPendingReservation] Pending 예약 생성 완료: reservationId=${it}, schedule=${request.scheduleId}" } @@ -70,18 +71,17 @@ class ReservationService( } @Transactional - fun cancelReservation(memberId: Long, reservationId: Long, request: ReservationCancelRequest) { - log.info { "[ReservationService.cancelReservation] 예약 취소 시작: memberId=${memberId}, reservationId=${reservationId}" } + fun cancelReservation(user: CurrentUserContext, reservationId: Long, request: ReservationCancelRequest) { + log.info { "[ReservationService.cancelReservation] 예약 취소 시작: userId=${user.id}, reservationId=${reservationId}" } val reservation: ReservationEntity = findOrThrow(reservationId) - val member: MemberSummaryRetrieveResponse = memberService.findSummaryById(memberId) run { scheduleService.updateSchedule( reservation.scheduleId, ScheduleUpdateRequest(status = ScheduleStatus.AVAILABLE) ) - saveCanceledReservation(member, reservation, request.cancelReason) + saveCanceledReservation(user, reservation, request.cancelReason) reservation.cancel() }.also { log.info { "[ReservationService.cancelReservation] 예약 취소 완료: reservationId=${reservationId}" } @@ -89,10 +89,10 @@ class ReservationService( } @Transactional(readOnly = true) - fun findSummaryByMemberId(memberId: Long): ReservationSummaryRetrieveListResponse { - log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: memberId=${memberId}" } + fun findUserSummaryReservation(user: CurrentUserContext): ReservationSummaryRetrieveListResponse { + log.info { "[ReservationService.findSummaryByMemberId] 예약 조회 시작: userId=${user.id}" } - val reservations: List = reservationRepository.findAllByMemberId(memberId) + val reservations: List = reservationRepository.findAllByUserId(user.id) return ReservationSummaryRetrieveListResponse(reservations.map { val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId) @@ -106,7 +106,7 @@ class ReservationService( status = it.status ) }).also { - log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=${memberId}" } + log.info { "[ReservationService.findSummaryByMemberId] ${it.reservations.size}개의 예약 조회 완료: userId=${user.id}" } } } @@ -115,11 +115,11 @@ class ReservationService( log.info { "[ReservationService.findDetailById] 예약 상세 조회 시작: reservationId=${id}" } val reservation: ReservationEntity = findOrThrow(id) - val member: MemberSummaryRetrieveResponse = memberService.findSummaryById(reservation.memberId) + val user: UserContactRetrieveResponse = userService.findContactById(reservation.userId) val paymentDetail: PaymentRetrieveResponse = paymentService.findDetailByReservationId(id) return reservation.toReservationDetailRetrieveResponse( - member = member, + user = user, payment = paymentDetail ).also { log.info { "[ReservationService.findDetailById] 예약 상세 조회 완료: reservationId=${id}" } @@ -138,19 +138,19 @@ class ReservationService( } private fun saveCanceledReservation( - member: MemberSummaryRetrieveResponse, + user: CurrentUserContext, reservation: ReservationEntity, cancelReason: String ) { - if (member.role != Role.ADMIN && reservation.memberId != member.id) { - log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, memberId=${member.id}" } + if (user.type != PrincipalType.ADMIN && reservation.userId != user.id) { + log.warn { "[ReservationService.createCanceledPayment] 예약자 본인 또는 관리자가 아닌 회원의 취소 요청: reservationId=${reservation.id}, userId=${user.id}" } throw ReservationException(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION) } CanceledReservationEntity( id = tsidFactory.next(), reservationId = reservation.id, - canceledBy = member.id, + canceledBy = user.id, cancelReason = cancelReason, canceledAt = LocalDateTime.now(), status = CanceledReservationStatus.PROCESSING diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index 2d25ff0a..262f7329 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -1,16 +1,14 @@ package roomescape.reservation.docs import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.support.Authenticated import roomescape.auth.web.support.CurrentUser -import roomescape.auth.web.support.LoginRequired -import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -18,6 +16,7 @@ import roomescape.reservation.web.* interface ReservationAPI { + @UserOnly @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createPendingReservation( @@ -32,7 +31,7 @@ interface ReservationAPI { @PathVariable("id") id: Long ): ResponseEntity> - @UserOnly + @Authenticated @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelReservation( @@ -41,6 +40,7 @@ interface ReservationAPI { @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> + @UserOnly @Operation(summary = "회원별 예약 요약 목록 조회", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findSummaryByMemberId( diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index dedf02f5..ae0652cb 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -19,7 +19,7 @@ class ReservationController( @CurrentUser user: CurrentUserContext, @Valid @RequestBody request: PendingReservationCreateRequest ): ResponseEntity> { - val response = reservationService.createPendingReservation(user.id, request) + val response = reservationService.createPendingReservation(user, request) return ResponseEntity.ok(CommonApiResponse(response)) } @@ -39,7 +39,7 @@ class ReservationController( @PathVariable reservationId: Long, @Valid @RequestBody request: ReservationCancelRequest ): ResponseEntity> { - reservationService.cancelReservation(user.id, reservationId, request) + reservationService.cancelReservation(user, reservationId, request) return ResponseEntity.ok().body(CommonApiResponse()) } @@ -48,7 +48,7 @@ class ReservationController( override fun findSummaryByMemberId( @CurrentUser user: CurrentUserContext, ): ResponseEntity> { - val response = reservationService.findSummaryByMemberId(user.id) + val response = reservationService.findUserSummaryReservation(user) return ResponseEntity.ok(CommonApiResponse(response)) } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt index 5e5050cb..beeadf1f 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt @@ -1,7 +1,7 @@ package roomescape.reservation.web import jakarta.validation.constraints.NotEmpty -import roomescape.member.web.MemberSummaryRetrieveResponse +import roomescape.member.web.UserContactRetrieveResponse import roomescape.payment.web.PaymentRetrieveResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus @@ -48,18 +48,18 @@ data class ReservationSummaryRetrieveListResponse( data class ReservationDetailRetrieveResponse( val id: Long, - val member: MemberSummaryRetrieveResponse, + val user: UserContactRetrieveResponse, val applicationDateTime: LocalDateTime, val payment: PaymentRetrieveResponse, ) fun ReservationEntity.toReservationDetailRetrieveResponse( - member: MemberSummaryRetrieveResponse, + user: UserContactRetrieveResponse, payment: PaymentRetrieveResponse, ): ReservationDetailRetrieveResponse { return ReservationDetailRetrieveResponse( id = this.id, - member = member, + user = user, applicationDateTime = this.createdAt, payment = payment, ) -- 2.47.2 From 611508b358fa689bb372e071826ac6c3a8d491ba Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:29:08 +0900 Subject: [PATCH 43/73] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=97=90=20=EC=8B=A0=EA=B7=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt index 5afb5d7a..075ce787 100644 --- a/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt +++ b/src/main/kotlin/roomescape/payment/docs/PaymentAPI.kt @@ -1,17 +1,13 @@ package roomescape.payment.docs import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam -import roomescape.auth.web.support.Authenticated import roomescape.auth.web.support.CurrentUser -import roomescape.auth.web.support.LoginRequired -import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -29,6 +25,7 @@ interface PaymentAPI { @Valid @RequestBody request: PaymentConfirmRequest ): ResponseEntity> + @UserOnly @Operation(summary = "결제 취소", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun cancelPayment( -- 2.47.2 From 671243b9b1d4333c692f79f0ed4cdf7d9909b628 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:54:33 +0900 Subject: [PATCH 44/73] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/infrastructure/persistence/UserEntities.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt index 407a83a1..bb4abd98 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserEntities.kt @@ -22,12 +22,7 @@ class UserEntity( @Enumerated(value = EnumType.STRING) var status: UserStatus -): AuditingBaseEntity(id) { - - fun updateStatus(status: UserStatus) { - this.status = status - } -} +): AuditingBaseEntity(id) @Entity @Table(name = "user_status_history") -- 2.47.2 From c8324101605d5f637be4907059536bad6f59448b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:55:17 +0900 Subject: [PATCH 45/73] =?UTF-8?q?refactor:=20Kotest=20=EB=B3=91=EB=A0=AC?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=20+=20Region=20Sql=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B4=88=EA=B8=B0=ED=99=94=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20FK=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=ED=95=B4=EC=86=8C=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20regionCode=20=EC=9E=84=EC=8B=9C=20Null=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/schema-h2.sql | 2 +- src/test/kotlin/roomescape/util/Fixtures.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 4551306b..874e840b 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -24,7 +24,7 @@ create table if not exists users( email varchar(255) not null, password varchar(255) not null, phone varchar(20) not null, - region_code varchar(10) not null, + region_code varchar(10) null, status varchar(20) not null, created_at timestamp not null, created_by bigint not null, diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index 4abd39f9..d715b5b9 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -72,7 +72,7 @@ object UserFixture { email: String = "sample@example.com", password: String = "a".repeat(MIN_PASSWORD_LENGTH), phone: String = "01012345678", - regionCode: String = "1130510300", + regionCode: String? = null, status: UserStatus = UserStatus.ACTIVE ): UserEntity = UserEntity( id = id, @@ -89,7 +89,7 @@ object UserFixture { email = "sample@example.com", password = "a".repeat(MIN_PASSWORD_LENGTH), phone = "01012345678", - regionCode = "1130510300" + regionCode = null ) } -- 2.47.2 From 45039b8e7c7cb7bead6ae01e473b20bb004e91b0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:55:31 +0900 Subject: [PATCH 46/73] =?UTF-8?q?test:=20=EB=A1=9C=EC=A7=81=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/auth/JwtUtilsTest.kt | 8 ++------ .../common/log/ApiLogMessageConverterTest.kt | 10 +++++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt index f92c5c56..655b4667 100644 --- a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt +++ b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt @@ -52,12 +52,8 @@ class JwtUtilsTest : FunSpec() { } } - test("claim에 입력된 key의 정보가 없으면 실패한다.") { - shouldThrow { - jwtUtils.extractClaim(token = commonToken, key = "abcde") - }.also { - it.errorCode shouldBe AuthErrorCode.INVALID_TOKEN - } + test("claim에 입력된 key의 정보가 없으면 null을 반환한다.") { + jwtUtils.extractClaim(token = commonToken, key = "abcde") shouldBe null } test("토큰이 만료되면 실패한다.") { diff --git a/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt b/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt index 451c1598..1e0e4afb 100644 --- a/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt +++ b/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt @@ -15,12 +15,12 @@ class ApiLogMessageConverterTest : StringSpec({ val request: HttpServletRequest = mockk() beforeTest { - MDC.remove("member_id") - MDC.put("member_id", "1") + MDC.remove("principal_id") + MDC.put("principal_id", "1") } afterSpec { - MDC.remove("member_id") + MDC.remove("principal_id") } "HTTP 요청 메시지를 변환한다." { @@ -44,7 +44,7 @@ class ApiLogMessageConverterTest : StringSpec({ val requestURI = "/test/sangdol".also { every { request.requestURI } returns it } converter.convertToControllerInvokedMessage(request, controllerPayload) shouldBe """ - {"type":"CONTROLLER_INVOKED","method":"$method","uri":"$requestURI","member_id":1,"controller_method":"${ + {"type":"CONTROLLER_INVOKED","method":"$method","uri":"$requestURI","principal_id":1,"controller_method":"${ controllerPayload.get( "controller_method" ) @@ -62,7 +62,7 @@ class ApiLogMessageConverterTest : StringSpec({ ) converter.convertToResponseMessage(request) shouldBe """ - {"type":"CONTROLLER_SUCCESS","endpoint":"$endpoint","status_code":200,"member_id":1,"exception":{"class":"AuthException","message":"테스트 메시지!"}} + {"type":"CONTROLLER_SUCCESS","endpoint":"$endpoint","status_code":200,"principal_id":1,"exception":{"class":"AuthException","message":"테스트 메시지!"}} """.trimIndent() } }) -- 2.47.2 From 97a84f1c6164973e83d38251a46b9dded6cba92e Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:56:09 +0900 Subject: [PATCH 47/73] =?UTF-8?q?test:=20=EC=98=88=EC=95=BD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=90=9C=20=ED=9A=8C=EC=9B=90=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B6=8C=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ReservationApiTest.kt | 355 ++++++++++-------- .../roomescape/util/RestAssuredUtils.kt | 3 + 2 files changed, 207 insertions(+), 151 deletions(-) diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 87ac97b6..1e7e43ea 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -3,9 +3,10 @@ package roomescape.reservation import io.kotest.matchers.shouldBe import org.hamcrest.CoreMatchers.equalTo import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import roomescape.auth.exception.AuthErrorCode import roomescape.common.exception.CommonErrorCode -import roomescape.member.infrastructure.persistence.Role import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.infrastructure.common.BankCode import roomescape.payment.infrastructure.common.CardIssuerCode @@ -35,21 +36,42 @@ class ReservationApiTest( init { context("결제 전 임시 예약을 생성한다.") { val commonRequest = ReservationFixture.pendingCreateRequest + val endpoint = "/reservations/pending" + + context("권한이 없으면 접근할 수 없다.") { + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 생성") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.loginAsAdmin(), + adminToken = authUtil.defaultAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.HOLD ) runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, on = { - post("/reservations/pending") + post(endpoint) }, expect = { statusCode(HttpStatus.OK.value()) @@ -67,18 +89,18 @@ class ReservationApiTest( test("예약을 생성할 때 해당 일정이 ${ScheduleStatus.HOLD} 상태가 아니면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( - adminToken = authUtil.loginAsAdmin(), + adminToken = authUtil.defaultAdminLogin(), request = ScheduleFixture.createRequest, status = ScheduleStatus.AVAILABLE ) runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), using = { body(commonRequest.copy(scheduleId = schedule.id)) }, on = { - post("/reservations/pending") + post(endpoint) }, expect = { statusCode(HttpStatus.BAD_REQUEST.value()) @@ -88,7 +110,7 @@ class ReservationApiTest( } test("예약 인원이 테마의 최소 인원보다 작거나 최대 인원보다 많으면 실패한다.") { - val adminToken = authUtil.loginAsAdmin() + val adminToken = authUtil.defaultAdminLogin() val theme: ThemeEntity = dummyInitializer.createTheme( adminToken = adminToken, request = ThemeFixture.createRequest @@ -100,86 +122,81 @@ class ReservationApiTest( status = ScheduleStatus.HOLD ) - runTest( - token = authUtil.loginAsUser(), - using = { - body( - commonRequest.copy( - scheduleId = schedule.id, - participantCount = ((theme.minParticipants - 1).toShort()) - ) - ) - }, - on = { - post("/reservations/pending") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ReservationErrorCode.INVALID_PARTICIPANT_COUNT.errorCode)) - } + val userToken = authUtil.defaultUserLogin() + + runExceptionTest( + token = userToken, + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy( + schedule.id, + participantCount = ((theme.minParticipants - 1).toShort()) + ), + expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT ) - runTest( - token = authUtil.loginAsUser(), - using = { - body( - commonRequest.copy( - scheduleId = schedule.id, - participantCount = ((theme.maxParticipants + 1).toShort()) - ) - ) - }, - on = { - post("/reservations/pending") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ReservationErrorCode.INVALID_PARTICIPANT_COUNT.errorCode)) - } + runExceptionTest( + token = userToken, + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy( + scheduleId = schedule.id, + participantCount = ((theme.maxParticipants + 1).toShort()) + ), + expectedErrorCode = ReservationErrorCode.INVALID_PARTICIPANT_COUNT ) } context("필수 입력값이 입력되지 않으면 실패한다.") { test("예약자명") { - runTest( - token = authUtil.loginAsUser(), - using = { - body(commonRequest.copy(reserverName = "")) - }, - on = { - post("/reservations/pending") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(reserverName = ""), + expectedErrorCode = CommonErrorCode.INVALID_INPUT_VALUE ) } test("예약자 연락처") { - runTest( - token = authUtil.loginAsUser(), - using = { - body(commonRequest.copy(reserverContact = "")) - }, - on = { - post("/reservations/pending") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = commonRequest.copy(reserverContact = ""), + expectedErrorCode = CommonErrorCode.INVALID_INPUT_VALUE ) } } } context("예약을 확정한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/reservations/$INVALID_PK/confirm" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 응답") { - val userToken = authUtil.loginAsUser() + val userToken = authUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createPendingReservation( - adminToken = authUtil.loginAsAdmin(), + adminToken = authUtil.defaultAdminLogin(), reserverToken = userToken, ) @@ -204,25 +221,42 @@ class ReservationApiTest( } test("예약이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsUser(), - on = { - post("/reservations/$INVALID_PK/confirm") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + endpoint = "/reservations/$INVALID_PK/confirm", + expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND ) } } context("예약을 취소한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/reservations/$INVALID_PK/confirm" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 응답") { - val userToken = authUtil.loginAsUser() + val userToken = authUtil.defaultUserLogin() val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), + adminToken = authUtil.defaultAdminLogin(), reserverToken = userToken, ) @@ -250,54 +284,42 @@ class ReservationApiTest( } test("예약이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsUser(), - using = { - body(ReservationCancelRequest(cancelReason = "test")) - }, - on = { - post("/reservations/$INVALID_PK/cancel") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.POST, + endpoint = "/reservations/$INVALID_PK/cancel", + requestBody = ReservationCancelRequest(cancelReason = "test"), + expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND ) } test("관리자가 아닌 회원은 다른 회원의 예약을 취소할 수 없다.") { val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser(), + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin(), ) - val otherUserToken = authUtil.login("other@example.com", "other", role = Role.MEMBER) + val otherUserToken = authUtil.userLogin(UserFixture.createUser(email = "test@test.com", phone="01011111111")) - runTest( + runExceptionTest( token = otherUserToken, - using = { - body(ReservationCancelRequest(cancelReason = "test")) - }, - on = { - post("/reservations/${reservation.id}/cancel") - }, - expect = { - statusCode(HttpStatus.FORBIDDEN.value()) - body("code", equalTo(ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION.errorCode)) - } + method = HttpMethod.POST, + endpoint = "/reservations/${reservation.id}/cancel", + requestBody = ReservationCancelRequest(cancelReason = "test"), + expectedErrorCode = ReservationErrorCode.NO_PERMISSION_TO_CANCEL_RESERVATION ) } test("관리자는 다른 회원의 예약을 취소할 수 있다.") { + val adminToken = authUtil.defaultAdminLogin() + val reservation: ReservationEntity = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsAdmin(), + adminToken = adminToken, + reserverToken = authUtil.defaultUserLogin(), ) - val otherAdminToken = authUtil.login("admin1@example.com", "admin1", role = Role.ADMIN) - runTest( - token = otherAdminToken, + token = adminToken, using = { body(ReservationCancelRequest(cancelReason = "test")) }, @@ -321,9 +343,30 @@ class ReservationApiTest( } context("나의 예약 목록을 조회한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/reservations/$INVALID_PK/confirm" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 응답") { - val userToken = authUtil.loginAsUser() - val adminToken = authUtil.loginAsAdmin() + val userToken = authUtil.defaultUserLogin() + val adminToken = authUtil.defaultAdminLogin() for (i in 1..3) { dummyInitializer.createConfirmReservation( @@ -355,6 +398,27 @@ class ReservationApiTest( } context("예약 상세 정보를 조회한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/reservations/$INVALID_PK/confirm" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + context("정상 응답") { val commonPaymentRequest = PaymentFixture.confirmRequest @@ -362,8 +426,8 @@ class ReservationApiTest( beforeTest { reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser(), + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin(), ) } @@ -428,10 +492,11 @@ class ReservationApiTest( ) val cancelReason = "테스트입니다." - val memberId = authUtil.getUser().id!! + + val user = authUtil.defaultUser() dummyInitializer.cancelPayment( - memberId = memberId, + userId = user.id, reservationId = reservation.id, cancelReason = cancelReason, ) @@ -448,7 +513,7 @@ class ReservationApiTest( with((it.get("cancel") as LinkedHashMap<*, *>)) { this["cancelReason"] shouldBe cancelReason - this["canceledBy"] shouldBe memberId + this["canceledBy"] shouldBe user.id } } } @@ -494,40 +559,32 @@ class ReservationApiTest( } test("예약이 없으면 실패한다.") { - runTest( - token = authUtil.loginAsUser(), - on = { - get("/reservations/$INVALID_PK/detail") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(ReservationErrorCode.RESERVATION_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = "/reservations/$INVALID_PK/detail", + expectedErrorCode = ReservationErrorCode.RESERVATION_NOT_FOUND ) } test("예약은 있지만, 결제 정보가 없으면 실패한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser(), + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin(), ) - runTest( - token = authUtil.loginAsUser(), - on = { - get("/reservations/${reservation.id}/detail") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(PaymentErrorCode.PAYMENT_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = "/reservations/${reservation.id}/detail", + expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND ) } test("예약과 결제는 있지만, 결제 세부 내역이 없으면 실패한다.") { val reservation = dummyInitializer.createConfirmReservation( - adminToken = authUtil.loginAsAdmin(), - reserverToken = authUtil.loginAsUser(), + adminToken = authUtil.defaultAdminLogin(), + reserverToken = authUtil.defaultUserLogin(), ) dummyInitializer.createPayment( @@ -537,15 +594,11 @@ class ReservationApiTest( paymentDetailRepository.deleteAll() } - runTest( - token = authUtil.loginAsUser(), - on = { - get("/reservations/${reservation.id}/detail") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = authUtil.defaultUserLogin(), + method = HttpMethod.GET, + endpoint = "/reservations/${reservation.id}/detail", + expectedErrorCode = PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND ) } } @@ -555,17 +608,17 @@ class ReservationApiTest( reservation: ReservationEntity ): LinkedHashMap { return runTest( - token = authUtil.loginAsUser(), + token = authUtil.defaultUserLogin(), on = { get("/reservations/${reservation.id}/detail") }, expect = { statusCode(HttpStatus.OK.value()) - assertProperties(props = setOf("id", "member", "applicationDateTime", "payment")) + assertProperties(props = setOf("id", "user", "applicationDateTime", "payment")) } ).also { it.extract().path("data.id") shouldBe reservation.id - it.extract().path("data.member.id") shouldBe reservation.memberId + it.extract().path("data.user.id") shouldBe reservation.userId }.extract().path("data.payment") } } diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index bfe87932..c7cf5cf7 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -123,6 +123,9 @@ class AuthUtil( } fun defaultUserLogin(): String = userLogin(UserFixture.default) + + fun defaultUser(): UserEntity = userRepository.findByEmail(UserFixture.default.email) + ?: throw AssertionError("Unexpected Exception Occurred.") } fun runTest( -- 2.47.2 From 32837797209c5a8bbdaf39703bf763efdd25f47b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 12:56:16 +0900 Subject: [PATCH 48/73] =?UTF-8?q?test:=20=EA=B2=B0=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=90=9C=20=ED=9A=8C=EC=9B=90=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B6=8C=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/payment/PaymentAPITest.kt | 104 ++++++++++++------ 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index 69017655..d22c1e61 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -3,9 +3,10 @@ package roomescape.payment import com.ninjasquad.springmockk.MockkBean import io.kotest.matchers.shouldBe import io.mockk.every -import org.hamcrest.CoreMatchers.equalTo import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import roomescape.auth.exception.AuthErrorCode import roomescape.payment.business.PaymentService import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.infrastructure.client.CardDetail @@ -18,7 +19,9 @@ import roomescape.payment.web.PaymentConfirmRequest import roomescape.payment.web.PaymentCreateResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.util.FunSpecSpringbootTest +import roomescape.util.INVALID_PK import roomescape.util.PaymentFixture +import roomescape.util.runExceptionTest import roomescape.util.runTest class PaymentAPITest( @@ -31,6 +34,27 @@ class PaymentAPITest( ) : FunSpecSpringbootTest() { init { context("결제를 승인한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/payments?reservationId=$INVALID_PK" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + val amount = 100_000 context("간편결제 + 카드로 ${amount}원을 결제한다.") { context("일시불") { @@ -162,18 +186,12 @@ class PaymentAPITest( transferDetail = null, ) - runTest( + runExceptionTest( token = authUtil.defaultUserLogin(), - using = { - body(PaymentFixture.confirmRequest) - }, - on = { - post("/payments?reservationId=${reservation.id}") - }, - expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE.errorCode)) - } + method = HttpMethod.POST, + endpoint = "/payments?reservationId=${reservation.id}", + requestBody = PaymentFixture.confirmRequest, + expectedErrorCode = PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE ) } } @@ -181,12 +199,35 @@ class PaymentAPITest( } context("결제를 취소한다.") { + context("권한이 없으면 접근할 수 없다.") { + val endpoint = "/payments/cancel" + + test("비회원") { + runExceptionTest( + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = PaymentFixture.cancelRequest, + expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND + ) + } + + test("관리자") { + runExceptionTest( + token = authUtil.defaultAdminLogin(), + method = HttpMethod.POST, + endpoint = endpoint, + requestBody = PaymentFixture.cancelRequest, + expectedErrorCode = AuthErrorCode.ACCESS_DENIED + ) + } + } + test("정상 취소") { - val token = authUtil.defaultAdminLogin() + val userToken = authUtil.defaultUserLogin() val confirmRequest = PaymentFixture.confirmRequest val reservation = dummyInitializer.createConfirmReservation( - adminToken = token, - reserverToken = token + adminToken = authUtil.defaultAdminLogin(), + reserverToken = userToken ) val paymentCreateResponse = createPayment( @@ -202,13 +243,12 @@ class PaymentAPITest( ) } returns PaymentFixture.cancelResponse(confirmRequest.amount) + val requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id) + runTest( - token = token, + token = userToken, using = { - val cancelRequest = PaymentFixture.cancelRequest.copy( - reservationId = reservation.id - ) - body(cancelRequest) + body(requestBody) }, on = { post("/payments/cancel") @@ -230,24 +270,18 @@ class PaymentAPITest( } test("예약에 대한 결제 정보가 없으면 실패한다.") { - val token = authUtil.defaultAdminLogin() + val userToken = authUtil.defaultUserLogin() val reservation = dummyInitializer.createConfirmReservation( - adminToken = token, - reserverToken = token, + adminToken = authUtil.defaultAdminLogin(), + reserverToken = userToken, ) - runTest( - token = token, - using = { - body(PaymentFixture.cancelRequest.copy(reservationId = reservation.id)) - }, - on = { - post("/payments/cancel") - }, - expect = { - statusCode(HttpStatus.NOT_FOUND.value()) - body("code", equalTo(PaymentErrorCode.PAYMENT_NOT_FOUND.errorCode)) - } + runExceptionTest( + token = userToken, + method = HttpMethod.POST, + endpoint = "/payments/cancel", + requestBody = PaymentFixture.cancelRequest.copy(reservationId = reservation.id), + expectedErrorCode = PaymentErrorCode.PAYMENT_NOT_FOUND ) } } -- 2.47.2 From 8a7778ba192036d47a14e9f7038546395e5fa9d1 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:22:31 +0900 Subject: [PATCH 49/73] =?UTF-8?q?refactor:=20DatabaseCleanerExtension?= =?UTF-8?q?=EC=97=90=EC=84=9C=20region=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...DatabaseCleaner.kt => TestDatabaseUtil.kt} | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) rename src/test/kotlin/roomescape/util/{DatabaseCleaner.kt => TestDatabaseUtil.kt} (72%) diff --git a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt b/src/test/kotlin/roomescape/util/TestDatabaseUtil.kt similarity index 72% rename from src/test/kotlin/roomescape/util/DatabaseCleaner.kt rename to src/test/kotlin/roomescape/util/TestDatabaseUtil.kt index 4fdfe401..0e7aed8d 100644 --- a/src/test/kotlin/roomescape/util/DatabaseCleaner.kt +++ b/src/test/kotlin/roomescape/util/TestDatabaseUtil.kt @@ -2,6 +2,7 @@ package roomescape.util import io.kotest.core.listeners.AfterSpecListener import io.kotest.core.listeners.AfterTestListener +import io.kotest.core.listeners.BeforeSpecListener import io.kotest.core.spec.Spec import io.kotest.core.test.TestCase import io.kotest.core.test.TestResult @@ -11,7 +12,7 @@ import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Component @Component -class DatabaseCleaner( +class TestDatabaseUtil( val entityManager: EntityManager, val jdbcTemplate: JdbcTemplate, ) { @@ -21,6 +22,12 @@ class DatabaseCleaner( } } + fun initializeRegion() { + this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { + jdbcTemplate.execute(it) + } + } + fun clear(mode: CleanerMode) { entityManager.clear() @@ -40,7 +47,13 @@ enum class CleanerMode { ALL } -class DatabaseCleanerExtension: AfterTestListener, AfterSpecListener { +class DatabaseCleanerExtension: BeforeSpecListener, AfterTestListener, AfterSpecListener { + + override suspend fun beforeSpec(spec: Spec) { + super.beforeSpec(spec) + getCleaner().initializeRegion() + } + override suspend fun afterTest(testCase: TestCase, result: TestResult) { super.afterTest(testCase, result) getCleaner().clear(CleanerMode.EXCEPT_REGION) @@ -51,9 +64,9 @@ class DatabaseCleanerExtension: AfterTestListener, AfterSpecListener { getCleaner().clear(CleanerMode.ALL) } - private suspend fun getCleaner(): DatabaseCleaner { + private suspend fun getCleaner(): TestDatabaseUtil { return testContextManager().testContext .applicationContext - .getBean(DatabaseCleaner::class.java) + .getBean(TestDatabaseUtil::class.java) } } -- 2.47.2 From dd4e022d6d9fb512308a2b1dbb39fa4f985ccec0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:23:03 +0900 Subject: [PATCH 50/73] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20region=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20FK=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B0=92=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/util/Fixtures.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index d715b5b9..cd99607d 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -72,7 +72,7 @@ object UserFixture { email: String = "sample@example.com", password: String = "a".repeat(MIN_PASSWORD_LENGTH), phone: String = "01012345678", - regionCode: String? = null, + regionCode: String = "1111010100", status: UserStatus = UserStatus.ACTIVE ): UserEntity = UserEntity( id = id, @@ -89,7 +89,7 @@ object UserFixture { email = "sample@example.com", password = "a".repeat(MIN_PASSWORD_LENGTH), phone = "01012345678", - regionCode = null + regionCode = "1111010100" ) } -- 2.47.2 From f5192750c33ec478a02e056a9a761a8677c24aff Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:23:15 +0900 Subject: [PATCH 51/73] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20/=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/util/KotestConfig.kt | 2 + src/test/resources/application-test.yaml | 50 +++++++++++++++++-- src/test/resources/logback-test.xml | 20 ++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/logback-test.xml diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/util/KotestConfig.kt index 3e92b362..3c41abab 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/util/KotestConfig.kt @@ -12,6 +12,7 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles import roomescape.admin.infrastructure.persistence.AdminRepository import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.UserRepository @@ -26,6 +27,7 @@ object KotestConfig : AbstractProjectConfig() { } @Import(TestConfig::class) +@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) abstract class FunSpecSpringbootTest : FunSpec({ extension(DatabaseCleanerExtension()) diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 7a658a0e..914bb277 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,5 +1,47 @@ logging: - level: - root: INFO - org.springframework.orm.jpa: INFO - org.springframework.transaction: DEBUG + config: classpath:logback-test.xml + +spring: + jpa: + properties: + hibernate: + format_sql: true + hibernate: + ddl-auto: validate + datasource: + hikari: + jdbc-url: jdbc:h2:mem:test + driver-class-name: org.h2.Driver + username: sa + password: + sql: + init: + mode: always + schema-locations: classpath:schema/schema-h2.sql + +security: + jwt: + token: + secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi + ttl-seconds: 1800 + +payment: + confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + read-timeout: 3 + connect-timeout: 30 + +jdbc: + datasource-proxy: + enabled: true + include-parameter-values: false + query: + enable-logging: true + log-level: DEBUG + logger-name: all-query-logger + multiline: true + includes: connection,query,keys,fetch + +management: + tracing: + sampling: + probability: 1 \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..8564c2dd --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file -- 2.47.2 From 40d687f7f2b90f7990b7e084274387af4ffa65f8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:29:43 +0900 Subject: [PATCH 52/73] =?UTF-8?q?remove:=20=ED=9A=8C=EC=9B=90=20/=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/AdminEntity.kt | 6 +- .../roomescape/auth/business/AuthService.kt | 118 +++++++++++++----- .../roomescape/auth/business/AuthServiceV2.kt | 114 ----------------- .../kotlin/roomescape/auth/docs/AuthAPI.kt | 34 ++--- .../kotlin/roomescape/auth/docs/AuthAPIV2.kt | 52 -------- .../auth/infrastructure/jwt/JwtHandler.kt | 64 ---------- .../roomescape/auth/web/AuthController.kt | 36 +++--- .../roomescape/auth/web/AuthControllerV2.kt | 46 ------- .../kotlin/roomescape/auth/web/AuthDTO.kt | 38 +++--- .../kotlin/roomescape/auth/web/AuthDTOV2.kt | 24 ---- .../auth/web/support/AuthAnnotations.kt | 12 -- .../auth/web/support/AuthInterceptor.kt | 63 ---------- .../auth/web/support/MemberIdResolver.kt | 46 ------- .../interceptors/AuthenticatedInterceptor.kt | 4 +- .../resolver/CurrentUserContextResolver.kt | 4 +- .../roomescape/common/config/WebMvcConfig.kt | 6 - .../roomescape/common/entity/BaseEntity.kt | 37 +++--- .../roomescape/common/entity/BaseEntityV2.kt | 53 -------- .../member/business/MemberService.kt | 46 ------- .../roomescape/member/docs/MemberAPI.kt | 37 ------ .../member/exception/MemberErrorCode.kt | 13 -- .../member/exception/MemberException.kt | 8 -- .../member/implement/MemberFinder.kt | 47 ------- .../member/implement/MemberValidator.kt | 26 ---- .../member/implement/MemberWriter.kt | 35 ------ .../persistence/MemberEntity.kt | 34 ----- .../persistence/MemberRepository.kt | 9 -- .../roomescape/member/web/MemberController.kt | 31 ----- .../kotlin/roomescape/member/web/MemberDTO.kt | 56 --------- .../payment/web/PaymentController.kt | 2 - .../persistence/CanceledReservationEntity.kt | 5 +- .../schedule/business/ScheduleService.kt | 1 - .../roomescape/schedule/docs/ScheduleAPI.kt | 3 - .../roomescape/theme/business/ThemeService.kt | 1 - .../kotlin/roomescape/theme/docs/ThemeApi.kt | 2 - src/main/resources/schema/schema-h2.sql | 10 -- .../roomescape/payment/PaymentAPITest.kt | 6 +- src/test/kotlin/roomescape/util/Fixtures.kt | 20 --- .../kotlin/roomescape/util/KotestConfig.kt | 6 +- .../roomescape/util/RestAssuredUtils.kt | 44 +------ 40 files changed, 174 insertions(+), 1025 deletions(-) delete mode 100644 src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt delete mode 100644 src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt delete mode 100644 src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt delete mode 100644 src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt delete mode 100644 src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt delete mode 100644 src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt delete mode 100644 src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt delete mode 100644 src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt delete mode 100644 src/main/kotlin/roomescape/member/business/MemberService.kt delete mode 100644 src/main/kotlin/roomescape/member/docs/MemberAPI.kt delete mode 100644 src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt delete mode 100644 src/main/kotlin/roomescape/member/exception/MemberException.kt delete mode 100644 src/main/kotlin/roomescape/member/implement/MemberFinder.kt delete mode 100644 src/main/kotlin/roomescape/member/implement/MemberValidator.kt delete mode 100644 src/main/kotlin/roomescape/member/implement/MemberWriter.kt delete mode 100644 src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt delete mode 100644 src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt delete mode 100644 src/main/kotlin/roomescape/member/web/MemberController.kt delete mode 100644 src/main/kotlin/roomescape/member/web/MemberDTO.kt diff --git a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt index 5f98745e..3de6097f 100644 --- a/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt +++ b/src/main/kotlin/roomescape/admin/infrastructure/persistence/AdminEntity.kt @@ -1,10 +1,6 @@ package roomescape.admin.infrastructure.persistence -import jakarta.persistence.Entity -import jakarta.persistence.EntityListeners -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Table +import jakarta.persistence.* import org.springframework.data.jpa.domain.support.AuditingEntityListener import roomescape.common.entity.AuditingBaseEntity diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index e2f23227..c4548244 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -4,55 +4,111 @@ import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import roomescape.admin.business.AdminService import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException -import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.auth.web.LoginCheckResponse -import roomescape.auth.web.LoginRequest -import roomescape.auth.web.LoginResponse -import roomescape.member.implement.MemberFinder -import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.auth.infrastructure.jwt.JwtUtils +import roomescape.auth.web.LoginContext +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.common.dto.CurrentUserContext +import roomescape.common.dto.LoginCredentials +import roomescape.common.dto.PrincipalType +import roomescape.member.business.UserService private val log: KLogger = KotlinLogging.logger {} +const val CLAIM_PERMISSION_KEY = "permission" +const val CLAIM_TYPE_KEY = "principal_type" + @Service class AuthService( - private val memberFinder: MemberFinder, - private val jwtHandler: JwtHandler, + private val adminService: AdminService, + private val userService: UserService, + private val loginHistoryService: LoginHistoryService, + private val jwtUtils: JwtUtils, ) { @Transactional(readOnly = true) - fun login(request: LoginRequest): LoginResponse { - val params = "email=${request.email}, password=${request.password}" - log.debug { "[AuthService.login] 시작: $params" } + fun login( + request: LoginRequestV2, + context: LoginContext + ): LoginSuccessResponse { + log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } - val member: MemberEntity = fetchOrThrow(AuthErrorCode.LOGIN_FAILED) { - memberFinder.findByEmailAndPassword(request.email, request.password) + val (credentials, extraClaims) = getCredentials(request) + + try { + verifyPasswordOrThrow(request, credentials) + + val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) + + loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) + + return LoginSuccessResponse(accessToken).also { + log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } + } + + } catch (e: Exception) { + loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) + + when (e) { + is AuthException -> { + log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" } + throw e + } + + else -> { + log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" } + throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) + } + } } - val accessToken: String = jwtHandler.createToken(member.id!!) - - return LoginResponse(accessToken) - .also { log.info { "[AuthService.login] 완료: email=${request.email}, memberId=${member.id}" } } } @Transactional(readOnly = true) - fun checkLogin(memberId: Long): LoginCheckResponse { - log.debug { "[AuthService.checkLogin] 시작: memberId=$memberId" } + fun findContextById(id: Long, type: PrincipalType): CurrentUserContext { + log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" } - val member: MemberEntity = fetchOrThrow(AuthErrorCode.MEMBER_NOT_FOUND) { memberFinder.findById(memberId) } + return when (type) { + PrincipalType.ADMIN -> { + adminService.findContextById(id) + } - return LoginCheckResponse(member.name, member.role.name) - .also { log.info { "[AuthService.checkLogin] 완료: memberId=$memberId, role=${it.role}" } } - } - - private fun fetchOrThrow(errorCode: AuthErrorCode, block: () -> MemberEntity): MemberEntity { - try { - return block() - } catch (e: Exception) { - throw AuthException(errorCode, e.message ?: errorCode.message) + PrincipalType.USER -> { + userService.findContextById(id) + } + }.also { + log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } } } - fun logout(memberId: Long) { - log.info { "[AuthService.logout] 로그아웃: memberId=$memberId" } + private fun verifyPasswordOrThrow( + request: LoginRequestV2, + credentials: LoginCredentials + ) { + if (credentials.password != request.password) { + log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } + throw AuthException(AuthErrorCode.LOGIN_FAILED) + } + } + + private fun getCredentials(request: LoginRequestV2): Pair> { + val extraClaims: MutableMap = mutableMapOf() + val credentials: LoginCredentials = when (request.principalType) { + PrincipalType.ADMIN -> { + adminService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) + } + } + + PrincipalType.USER -> { + userService.findCredentialsByAccount(request.account).also { + extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) + } + } + } + + return credentials to extraClaims } } diff --git a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt b/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt deleted file mode 100644 index 7fd04dfb..00000000 --- a/src/main/kotlin/roomescape/auth/business/AuthServiceV2.kt +++ /dev/null @@ -1,114 +0,0 @@ -package roomescape.auth.business - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import roomescape.admin.business.AdminService -import roomescape.auth.exception.AuthErrorCode -import roomescape.auth.exception.AuthException -import roomescape.auth.infrastructure.jwt.JwtUtils -import roomescape.auth.web.LoginContext -import roomescape.auth.web.LoginRequestV2 -import roomescape.auth.web.LoginSuccessResponse -import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.LoginCredentials -import roomescape.common.dto.PrincipalType -import roomescape.member.business.UserService - -private val log: KLogger = KotlinLogging.logger {} - -const val CLAIM_PERMISSION_KEY = "permission" -const val CLAIM_TYPE_KEY = "principal_type" - -@Service -class AuthServiceV2( - private val adminService: AdminService, - private val userService: UserService, - private val loginHistoryService: LoginHistoryService, - private val jwtUtils: JwtUtils, -) { - @Transactional(readOnly = true) - fun login( - request: LoginRequestV2, - context: LoginContext - ): LoginSuccessResponse { - log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } - - val (credentials, extraClaims) = getCredentials(request) - - try { - verifyPasswordOrThrow(request, credentials) - - val accessToken = jwtUtils.createToken(subject = credentials.id.toString(), claims = extraClaims) - - loginHistoryService.createSuccessHistory(credentials.id, request.principalType, context) - - return LoginSuccessResponse(accessToken).also { - log.info { "[AuthService.login] 로그인 완료: account=${request.account}, context=${context}" } - } - - } catch (e: Exception) { - loginHistoryService.createFailureHistory(credentials.id, request.principalType, context) - - when (e) { - is AuthException -> { - log.info { "[AuthService.login] 로그인 실패: account = ${request.account}" } - throw e - } - - else -> { - log.warn { "[AuthService.login] 로그인 실패: message=${e.message} account = ${request.account}" } - throw AuthException(AuthErrorCode.TEMPORARY_AUTH_ERROR) - } - } - } - } - - @Transactional(readOnly = true) - fun findContextById(id: Long, type: PrincipalType): CurrentUserContext { - log.info { "[AuthService.checkLogin] 로그인 확인 시작: id=${id}, type=${type}" } - - return when (type) { - PrincipalType.ADMIN -> { - adminService.findContextById(id) - } - - PrincipalType.USER -> { - userService.findContextById(id) - } - }.also { - log.info { "[AuthService.checkLogin] 로그인 확인 완료: id=${id}, type=${type}" } - } - } - - private fun verifyPasswordOrThrow( - request: LoginRequestV2, - credentials: LoginCredentials - ) { - if (credentials.password != request.password) { - log.info { "[AuthService.login] 비밀번호 불일치로 인한 로그인 실패: account = ${request.account}" } - throw AuthException(AuthErrorCode.LOGIN_FAILED) - } - } - - private fun getCredentials(request: LoginRequestV2): Pair> { - val extraClaims: MutableMap = mutableMapOf() - val credentials: LoginCredentials = when (request.principalType) { - PrincipalType.ADMIN -> { - adminService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_PERMISSION_KEY, it.permissionLevel) - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.ADMIN) - } - } - - PrincipalType.USER -> { - userService.findCredentialsByAccount(request.account).also { - extraClaims.put(CLAIM_TYPE_KEY, PrincipalType.USER) - } - } - } - - return credentials to extraClaims - } -} diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt index d9ada882..3eea8f6a 100644 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt @@ -1,46 +1,52 @@ package roomescape.auth.docs import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody -import roomescape.auth.web.LoginCheckResponse -import roomescape.auth.web.LoginRequest -import roomescape.auth.web.LoginResponse -import roomescape.auth.web.support.LoginRequired -import roomescape.auth.web.support.MemberId +import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginSuccessResponse +import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.Public +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") interface AuthAPI { + + @Public @Operation(summary = "로그인") @ApiResponses( ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), ) fun login( - @Valid @RequestBody loginRequest: LoginRequest - ): ResponseEntity> + @Valid @RequestBody loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> @Operation(summary = "로그인 상태 확인") @ApiResponses( ApiResponse( responseCode = "200", - description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.", + description = "입력된 ID / 결과(Boolean)을 반환합니다.", useReturnTypeSchema = true ), ) fun checkLogin( - @MemberId @Parameter(hidden = true) memberId: Long - ): ResponseEntity> + @CurrentUser user: CurrentUserContext + ): ResponseEntity> - @LoginRequired @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @ApiResponses( - ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), + ApiResponse(responseCode = "200"), ) - fun logout(@MemberId memberId: Long): ResponseEntity> + fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt deleted file mode 100644 index 9cacbdd1..00000000 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPIV2.kt +++ /dev/null @@ -1,52 +0,0 @@ -package roomescape.auth.docs - -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import jakarta.validation.Valid -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.RequestBody -import roomescape.auth.web.LoginRequestV2 -import roomescape.auth.web.LoginSuccessResponse -import roomescape.auth.web.support.CurrentUser -import roomescape.auth.web.support.Public -import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.response.CommonApiResponse - -@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다") -interface AuthAPIV2 { - - @Public - @Operation(summary = "로그인") - @ApiResponses( - ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), - ) - fun login( - @Valid @RequestBody loginRequest: LoginRequestV2, - servletRequest: HttpServletRequest - ): ResponseEntity> - - @Operation(summary = "로그인 상태 확인") - @ApiResponses( - ApiResponse( - responseCode = "200", - description = "입력된 ID / 결과(Boolean)을 반환합니다.", - useReturnTypeSchema = true - ), - ) - fun checkLogin( - @CurrentUser user: CurrentUserContext - ): ResponseEntity> - - @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) - @ApiResponses( - ApiResponse(responseCode = "200"), - ) - fun logout( - @CurrentUser user: CurrentUserContext, - servletResponse: HttpServletResponse - ): ResponseEntity> -} diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt deleted file mode 100644 index 42ef933c..00000000 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt +++ /dev/null @@ -1,64 +0,0 @@ -package roomescape.auth.infrastructure.jwt - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import io.jsonwebtoken.ExpiredJwtException -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.security.Keys -import org.springframework.beans.factory.annotation.Value -import org.springframework.stereotype.Component -import roomescape.auth.exception.AuthErrorCode -import roomescape.auth.exception.AuthException -import java.util.* -import javax.crypto.SecretKey - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class JwtHandler( - @Value("\${security.jwt.token.secret-key}") - private val secretKeyString: String, - - @Value("\${security.jwt.token.ttl-seconds}") - private val tokenTtlSeconds: Long -) { - private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) - - fun createToken(memberId: Long): String { - log.debug { "[JwtHandler.createToken] 시작: memberId=$memberId" } - val date = Date() - val accessTokenExpiredAt = Date(date.time + (tokenTtlSeconds * 1_000)) - - return Jwts.builder() - .claim(MEMBER_ID_CLAIM_KEY, memberId) - .issuedAt(date) - .expiration(accessTokenExpiredAt) - .signWith(secretKey) - .compact() - .also { log.debug { "[JwtHandler.createToken] 완료. memberId=$memberId, token=$it" } } - } - - fun getMemberIdFromToken(token: String?): Long { - try { - log.debug { "[JwtHandler.getMemberIdFromToken] 시작: token=$token" } - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .payload - .get(MEMBER_ID_CLAIM_KEY, Number::class.java) - .toLong() - .also { log.debug { "[JwtHandler.getMemberIdFromToken] 완료. memberId=$it, token=$token" } } - } catch (_: IllegalArgumentException) { - throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) - } catch (_: ExpiredJwtException) { - throw AuthException(AuthErrorCode.EXPIRED_TOKEN) - } catch (_: Exception) { - throw AuthException(AuthErrorCode.INVALID_TOKEN) - } - } - - companion object { - private const val MEMBER_ID_CLAIM_KEY = "memberId" - } -} diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index 5b9184f7..7c6abc44 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -1,44 +1,46 @@ package roomescape.auth.web -import io.swagger.v3.oas.annotations.Parameter -import jakarta.validation.Valid +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import roomescape.auth.business.AuthService import roomescape.auth.docs.AuthAPI -import roomescape.auth.web.support.MemberId +import roomescape.auth.web.support.CurrentUser +import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @RestController +@RequestMapping("/auth") class AuthController( - private val authService: AuthService + private val authService: AuthService, ) : AuthAPI { @PostMapping("/login") override fun login( - @Valid @RequestBody loginRequest: LoginRequest, - ): ResponseEntity> { - val response: LoginResponse = authService.login(loginRequest) + loginRequest: LoginRequestV2, + servletRequest: HttpServletRequest + ): ResponseEntity> { + val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext()) return ResponseEntity.ok(CommonApiResponse(response)) } @GetMapping("/login/check") override fun checkLogin( - @MemberId @Parameter(hidden = true) memberId: Long - ): ResponseEntity> { - val response: LoginCheckResponse = authService.checkLogin(memberId) - - return ResponseEntity.ok(CommonApiResponse(response)) + @CurrentUser user: CurrentUserContext, + ): ResponseEntity> { + return ResponseEntity.ok(CommonApiResponse(user)) } @PostMapping("/logout") - override fun logout(@MemberId memberId: Long): ResponseEntity> { - authService.logout(memberId) - - return ResponseEntity.noContent().build() + override fun logout( + @CurrentUser user: CurrentUserContext, + servletResponse: HttpServletResponse + ): ResponseEntity> { + return ResponseEntity.ok().build() } } diff --git a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt b/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt deleted file mode 100644 index d0085688..00000000 --- a/src/main/kotlin/roomescape/auth/web/AuthControllerV2.kt +++ /dev/null @@ -1,46 +0,0 @@ -package roomescape.auth.web - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import roomescape.auth.business.AuthServiceV2 -import roomescape.auth.docs.AuthAPIV2 -import roomescape.auth.web.support.CurrentUser -import roomescape.common.dto.CurrentUserContext -import roomescape.common.dto.response.CommonApiResponse - -@RestController -@RequestMapping("/auth") -class AuthControllerV2( - private val authService: AuthServiceV2, -) : AuthAPIV2 { - - @PostMapping("/login") - override fun login( - loginRequest: LoginRequestV2, - servletRequest: HttpServletRequest - ): ResponseEntity> { - val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext()) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - - @GetMapping("/login/check") - override fun checkLogin( - @CurrentUser user: CurrentUserContext, - ): ResponseEntity> { - return ResponseEntity.ok(CommonApiResponse(user)) - } - - @PostMapping("/logout") - override fun logout( - @CurrentUser user: CurrentUserContext, - servletResponse: HttpServletResponse - ): ResponseEntity> { - return ResponseEntity.ok().build() - } -} diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt index f413c439..4bf06274 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt @@ -1,24 +1,24 @@ package roomescape.auth.web -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.Email -import jakarta.validation.constraints.NotBlank +import jakarta.servlet.http.HttpServletRequest +import roomescape.common.dto.PrincipalType -data class LoginResponse( +data class LoginContext( + val ipAddress: String, + val userAgent: String, +) + +fun HttpServletRequest.toLoginContext() = LoginContext( + ipAddress = this.remoteAddr, + userAgent = this.getHeader("User-Agent") +) + +data class LoginRequestV2( + val account: String, + val password: String, + val principalType: PrincipalType +) + +data class LoginSuccessResponse( val accessToken: String ) - -data class LoginCheckResponse( - @Schema(description = "로그인된 회원의 이름") - val name: String, - @Schema(description = "회원(MEMBER) / 관리자(ADMIN)") - val role: String, -) - -data class LoginRequest( - @Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com") - val email: String, - - @NotBlank(message = "비밀번호는 공백일 수 없습니다.") - val password: String -) diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt b/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt deleted file mode 100644 index 4bf06274..00000000 --- a/src/main/kotlin/roomescape/auth/web/AuthDTOV2.kt +++ /dev/null @@ -1,24 +0,0 @@ -package roomescape.auth.web - -import jakarta.servlet.http.HttpServletRequest -import roomescape.common.dto.PrincipalType - -data class LoginContext( - val ipAddress: String, - val userAgent: String, -) - -fun HttpServletRequest.toLoginContext() = LoginContext( - ipAddress = this.remoteAddr, - userAgent = this.getHeader("User-Agent") -) - -data class LoginRequestV2( - val account: String, - val password: String, - val principalType: PrincipalType -) - -data class LoginSuccessResponse( - val accessToken: String -) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt index 5cc61d1e..7d18db7a 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthAnnotations.kt @@ -2,18 +2,6 @@ package roomescape.auth.web.support import roomescape.admin.infrastructure.persistence.Privilege -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class Admin - -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class LoginRequired - -@Target(AnnotationTarget.VALUE_PARAMETER) -@Retention(AnnotationRetention.RUNTIME) -annotation class MemberId - @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class AdminOnly( diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt deleted file mode 100644 index 6901c384..00000000 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt +++ /dev/null @@ -1,63 +0,0 @@ -package roomescape.auth.web.support - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.slf4j.MDC -import org.springframework.stereotype.Component -import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.HandlerInterceptor -import roomescape.auth.exception.AuthErrorCode -import roomescape.auth.exception.AuthException -import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.member.implement.MemberFinder -import roomescape.member.infrastructure.persistence.MemberEntity - -private val log: KLogger = KotlinLogging.logger {} - -const val MDC_MEMBER_ID_KEY: String = "member_id" - -@Component -class AuthInterceptor( - private val memberFinder: MemberFinder, - private val jwtHandler: JwtHandler -) : HandlerInterceptor { - override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { - if (handler !is HandlerMethod) { - return true - } - - val loginRequired = handler.getMethodAnnotation(LoginRequired::class.java) - val admin = handler.getMethodAnnotation(Admin::class.java) - - if (loginRequired == null && admin == null) { - return true - } - - val accessToken: String? = request.accessToken() - log.info { "[AuthInterceptor] 인증 시작. accessToken=${accessToken}" } - val member: MemberEntity = findMember(accessToken) - - if (admin != null && !member.isAdmin()) { - log.info { "[AuthInterceptor] 관리자 인증 실패. memberId=${member.id}, role=${member.role}" } - throw AuthException(AuthErrorCode.ACCESS_DENIED) - } - - MDC.put(MDC_MEMBER_ID_KEY, "${member.id}") - log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" } - return true - } - - private fun findMember(accessToken: String?): MemberEntity { - try { - val memberId = jwtHandler.getMemberIdFromToken(accessToken) - return memberFinder.findById(memberId) - .also { MDC.put(MDC_MEMBER_ID_KEY, "$memberId") } - } catch (e: Exception) { - log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = $accessToken" } - val errorCode = AuthErrorCode.MEMBER_NOT_FOUND - throw AuthException(errorCode, e.message ?: errorCode.message) - } - } -} diff --git a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt deleted file mode 100644 index 2731f5d9..00000000 --- a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt +++ /dev/null @@ -1,46 +0,0 @@ -package roomescape.auth.web.support - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import jakarta.servlet.http.HttpServletRequest -import org.slf4j.MDC -import org.springframework.core.MethodParameter -import org.springframework.stereotype.Component -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer -import roomescape.auth.exception.AuthErrorCode -import roomescape.auth.exception.AuthException -import roomescape.auth.infrastructure.jwt.JwtHandler - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class MemberIdResolver( - private val jwtHandler: JwtHandler -) : HandlerMethodArgumentResolver { - - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(MemberId::class.java) - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? - ): Any { - val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest - val token: String? = request.accessToken() - - try { - return jwtHandler.getMemberIdFromToken(token) - .also { MDC.put("member_id", "$it") } - } catch (e: Exception) { - log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" } - val errorCode = AuthErrorCode.MEMBER_NOT_FOUND - throw AuthException(errorCode, e.message ?: errorCode.message) - } - } -} diff --git a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt index 90ee6b45..f69629bc 100644 --- a/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/interceptors/AuthenticatedInterceptor.kt @@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod import org.springframework.web.servlet.HandlerInterceptor -import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.business.AuthService import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.support.Authenticated import roomescape.auth.web.support.accessToken @@ -17,7 +17,7 @@ private val log: KLogger = KotlinLogging.logger {} @Component class AuthenticatedInterceptor( private val jwtUtils: JwtUtils, - private val authService: AuthServiceV2 + private val authService: AuthService ) : HandlerInterceptor { override fun preHandle( diff --git a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt index a4ca2652..5ee2285f 100644 --- a/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/resolver/CurrentUserContextResolver.kt @@ -9,7 +9,7 @@ import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer -import roomescape.auth.business.AuthServiceV2 +import roomescape.auth.business.AuthService import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils @@ -21,7 +21,7 @@ private val log: KLogger = KotlinLogging.logger {} @Component class CurrentUserContextResolver( private val jwtUtils: JwtUtils, - private val authService: AuthServiceV2 + private val authService: AuthService ) : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean { diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index 9d744670..6ee4e748 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -4,8 +4,6 @@ import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import roomescape.auth.web.support.AuthInterceptor -import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.interceptors.AdminInterceptor import roomescape.auth.web.support.interceptors.AuthenticatedInterceptor import roomescape.auth.web.support.interceptors.UserInterceptor @@ -13,8 +11,6 @@ import roomescape.auth.web.support.resolver.CurrentUserContextResolver @Configuration class WebMvcConfig( - private val memberIdResolver: MemberIdResolver, - private val authInterceptor: AuthInterceptor, private val adminInterceptor: AdminInterceptor, private val userInterceptor: UserInterceptor, private val authenticatedInterceptor: AuthenticatedInterceptor, @@ -22,12 +18,10 @@ class WebMvcConfig( ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { - resolvers.add(memberIdResolver) resolvers.add(currentUserContextResolver) } override fun addInterceptors(registry: InterceptorRegistry) { - registry.addInterceptor(authInterceptor) registry.addInterceptor(adminInterceptor) registry.addInterceptor(userInterceptor) registry.addInterceptor(authenticatedInterceptor) diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntity.kt b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt index 3c821aa3..707d2249 100644 --- a/src/main/kotlin/roomescape/common/entity/BaseEntity.kt +++ b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt @@ -1,7 +1,9 @@ package roomescape.common.entity import jakarta.persistence.* +import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedBy import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.domain.Persistable import org.springframework.data.jpa.domain.support.AuditingEntityListener @@ -10,28 +12,24 @@ import kotlin.jvm.Transient @MappedSuperclass @EntityListeners(AuditingEntityListener::class) -abstract class BaseEntity( +abstract class AuditingBaseEntity( + id: Long, +) : PersistableBaseEntity(id) { @Column(updatable = false) @CreatedDate - var createdAt: LocalDateTime? = null, + lateinit var createdAt: LocalDateTime + @Column(updatable = false) + @CreatedBy + var createdBy: Long = 0L + + @Column @LastModifiedDate - var lastModifiedAt: LocalDateTime? = null, -) : Persistable { - - @Transient - private var isNewEntity: Boolean = true - - @PostLoad - @PostPersist - fun markNotNew() { - isNewEntity = false - } - - override fun isNew(): Boolean = isNewEntity - - abstract override fun getId(): Long? + lateinit var updatedAt: LocalDateTime + @Column + @LastModifiedBy + var updatedBy: Long = 0L } @MappedSuperclass @@ -43,12 +41,13 @@ abstract class PersistableBaseEntity( @Transient private var isNewEntity: Boolean = true ) : Persistable { + @PostLoad - @PostPersist + @PrePersist fun markNotNew() { isNewEntity = false } - override fun isNew(): Boolean = isNewEntity override fun getId(): Long = _id + override fun isNew(): Boolean = isNewEntity } diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt b/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt deleted file mode 100644 index 021f4730..00000000 --- a/src/main/kotlin/roomescape/common/entity/BaseEntityV2.kt +++ /dev/null @@ -1,53 +0,0 @@ -package roomescape.common.entity - -import jakarta.persistence.* -import org.springframework.data.annotation.CreatedBy -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedBy -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 AuditingBaseEntity( - id: Long, -) : BaseEntityV2(id) { - @Column(updatable = false) - @CreatedDate - lateinit var createdAt: LocalDateTime - - @Column(updatable = false) - @CreatedBy - var createdBy: Long = 0L - - @Column - @LastModifiedDate - lateinit var updatedAt: LocalDateTime - - @Column - @LastModifiedBy - var updatedBy: Long = 0L -} - -@MappedSuperclass -abstract class BaseEntityV2( - @Id - @Column(name = "id") - private val _id: Long, - - @Transient - private var isNewEntity: Boolean = true -) : Persistable { - - @PostLoad - @PrePersist - fun markNotNew() { - isNewEntity = false - } - - override fun getId(): Long = _id - override fun isNew(): Boolean = isNewEntity -} diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt deleted file mode 100644 index 7df26417..00000000 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ /dev/null @@ -1,46 +0,0 @@ -package roomescape.member.business - -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import roomescape.member.implement.MemberFinder -import roomescape.member.implement.MemberWriter -import roomescape.member.infrastructure.persistence.Role -import roomescape.member.web.* - -private val log = KotlinLogging.logger {} - -@Service -class MemberService( - private val memberWriter: MemberWriter, - private val memberFinder: MemberFinder, -) { - @Transactional(readOnly = true) - fun findMembers(): MemberRetrieveListResponse { - log.debug { "[MemberService.findMembers] 시작" } - - return memberFinder.findAll() - .toRetrieveListResponse() - .also { log.info { "[MemberService.findMembers] 완료. ${it.members.size}명 반환" } } - } - - @Transactional(readOnly = true) - fun findSummaryById(id: Long): MemberSummaryRetrieveResponse { - log.debug { "[MemberService.findSummaryById] 시작" } - - return memberFinder.findById(id) - .toSummaryRetrieveResponse() - .also { - log.info { "[MemberService.findSummaryById] 완료. memberId=${id}, email=${it.email}" } - } - } - - @Transactional - fun createMember(request: SignupRequest): SignupResponse { - log.debug { "[MemberService.createMember] 시작" } - - return memberWriter.create(request.name, request.email, request.password, Role.MEMBER) - .toSignupResponse() - .also { log.info { "[MemberService.create] 완료: email=${request.email} memberId=${it.id}" } } - } -} diff --git a/src/main/kotlin/roomescape/member/docs/MemberAPI.kt b/src/main/kotlin/roomescape/member/docs/MemberAPI.kt deleted file mode 100644 index 36ebfa7a..00000000 --- a/src/main/kotlin/roomescape/member/docs/MemberAPI.kt +++ /dev/null @@ -1,37 +0,0 @@ -package roomescape.member.docs - -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.responses.ApiResponse -import io.swagger.v3.oas.annotations.responses.ApiResponses -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.RequestBody -import roomescape.auth.web.support.Admin -import roomescape.common.dto.response.CommonApiResponse -import roomescape.member.web.MemberRetrieveListResponse -import roomescape.member.web.SignupRequest -import roomescape.member.web.SignupResponse - -@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.") -interface MemberAPI { - @Admin - @Operation(summary = "모든 회원 조회", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses( - ApiResponse( - responseCode = "200", - description = "성공", - useReturnTypeSchema = true - ) - ) - fun findMembers(): ResponseEntity> - - @Operation(summary = "회원 가입") - @ApiResponses( - ApiResponse( - responseCode = "201", - description = "성공", - useReturnTypeSchema = true - ) - ) - fun signup(@RequestBody request: SignupRequest): ResponseEntity> -} diff --git a/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt b/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt deleted file mode 100644 index 8185ef5b..00000000 --- a/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt +++ /dev/null @@ -1,13 +0,0 @@ -package roomescape.member.exception - -import org.springframework.http.HttpStatus -import roomescape.common.exception.ErrorCode - -enum class MemberErrorCode( - override val httpStatus: HttpStatus, - override val errorCode: String, - override val message: String -) : ErrorCode { - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요."), - DUPLICATE_EMAIL(HttpStatus.CONFLICT, "M002", "이미 가입된 이메일이에요.") -} diff --git a/src/main/kotlin/roomescape/member/exception/MemberException.kt b/src/main/kotlin/roomescape/member/exception/MemberException.kt deleted file mode 100644 index 2d82be5a..00000000 --- a/src/main/kotlin/roomescape/member/exception/MemberException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package roomescape.member.exception - -import roomescape.common.exception.RoomescapeException - -class MemberException( - override val errorCode: MemberErrorCode, - override val message: String = errorCode.message -) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/member/implement/MemberFinder.kt b/src/main/kotlin/roomescape/member/implement/MemberFinder.kt deleted file mode 100644 index 4c68dfcf..00000000 --- a/src/main/kotlin/roomescape/member/implement/MemberFinder.kt +++ /dev/null @@ -1,47 +0,0 @@ -package roomescape.member.implement - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.data.repository.findByIdOrNull -import org.springframework.stereotype.Component -import roomescape.member.exception.MemberErrorCode -import roomescape.member.exception.MemberException -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class MemberFinder( - private val memberRepository: MemberRepository -) { - - fun findAll(): List { - log.debug { "[MemberFinder.findAll] 회원 조회 시작" } - - return memberRepository.findAll() - .also { log.debug { "[MemberFinder.findAll] 회원 ${it.size}명 조회 완료" } } - } - - fun findById(id: Long): MemberEntity { - log.debug { "[MemberFinder.findById] 조회 시작: memberId=$id" } - - return memberRepository.findByIdOrNull(id) - ?.also { log.debug { "[MemberFinder.findById] 조회 완료: memberId=$id, email=${it.email}" } } - ?: run { - log.info { "[MemberFinder.findById] 조회 실패: id=$id" } - throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) - } - } - - fun findByEmailAndPassword(email: String, password: String): MemberEntity { - log.debug { "[MemberFinder.findByEmailAndPassword] 조회 시작: email=$email, password=$password" } - - return memberRepository.findByEmailAndPassword(email, password) - ?.also { log.debug { "[MemberFinder.findByEmailAndPassword] 조회 완료: email=${email}, memberId=${it.id}" } } - ?: run { - log.info { "[MemberFinder.findByEmailAndPassword] 조회 실패: email=${email}, password=${password}" } - throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) - } - } -} diff --git a/src/main/kotlin/roomescape/member/implement/MemberValidator.kt b/src/main/kotlin/roomescape/member/implement/MemberValidator.kt deleted file mode 100644 index 1b3c54d2..00000000 --- a/src/main/kotlin/roomescape/member/implement/MemberValidator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package roomescape.member.implement - -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component -import roomescape.member.exception.MemberErrorCode -import roomescape.member.exception.MemberException -import roomescape.member.infrastructure.persistence.MemberRepository - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class MemberValidator( - private val memberRepository: MemberRepository -) { - fun validateCanSignup(email: String) { - log.debug { "[MemberValidator.validateCanSignup] 시작: email=$email" } - - if (memberRepository.existsByEmail(email)) { - log.info { "[MemberValidator.validateCanSignup] 중복 이메일: email=$email" } - throw MemberException(MemberErrorCode.DUPLICATE_EMAIL) - } - - log.debug { "[MemberValidator.validateCanSignup] 완료: email=$email" } - } -} diff --git a/src/main/kotlin/roomescape/member/implement/MemberWriter.kt b/src/main/kotlin/roomescape/member/implement/MemberWriter.kt deleted file mode 100644 index 34a7b7b9..00000000 --- a/src/main/kotlin/roomescape/member/implement/MemberWriter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package roomescape.member.implement - -import com.github.f4b6a3.tsid.TsidFactory -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component -import roomescape.common.config.next -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.MemberRepository -import roomescape.member.infrastructure.persistence.Role - -private val log: KLogger = KotlinLogging.logger {} - -@Component -class MemberWriter( - private val tsidFactory: TsidFactory, - private val memberValidator: MemberValidator, - private val memberRepository: MemberRepository -) { - fun create(name: String, email: String, password: String, role: Role): MemberEntity { - log.debug { "[MemberWriter.create] 시작: email=$email" } - memberValidator.validateCanSignup(email) - - val member = MemberEntity( - _id = tsidFactory.next(), - name = name, - email = email, - password = password, - role = role - ) - - return memberRepository.save(member) - .also { log.debug { "[MemberWriter.create] 완료: email=$email, memberId=${it.id}" } } - } -} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt deleted file mode 100644 index 2b287722..00000000 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberEntity.kt +++ /dev/null @@ -1,34 +0,0 @@ -package roomescape.member.infrastructure.persistence - -import jakarta.persistence.* -import roomescape.common.entity.BaseEntity - -@Entity -@Table(name = "members") -class MemberEntity( - @Id - @Column(name = "member_id") - private var _id: Long?, - - @Column(name = "name", nullable = false) - var name: String, - - @Column(name = "email", nullable = false) - var email: String, - - @Column(name = "password", nullable = false) - var password: String, - - @Column(name = "role", nullable = false, length = 20) - @Enumerated(value = EnumType.STRING) - var role: Role -) : BaseEntity() { - override fun getId(): Long? = _id - - fun isAdmin(): Boolean = role == Role.ADMIN -} - -enum class Role { - MEMBER, - ADMIN, -} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt deleted file mode 100644 index 5b5fd828..00000000 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/MemberRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package roomescape.member.infrastructure.persistence - -import org.springframework.data.jpa.repository.JpaRepository - -interface MemberRepository : JpaRepository { - fun existsByEmail(email: String): Boolean - - fun findByEmailAndPassword(email: String, password: String): MemberEntity? -} diff --git a/src/main/kotlin/roomescape/member/web/MemberController.kt b/src/main/kotlin/roomescape/member/web/MemberController.kt deleted file mode 100644 index 00859ca2..00000000 --- a/src/main/kotlin/roomescape/member/web/MemberController.kt +++ /dev/null @@ -1,31 +0,0 @@ -package roomescape.member.web - -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController -import roomescape.common.dto.response.CommonApiResponse -import roomescape.member.business.MemberService -import roomescape.member.docs.MemberAPI -import java.net.URI - -@RestController -class MemberController( - private val memberService: MemberService -) : MemberAPI { - - @PostMapping("/members") - override fun signup(@RequestBody request: SignupRequest): ResponseEntity> { - val response: SignupResponse = memberService.createMember(request) - return ResponseEntity.created(URI.create("/members/${response.id}")) - .body(CommonApiResponse(response)) - } - - @GetMapping("/members") - override fun findMembers(): ResponseEntity> { - val response: MemberRetrieveListResponse = memberService.findMembers() - - return ResponseEntity.ok(CommonApiResponse(response)) - } -} diff --git a/src/main/kotlin/roomescape/member/web/MemberDTO.kt b/src/main/kotlin/roomescape/member/web/MemberDTO.kt deleted file mode 100644 index c1a77927..00000000 --- a/src/main/kotlin/roomescape/member/web/MemberDTO.kt +++ /dev/null @@ -1,56 +0,0 @@ -package roomescape.member.web - -import io.swagger.v3.oas.annotations.media.Schema -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.Role - -fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveResponse( - id = id!!, - name = name -) - -data class MemberRetrieveResponse( - @Schema(description = "회원 식별자") - val id: Long, - - @Schema(description = "회원 이름") - val name: String -) - -fun List.toRetrieveListResponse(): MemberRetrieveListResponse = MemberRetrieveListResponse( - members = this.map { it.toRetrieveResponse() } -) - -data class MemberRetrieveListResponse( - val members: List -) - -data class SignupRequest( - val email: String, - val password: String, - val name: String -) - -data class SignupResponse( - val id: Long, - val name: String, -) - -fun MemberEntity.toSignupResponse(): SignupResponse = SignupResponse( - id = this.id!!, - name = this.name -) - -data class MemberSummaryRetrieveResponse( - val id: Long, - val name: String, - val email: String, - val role: Role -) - -fun MemberEntity.toSummaryRetrieveResponse() = MemberSummaryRetrieveResponse( - id = this.id!!, - name = this.name, - email = this.email, - role = this.role -) diff --git a/src/main/kotlin/roomescape/payment/web/PaymentController.kt b/src/main/kotlin/roomescape/payment/web/PaymentController.kt index 3923a39c..3edc2293 100644 --- a/src/main/kotlin/roomescape/payment/web/PaymentController.kt +++ b/src/main/kotlin/roomescape/payment/web/PaymentController.kt @@ -1,6 +1,5 @@ package roomescape.payment.web -import io.swagger.v3.oas.annotations.Parameter import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -8,7 +7,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import roomescape.auth.web.support.CurrentUser -import roomescape.auth.web.support.MemberId import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse import roomescape.payment.business.PaymentService diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/CanceledReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/CanceledReservationEntity.kt index 7d8f7d0b..84ac475e 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/CanceledReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/CanceledReservationEntity.kt @@ -4,7 +4,7 @@ import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Table -import roomescape.common.entity.BaseEntityV2 +import roomescape.common.entity.PersistableBaseEntity import java.time.LocalDateTime @Entity @@ -19,8 +19,7 @@ class CanceledReservationEntity( @Enumerated(value = EnumType.STRING) val status: CanceledReservationStatus, - - ) : BaseEntityV2(id) +) : PersistableBaseEntity(id) enum class CanceledReservationStatus { PROCESSING, FAILED, COMPLETED diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt index 9c5efbb5..7328635b 100644 --- a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -9,7 +9,6 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.admin.business.AdminService import roomescape.common.config.next -import roomescape.member.business.MemberService import roomescape.schedule.exception.ScheduleErrorCode import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleRepository diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt index 1ae6df4b..69a0816e 100644 --- a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -4,16 +4,13 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid -import org.aspectj.internal.lang.annotation.ajcPrivileged import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestParam import roomescape.admin.infrastructure.persistence.Privilege -import roomescape.auth.web.support.Admin import roomescape.auth.web.support.AdminOnly -import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.UserOnly import roomescape.common.dto.response.CommonApiResponse import roomescape.schedule.web.* diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 56ae0271..66778111 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import roomescape.admin.business.AdminService import roomescape.common.config.next -import roomescape.member.business.MemberService import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 36587af1..d07cc8ce 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -9,9 +9,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import roomescape.admin.infrastructure.persistence.Privilege -import roomescape.auth.web.support.Admin import roomescape.auth.web.support.AdminOnly -import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.UserOnly import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 874e840b..9897fffc 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -8,16 +8,6 @@ create table if not exists region ( dong_name varchar(20) not null ); -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, - last_modified_at timestamp -); - create table if not exists users( id bigint primary key, name varchar(50) not null, diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index d22c1e61..ff60aaed 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -18,11 +18,7 @@ import roomescape.payment.infrastructure.persistence.* import roomescape.payment.web.PaymentConfirmRequest import roomescape.payment.web.PaymentCreateResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity -import roomescape.util.FunSpecSpringbootTest -import roomescape.util.INVALID_PK -import roomescape.util.PaymentFixture -import roomescape.util.runExceptionTest -import roomescape.util.runTest +import roomescape.util.* class PaymentAPITest( @MockkBean diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index cd99607d..45ff3c10 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -4,8 +4,6 @@ import com.github.f4b6a3.tsid.TsidFactory import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminPermissionLevel import roomescape.common.config.next -import roomescape.member.infrastructure.persistence.MemberEntity -import roomescape.member.infrastructure.persistence.Role import roomescape.member.infrastructure.persistence.UserEntity import roomescape.member.infrastructure.persistence.UserStatus import roomescape.member.web.MIN_PASSWORD_LENGTH @@ -25,24 +23,6 @@ import java.time.OffsetDateTime const val INVALID_PK: Long = 9999L val tsidFactory = TsidFactory(0) -object MemberFixture { - val admin: MemberEntity = MemberEntity( - _id = 9304, - name = "ADMIN", - email = "admin@example.com", - password = "adminPassword", - role = Role.ADMIN - ) - - val user: MemberEntity = MemberEntity( - _id = 9305, - name = "USER", - email = "user@example.com", - password = "userPassword", - role = Role.MEMBER - ) -} - object AdminFixture { val default: AdminEntity = create() diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/util/KotestConfig.kt index 3c41abab..93a80aa5 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/util/KotestConfig.kt @@ -14,7 +14,6 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.test.context.ActiveProfiles import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.UserRepository import roomescape.payment.business.PaymentWriter import roomescape.payment.infrastructure.persistence.PaymentRepository @@ -32,9 +31,6 @@ object KotestConfig : AbstractProjectConfig() { abstract class FunSpecSpringbootTest : FunSpec({ extension(DatabaseCleanerExtension()) }) { - @Autowired - private lateinit var memberRepository: MemberRepository - @Autowired private lateinit var userRepository: UserRepository @@ -51,7 +47,7 @@ abstract class FunSpecSpringbootTest : FunSpec({ override suspend fun beforeSpec(spec: Spec) { RestAssured.port = port - authUtil = AuthUtil(memberRepository, userRepository, adminRepository) + authUtil = AuthUtil(userRepository, adminRepository) } } diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index c7cf5cf7..d2b43ac7 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -14,57 +14,17 @@ import org.springframework.http.HttpStatus import org.springframework.http.MediaType import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequestV2 -import roomescape.common.config.next import roomescape.common.dto.PrincipalType import roomescape.common.exception.ErrorCode -import roomescape.member.infrastructure.persistence.* +import roomescape.member.infrastructure.persistence.UserEntity +import roomescape.member.infrastructure.persistence.UserRepository import roomescape.member.web.UserCreateRequest class AuthUtil( - private val memberRepository: MemberRepository, private val userRepository: UserRepository, private val adminRepository: AdminRepository ) { - fun login(email: String, password: String, role: Role = Role.MEMBER): String { - if (!memberRepository.existsByEmail(email)) { - memberRepository.save( - MemberEntity( - _id = tsidFactory.next(), - email = email, - password = password, - name = email.split("@").first(), - role = role - ) - ) - } - - return Given { - contentType(MediaType.APPLICATION_JSON_VALUE) - body(LoginRequest(email, password)) - } When { - post("/login") - } Then { - statusCode(200) - } Extract { - path("data.accessToken") - } - } - - fun loginAsAdmin(): String { - return login(MemberFixture.admin.email, MemberFixture.admin.password, Role.ADMIN) - } - - fun loginAsUser(): String { - return login(MemberFixture.user.email, MemberFixture.user.password) - } - - fun getUser(): MemberEntity = memberRepository.findByEmailAndPassword( - MemberFixture.user.email, - MemberFixture.user.password - ) ?: throw AssertionError("Unexpected Exception Occurred.") - fun createAdmin(admin: AdminEntity): AdminEntity { return adminRepository.save(admin) } -- 2.47.2 From 16ee7eecf37d9cd1056a1ab0150d81c71bfd691b Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:34:05 +0900 Subject: [PATCH 53/73] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=9C=A0=ED=8B=B8=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=AA=85=20=EC=88=98=EC=A0=95(util=20->=20su?= =?UTF-8?q?pports)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/auth/AuthApiTest.kt | 8 ++++---- .../kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt | 8 ++++---- src/test/kotlin/roomescape/auth/JwtUtilsTest.kt | 2 +- src/test/kotlin/roomescape/payment/PaymentAPITest.kt | 2 +- .../kotlin/roomescape/reservation/ReservationApiTest.kt | 2 +- src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt | 4 ++-- .../roomescape/{util => supports}/DummyInitializer.kt | 2 +- src/test/kotlin/roomescape/{util => supports}/Fixtures.kt | 2 +- .../kotlin/roomescape/{util => supports}/KotestConfig.kt | 2 +- .../roomescape/{util => supports}/RestAssuredUtils.kt | 2 +- .../roomescape/{util => supports}/TestDatabaseUtil.kt | 2 +- src/test/kotlin/roomescape/theme/ThemeApiTest.kt | 4 ++-- src/test/kotlin/roomescape/user/UserApiTest.kt | 6 +++--- 13 files changed, 23 insertions(+), 23 deletions(-) rename src/test/kotlin/roomescape/{util => supports}/DummyInitializer.kt (99%) rename src/test/kotlin/roomescape/{util => supports}/Fixtures.kt (99%) rename src/test/kotlin/roomescape/{util => supports}/KotestConfig.kt (98%) rename src/test/kotlin/roomescape/{util => supports}/RestAssuredUtils.kt (99%) rename src/test/kotlin/roomescape/{util => supports}/TestDatabaseUtil.kt (98%) diff --git a/src/test/kotlin/roomescape/auth/AuthApiTest.kt b/src/test/kotlin/roomescape/auth/AuthApiTest.kt index 49142f29..0f0f46b5 100644 --- a/src/test/kotlin/roomescape/auth/AuthApiTest.kt +++ b/src/test/kotlin/roomescape/auth/AuthApiTest.kt @@ -18,10 +18,10 @@ import roomescape.auth.web.LoginRequestV2 import roomescape.common.dto.PrincipalType import roomescape.member.exception.UserErrorCode import roomescape.member.infrastructure.persistence.UserEntity -import roomescape.util.AdminFixture -import roomescape.util.FunSpecSpringbootTest -import roomescape.util.UserFixture -import roomescape.util.runTest +import roomescape.supports.AdminFixture +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.UserFixture +import roomescape.supports.runTest class AuthApiTest( @SpykBean private val jwtUtils: JwtUtils, diff --git a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt index 750a3ee4..205e90ac 100644 --- a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt +++ b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt @@ -7,10 +7,10 @@ import org.springframework.http.HttpStatus import roomescape.auth.infrastructure.persistence.LoginHistoryRepository import roomescape.auth.web.LoginRequestV2 import roomescape.common.dto.PrincipalType -import roomescape.util.AdminFixture -import roomescape.util.FunSpecSpringbootTest -import roomescape.util.UserFixture -import roomescape.util.runTest +import roomescape.supports.AdminFixture +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.UserFixture +import roomescape.supports.runTest class FailOnSaveLoginHistoryTest( @MockkBean private val loginHistoryRepository: LoginHistoryRepository diff --git a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt index 655b4667..28aff8ef 100644 --- a/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt +++ b/src/test/kotlin/roomescape/auth/JwtUtilsTest.kt @@ -7,7 +7,7 @@ import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.common.config.next -import roomescape.util.tsidFactory +import roomescape.supports.tsidFactory class JwtUtilsTest : FunSpec() { private val jwtUtils: JwtUtils = JwtUtils( diff --git a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt index ff60aaed..97947b5e 100644 --- a/src/test/kotlin/roomescape/payment/PaymentAPITest.kt +++ b/src/test/kotlin/roomescape/payment/PaymentAPITest.kt @@ -18,7 +18,7 @@ import roomescape.payment.infrastructure.persistence.* import roomescape.payment.web.PaymentConfirmRequest import roomescape.payment.web.PaymentCreateResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity -import roomescape.util.* +import roomescape.supports.* class PaymentAPITest( @MockkBean diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 1e7e43ea..626b54b5 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -22,7 +22,7 @@ import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.util.* +import roomescape.supports.* import java.time.LocalDate import java.time.LocalTime diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt index 72573521..1f263f42 100644 --- a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -16,8 +16,8 @@ import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.ScheduleUpdateRequest -import roomescape.util.* -import roomescape.util.ScheduleFixture.createRequest +import roomescape.supports.* +import roomescape.supports.ScheduleFixture.createRequest import java.time.LocalDate import java.time.LocalTime diff --git a/src/test/kotlin/roomescape/util/DummyInitializer.kt b/src/test/kotlin/roomescape/supports/DummyInitializer.kt similarity index 99% rename from src/test/kotlin/roomescape/util/DummyInitializer.kt rename to src/test/kotlin/roomescape/supports/DummyInitializer.kt index f9d3368c..300af25b 100644 --- a/src/test/kotlin/roomescape/util/DummyInitializer.kt +++ b/src/test/kotlin/roomescape/supports/DummyInitializer.kt @@ -1,4 +1,4 @@ -package roomescape.util +package roomescape.supports import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt similarity index 99% rename from src/test/kotlin/roomescape/util/Fixtures.kt rename to src/test/kotlin/roomescape/supports/Fixtures.kt index 45ff3c10..53214c2c 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -1,4 +1,4 @@ -package roomescape.util +package roomescape.supports import com.github.f4b6a3.tsid.TsidFactory import roomescape.admin.infrastructure.persistence.AdminEntity diff --git a/src/test/kotlin/roomescape/util/KotestConfig.kt b/src/test/kotlin/roomescape/supports/KotestConfig.kt similarity index 98% rename from src/test/kotlin/roomescape/util/KotestConfig.kt rename to src/test/kotlin/roomescape/supports/KotestConfig.kt index 93a80aa5..6b578421 100644 --- a/src/test/kotlin/roomescape/util/KotestConfig.kt +++ b/src/test/kotlin/roomescape/supports/KotestConfig.kt @@ -1,4 +1,4 @@ -package roomescape.util +package roomescape.supports import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.spec.Spec diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt similarity index 99% rename from src/test/kotlin/roomescape/util/RestAssuredUtils.kt rename to src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index d2b43ac7..b3118d67 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -1,4 +1,4 @@ -package roomescape.util +package roomescape.supports import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given diff --git a/src/test/kotlin/roomescape/util/TestDatabaseUtil.kt b/src/test/kotlin/roomescape/supports/TestDatabaseUtil.kt similarity index 98% rename from src/test/kotlin/roomescape/util/TestDatabaseUtil.kt rename to src/test/kotlin/roomescape/supports/TestDatabaseUtil.kt index 0e7aed8d..07c5591f 100644 --- a/src/test/kotlin/roomescape/util/TestDatabaseUtil.kt +++ b/src/test/kotlin/roomescape/supports/TestDatabaseUtil.kt @@ -1,4 +1,4 @@ -package roomescape.util +package roomescape.supports import io.kotest.core.listeners.AfterSpecListener import io.kotest.core.listeners.AfterTestListener diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index fd9e29a6..31c0dabe 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -20,8 +20,8 @@ import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.ThemeListRetrieveRequest import roomescape.theme.web.ThemeUpdateRequest -import roomescape.util.* -import roomescape.util.ThemeFixture.createRequest +import roomescape.supports.* +import roomescape.supports.ThemeFixture.createRequest import kotlin.random.Random class ThemeApiTest( diff --git a/src/test/kotlin/roomescape/user/UserApiTest.kt b/src/test/kotlin/roomescape/user/UserApiTest.kt index 5bb1657f..1c2e0f85 100644 --- a/src/test/kotlin/roomescape/user/UserApiTest.kt +++ b/src/test/kotlin/roomescape/user/UserApiTest.kt @@ -16,9 +16,9 @@ import roomescape.member.exception.UserErrorCode import roomescape.member.infrastructure.persistence.* import roomescape.member.web.MIN_PASSWORD_LENGTH import roomescape.member.web.UserCreateRequest -import roomescape.util.FunSpecSpringbootTest -import roomescape.util.UserFixture -import roomescape.util.runTest +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.UserFixture +import roomescape.supports.runTest class UserApiTest( private val userRepository: UserRepository, -- 2.47.2 From bf6b1b5cdc9542830cd2c0fcb2bc5964295f1734 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 13:57:20 +0900 Subject: [PATCH 54/73] =?UTF-8?q?rename:=20=ED=85=8C=EB=A7=88=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/business/ReservationService.kt | 4 ++-- .../reservation/business/ReservationValidator.kt | 4 ++-- .../roomescape/theme/business/ThemeService.kt | 8 ++++---- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 6 +++--- .../roomescape/theme/web/ThemeController.kt | 8 ++++---- src/main/kotlin/roomescape/theme/web/ThemeDto.kt | 16 ++++++++-------- src/test/kotlin/roomescape/theme/ThemeApiTest.kt | 10 +++++----- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index f59889e9..e7d20335 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -22,7 +22,7 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.ScheduleSummaryResponse import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.theme.business.ThemeService -import roomescape.theme.web.ThemeSummaryResponse +import roomescape.theme.web.ThemeInfoRetrieveResponse import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -96,7 +96,7 @@ class ReservationService( return ReservationSummaryRetrieveListResponse(reservations.map { val schedule: ScheduleSummaryResponse = scheduleService.findSummaryById(it.scheduleId) - val theme: ThemeSummaryResponse = themeService.findSummaryById(schedule.themeId) + val theme: ThemeInfoRetrieveResponse = themeService.findSummaryById(schedule.themeId) ReservationSummaryRetrieveResponse( id = it.id, diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationValidator.kt b/src/main/kotlin/roomescape/reservation/business/ReservationValidator.kt index 2bcdaea8..ecc92dcd 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationValidator.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationValidator.kt @@ -8,7 +8,7 @@ import roomescape.reservation.exception.ReservationException import roomescape.reservation.web.PendingReservationCreateRequest import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.schedule.web.ScheduleSummaryResponse -import roomescape.theme.web.ThemeSummaryResponse +import roomescape.theme.web.ThemeInfoRetrieveResponse private val log: KLogger = KotlinLogging.logger {} @@ -17,7 +17,7 @@ class ReservationValidator { fun validateCanCreate( schedule: ScheduleSummaryResponse, - theme: ThemeSummaryResponse, + theme: ThemeInfoRetrieveResponse, request: PendingReservationCreateRequest ) { if (schedule.status != ScheduleStatus.HOLD) { diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 66778111..60171bef 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -24,7 +24,7 @@ class ThemeService( private val adminService: AdminService ) { @Transactional(readOnly = true) - fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeSummaryListResponse { + fun findThemesByIds(request: ThemeIdListRetrieveResponse): ThemeInfoListRetrieveResponse { log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } val result: MutableList = mutableListOf() @@ -43,7 +43,7 @@ class ThemeService( } @Transactional(readOnly = true) - fun findThemesForReservation(): ThemeSummaryListResponse { + fun findThemesForReservation(): ThemeInfoListRetrieveResponse { log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } return themeRepository.findOpenedThemes() @@ -52,7 +52,7 @@ class ThemeService( } @Transactional(readOnly = true) - fun findAdminThemes(): AdminThemeSummaryRetrieveListResponse { + fun findAdminThemes(): AdminThemeSummaryListRetrieveResponse { log.info { "[ThemeService.findAdminThemes] 관리자 페이지에서의 테마 목록 조회 시작" } return themeRepository.findAll() @@ -74,7 +74,7 @@ class ThemeService( } @Transactional(readOnly = true) - fun findSummaryById(id: Long): ThemeSummaryResponse { + fun findSummaryById(id: Long): ThemeInfoRetrieveResponse { log.info { "[ThemeService.findById] 테마 조회 시작: id=$id" } return findOrThrow(id).toSummaryResponse() diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index d07cc8ce..e11e4dfd 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -20,7 +20,7 @@ interface ThemeAPIV2 { @AdminOnly(privilege = Privilege.READ_SUMMARY) @Operation(summary = "모든 테마 조회", description = "관리자 페이지에서 요약된 테마 목록을 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findAdminThemes(): ResponseEntity> + fun findAdminThemes(): ResponseEntity> @AdminOnly(privilege = Privilege.READ_DETAIL) @Operation(summary = "테마 상세 조회", description = "해당 테마의 상세 정보를 조회합니다.", tags = ["관리자 로그인이 필요한 API"]) @@ -48,10 +48,10 @@ interface ThemeAPIV2 { @UserOnly @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findUserThemes(): ResponseEntity> + fun findUserThemes(): ResponseEntity> @UserOnly @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity> + fun findThemesByIds(request: ThemeIdListRetrieveResponse): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index bc7321da..8f5a667a 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -14,22 +14,22 @@ class ThemeController( @PostMapping("/themes/retrieve") override fun findThemesByIds( - @RequestBody request: ThemeListRetrieveRequest - ): ResponseEntity> { + @RequestBody request: ThemeIdListRetrieveResponse + ): ResponseEntity> { val response = themeService.findThemesByIds(request) return ResponseEntity.ok(CommonApiResponse(response)) } @GetMapping("/v2/themes") - override fun findUserThemes(): ResponseEntity> { + override fun findUserThemes(): ResponseEntity> { val response = themeService.findThemesForReservation() return ResponseEntity.ok(CommonApiResponse(response)) } @GetMapping("/admin/themes") - override fun findAdminThemes(): ResponseEntity> { + override fun findAdminThemes(): ResponseEntity> { val response = themeService.findAdminThemes() return ResponseEntity.ok(CommonApiResponse(response)) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt index a1cf87a8..ce3ae4eb 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt @@ -82,11 +82,11 @@ fun ThemeEntity.toAdminThemeSummaryResponse() = AdminThemeSummaryRetrieveRespons isOpen = this.isOpen ) -data class AdminThemeSummaryRetrieveListResponse( +data class AdminThemeSummaryListRetrieveResponse( val themes: List ) -fun List.toAdminThemeSummaryListResponse() = AdminThemeSummaryRetrieveListResponse( +fun List.toAdminThemeSummaryListResponse() = AdminThemeSummaryListRetrieveResponse( themes = this.map { it.toAdminThemeSummaryResponse() } ) @@ -129,11 +129,11 @@ fun ThemeEntity.toAdminThemeDetailResponse(createdBy: OperatorInfo, updatedBy: O updatedBy = updatedBy ) -data class ThemeListRetrieveRequest( +data class ThemeIdListRetrieveResponse( val themeIds: List ) -data class ThemeSummaryResponse( +data class ThemeInfoRetrieveResponse( val id: Long, val name: String, val thumbnailUrl: String, @@ -147,7 +147,7 @@ data class ThemeSummaryResponse( val expectedMinutesTo: Short ) -fun ThemeEntity.toSummaryResponse() = ThemeSummaryResponse( +fun ThemeEntity.toSummaryResponse() = ThemeInfoRetrieveResponse( id = this.id, name = this.name, thumbnailUrl = this.thumbnailUrl, @@ -161,10 +161,10 @@ fun ThemeEntity.toSummaryResponse() = ThemeSummaryResponse( expectedMinutesTo = this.expectedMinutesTo ) -data class ThemeSummaryListResponse( - val themes: List +data class ThemeInfoListRetrieveResponse( + val themes: List ) -fun List.toRetrieveListResponse() = ThemeSummaryListResponse( +fun List.toRetrieveListResponse() = ThemeInfoListRetrieveResponse( themes = this.map { it.toSummaryResponse() } ) diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 31c0dabe..7e7799ba 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -18,7 +18,7 @@ import roomescape.theme.business.MIN_PRICE import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeListRetrieveRequest +import roomescape.theme.web.ThemeIdListRetrieveResponse import roomescape.theme.web.ThemeUpdateRequest import roomescape.supports.* import roomescape.supports.ThemeFixture.createRequest @@ -289,7 +289,7 @@ class ThemeApiTest( test("비회원") { runExceptionTest( method = HttpMethod.POST, - requestBody = ThemeListRetrieveRequest(themeIds = listOf()), + requestBody = ThemeIdListRetrieveResponse(themeIds = listOf()), endpoint = endpoint, expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND ) @@ -299,7 +299,7 @@ class ThemeApiTest( runExceptionTest( token = authUtil.defaultAdminLogin(), method = HttpMethod.POST, - requestBody = ThemeListRetrieveRequest(themeIds = listOf()), + requestBody = ThemeIdListRetrieveResponse(themeIds = listOf()), endpoint = endpoint, expectedErrorCode = AuthErrorCode.ACCESS_DENIED ) @@ -319,7 +319,7 @@ class ThemeApiTest( runTest( token = authUtil.defaultUserLogin(), using = { - body(ThemeListRetrieveRequest(themeIds)) + body(ThemeIdListRetrieveResponse(themeIds)) }, on = { post("/themes/retrieve") @@ -345,7 +345,7 @@ class ThemeApiTest( runTest( token = authUtil.defaultUserLogin(), using = { - body(ThemeListRetrieveRequest(themeIds)) + body(ThemeIdListRetrieveResponse(themeIds)) }, on = { post("/themes/retrieve") -- 2.47.2 From 854b3153e115a23451ebb5bb52dbf8e1438bd336 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 15:33:12 +0900 Subject: [PATCH 55/73] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B8=B0=EB=B3=B8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/roomescape/supports/RestAssuredUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index b3118d67..deabfda8 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -85,7 +85,7 @@ class AuthUtil( fun defaultUserLogin(): String = userLogin(UserFixture.default) fun defaultUser(): UserEntity = userRepository.findByEmail(UserFixture.default.email) - ?: throw AssertionError("Unexpected Exception Occurred.") + ?: userRepository.save(UserFixture.default) } fun runTest( -- 2.47.2 From e4a18d0c798c99fd935b6a7e328a516a47064cd2 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 15:33:46 +0900 Subject: [PATCH 56/73] =?UTF-8?q?feat:=20=EC=9E=85=EB=A0=A5=EB=90=9C=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EA=B8=B0=EC=A4=80=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=A3=BC=20=EC=9D=BC=EC=9A=94=EC=9D=BC=EC=9D=84=20=EC=B0=BE?= =?UTF-8?q?=EB=8A=94=20=EC=9C=A0=ED=8B=B8=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/common/util/DateUtils.kt | 11 +++++++++++ .../roomescape/common/util/DateUtilsTest.kt | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/kotlin/roomescape/common/util/DateUtils.kt create mode 100644 src/test/kotlin/roomescape/common/util/DateUtilsTest.kt diff --git a/src/main/kotlin/roomescape/common/util/DateUtils.kt b/src/main/kotlin/roomescape/common/util/DateUtils.kt new file mode 100644 index 00000000..4ad16583 --- /dev/null +++ b/src/main/kotlin/roomescape/common/util/DateUtils.kt @@ -0,0 +1,11 @@ +package roomescape.common.util + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +object DateUtils { + fun getSundayOfPreviousWeek(date: LocalDate): LocalDate = date + .minusWeeks(1) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) +} diff --git a/src/test/kotlin/roomescape/common/util/DateUtilsTest.kt b/src/test/kotlin/roomescape/common/util/DateUtilsTest.kt new file mode 100644 index 00000000..30a2c6b3 --- /dev/null +++ b/src/test/kotlin/roomescape/common/util/DateUtilsTest.kt @@ -0,0 +1,15 @@ +package roomescape.common.util + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +class DateUtilsTest : StringSpec({ + "입력된 날짜의 이전 주 일요일을 찾는다." { + val expected = LocalDate.of(2025, 8, 31) + + for (i in 7..13){ + DateUtils.getSundayOfPreviousWeek(LocalDate.of(2025, 9, i)) shouldBe expected + } + } +}) -- 2.47.2 From c3b736b81f9dc3859b8d985e720e261e60887823 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 15:38:53 +0900 Subject: [PATCH 57/73] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=98=88=EC=95=BD=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=82=BD=EC=9E=85=20=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=9D=BC=EC=A0=95=20/=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/supports/DummyInitializer.kt | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/test/kotlin/roomescape/supports/DummyInitializer.kt b/src/test/kotlin/roomescape/supports/DummyInitializer.kt index 300af25b..4d20cc05 100644 --- a/src/test/kotlin/roomescape/supports/DummyInitializer.kt +++ b/src/test/kotlin/roomescape/supports/DummyInitializer.kt @@ -94,16 +94,26 @@ class DummyInitializer( scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, ): ReservationEntity { - val themeId: Long = createTheme( - adminToken = adminToken, - request = themeRequest - ).id + val themeId: Long = if (scheduleRequest.themeId > 1) { + scheduleRequest.themeId + } else if (reservationRequest.scheduleId > 1) { + scheduleRepository.findByIdOrNull(reservationRequest.scheduleId)!!.themeId + } else { + createTheme( + adminToken = adminToken, + request = themeRequest + ).id + } - val scheduleId: Long = createSchedule( - adminToken = adminToken, - request = scheduleRequest.copy(themeId = themeId), - status = ScheduleStatus.HOLD - ).id + val scheduleId: Long = if (reservationRequest.scheduleId > 1) { + reservationRequest.scheduleId + } else { + createSchedule( + adminToken = adminToken, + request = scheduleRequest.copy(themeId = themeId), + status = ScheduleStatus.HOLD + ).id + } return createPendingReservation( reserverToken = reserverToken, @@ -118,20 +128,30 @@ class DummyInitializer( scheduleRequest: ScheduleCreateRequest = ScheduleFixture.createRequest, reservationRequest: PendingReservationCreateRequest = ReservationFixture.pendingCreateRequest, ): ReservationEntity { - val themeId: Long = createTheme( - adminToken = adminToken, - request = themeRequest - ).id + val themeId: Long = if (scheduleRequest.themeId > 1) { + scheduleRequest.themeId + } else if (reservationRequest.scheduleId > 1) { + scheduleRepository.findByIdOrNull(reservationRequest.scheduleId)!!.themeId + } else { + createTheme( + adminToken = adminToken, + request = themeRequest + ).id + } - val schedule: ScheduleEntity = createSchedule( - adminToken = adminToken, - request = scheduleRequest.copy(themeId = themeId), - status = ScheduleStatus.HOLD - ) + val scheduleId: Long = if (reservationRequest.scheduleId > 1) { + reservationRequest.scheduleId + } else { + createSchedule( + adminToken = adminToken, + request = scheduleRequest.copy(themeId = themeId), + status = ScheduleStatus.HOLD + ).id + } val reservation = createPendingReservation( reserverToken = reserverToken, - request = reservationRequest.copy(scheduleId = schedule.id) + request = reservationRequest.copy(scheduleId = scheduleId) ) Given { -- 2.47.2 From 6eecd145cc6e85097e8ee92d64c0589c96d26c68 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 15:40:36 +0900 Subject: [PATCH 58/73] =?UTF-8?q?feat:=20=EA=B0=80=EC=9E=A5=20=EB=A7=8E?= =?UTF-8?q?=EC=9D=B4=20=EC=98=88=EC=95=BD=EB=90=9C=20=ED=85=8C=EB=A7=88=20?= =?UTF-8?q?ID=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 19 +++++++++++++++++++ .../reservation/docs/ReservationAPI.kt | 9 +++++++++ .../persistence/ReservationRepository.kt | 19 +++++++++++++++++++ .../reservation/web/ReservationController.kt | 9 +++++++++ .../reservation/web/ReservationDto.kt | 6 +++++- 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index e7d20335..653317a7 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional import roomescape.common.config.next import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.PrincipalType +import roomescape.common.util.DateUtils import roomescape.member.business.UserService import roomescape.member.web.UserContactRetrieveResponse import roomescape.payment.business.PaymentService @@ -23,6 +24,7 @@ import roomescape.schedule.web.ScheduleSummaryResponse import roomescape.schedule.web.ScheduleUpdateRequest import roomescape.theme.business.ThemeService import roomescape.theme.web.ThemeInfoRetrieveResponse +import java.time.LocalDate import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -126,6 +128,23 @@ class ReservationService( } } + @Transactional(readOnly = true) + fun findMostReservedThemeIds(count: Int): MostReservedThemeIdListResponse { + log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 시작: count=$count" } + val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()) + val previousWeekSaturday = previousWeekSunday.plusDays(6) + + val themeIds: List = reservationRepository.findMostReservedThemeIds( + dateFrom = previousWeekSunday, + dateTo = previousWeekSaturday, + count = count + ) + + return MostReservedThemeIdListResponse(themeIds = themeIds).also { + log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 완료: count=${it.themeIds.size}" } + } + } + private fun findOrThrow(id: Long): ReservationEntity { log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" } diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index 262f7329..ba331c45 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -7,8 +7,10 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam import roomescape.auth.web.support.Authenticated import roomescape.auth.web.support.CurrentUser +import roomescape.auth.web.support.Public import roomescape.auth.web.support.UserOnly import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.response.CommonApiResponse @@ -16,6 +18,13 @@ import roomescape.reservation.web.* interface ReservationAPI { + @Public + @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findMostReservedThemeIds( + @RequestParam count: Int + ): ResponseEntity> + @UserOnly @Operation(summary = "결제 대기 예약 저장", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index aba5f37f..d3417f45 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -1,8 +1,27 @@ package roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDate interface ReservationRepository : JpaRepository { fun findAllByUserId(userId: Long): List + + @Query(""" + SELECT s.themeId + FROM ReservationEntity r + JOIN ScheduleEntity s ON s._id = r.scheduleId + WHERE r.status = roomescape.reservation.infrastructure.persistence.ReservationStatus.CONFIRMED + AND s.date BETWEEN :dateFrom AND :dateTo + GROUP BY s.themeId + ORDER BY count(r) DESC + LIMIT :count + """) + fun findMostReservedThemeIds( + @Param("dateFrom") dateFrom: LocalDate, + @Param("dateTo") dateTo: LocalDate, + @Param("count") count: Int + ): List } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index ae0652cb..5ef79e71 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -14,6 +14,15 @@ class ReservationController( private val reservationService: ReservationService ) : ReservationAPI { + @GetMapping("/reservations/popular-themes") + override fun findMostReservedThemeIds( + @RequestParam count: Int + ): ResponseEntity> { + val response = reservationService.findMostReservedThemeIds(count) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + @PostMapping("/reservations/pending") override fun createPendingReservation( @CurrentUser user: CurrentUserContext, diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt index beeadf1f..c7f6ff92 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationDto.kt @@ -67,4 +67,8 @@ fun ReservationEntity.toReservationDetailRetrieveResponse( data class ReservationCancelRequest( val cancelReason: String -) \ No newline at end of file +) + +data class MostReservedThemeIdListResponse( + val themeIds: List +) -- 2.47.2 From 905c4b701927df398e56c2a4cc64e1683f86f345 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 15:40:42 +0900 Subject: [PATCH 59/73] =?UTF-8?q?test:=20=EA=B0=80=EC=9E=A5=20=EB=A7=8E?= =?UTF-8?q?=EC=9D=B4=20=EC=98=88=EC=95=BD=EB=90=9C=20=ED=85=8C=EB=A7=88=20?= =?UTF-8?q?ID=20=EC=A1=B0=ED=9A=8C=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ReservationApiTest.kt | 118 +++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 626b54b5..ca8d073b 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -6,7 +6,10 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.auth.exception.AuthErrorCode +import roomescape.common.config.next import roomescape.common.exception.CommonErrorCode +import roomescape.common.util.DateUtils +import roomescape.member.infrastructure.persistence.UserEntity import roomescape.payment.exception.PaymentErrorCode import roomescape.payment.infrastructure.common.BankCode import roomescape.payment.infrastructure.common.CardIssuerCode @@ -17,18 +20,22 @@ import roomescape.reservation.infrastructure.persistence.CanceledReservationRepo import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.MostReservedThemeIdListResponse import roomescape.reservation.web.ReservationCancelRequest import roomescape.schedule.infrastructure.persistence.ScheduleEntity import roomescape.schedule.infrastructure.persistence.ScheduleRepository import roomescape.schedule.infrastructure.persistence.ScheduleStatus -import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.supports.* +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.theme.web.toEntity import java.time.LocalDate import java.time.LocalTime class ReservationApiTest( private val reservationRepository: ReservationRepository, private val canceledReservationRepository: CanceledReservationRepository, + private val themeRepository: ThemeRepository, private val scheduleRepository: ScheduleRepository, private val paymentDetailRepository: PaymentDetailRepository, ) : FunSpecSpringbootTest() { @@ -299,7 +306,8 @@ class ReservationApiTest( reserverToken = authUtil.defaultUserLogin(), ) - val otherUserToken = authUtil.userLogin(UserFixture.createUser(email = "test@test.com", phone="01011111111")) + val otherUserToken = + authUtil.userLogin(UserFixture.createUser(email = "test@test.com", phone = "01011111111")) runExceptionTest( token = otherUserToken, @@ -602,6 +610,24 @@ class ReservationApiTest( ) } } + + context("가장 많이 예약된 테마 ID를 조회한다.") { + test("정상 응답") { + val expectedResult: MostReservedThemeIdListResponse = initializeForPopularThemeTest() + + runTest( + on = { + get("/reservations/popular-themes?count=10") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val result: List = it.extract().path("data.themeIds") + result shouldBe expectedResult.themeIds + } + } + } } fun runDetailRetrieveTest( @@ -621,4 +647,92 @@ class ReservationApiTest( it.extract().path("data.user.id") shouldBe reservation.userId }.extract().path("data.payment") } + + private fun initializeForPopularThemeTest(): MostReservedThemeIdListResponse { + val user: UserEntity = authUtil.defaultUser() + + val themeIds: List = (1..5).map { + themeRepository.save( + ThemeFixture.createRequest.copy(name = "theme-$it").toEntity(id = tsidFactory.next()) + ).id + } + + // 첫 번째 테마: 유효한 2개 예약 + (1L..2L).forEach { + createScheduleAndReservation( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[0], + userId = user.id, + ) + } + + // 두 번째 테마: 유효한 1개 예약 + createScheduleAndReservation( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()), + themeId = themeIds[1], + userId = user.id, + ) + + // 세 번째 테마: 유효한 3개 예약 + (1L..3L).forEach { + createScheduleAndReservation( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[2], + userId = user.id, + ) + } + + // 네 번째 테마: Pending 상태인 3개 예약 -> 집계되지 않음. + (1L..3L).forEach { + createScheduleAndReservation( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[3], + userId = user.id, + isPending = true + ) + } + + // 다섯 번째 테마: 이번주의 확정 예약 -> 집계되지 않음. + (1L..3L).forEach { i -> + val thisMonday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(8) + createScheduleAndReservation( + date = thisMonday.plusDays(i), + themeId = themeIds[4], + userId = user.id, + ) + } + + // 조회 예상 결과: 세번째, 첫번째, 두번째 테마 순서 + return MostReservedThemeIdListResponse(listOf(themeIds[2], themeIds[0], themeIds[1])) + } + + private fun createScheduleAndReservation( + date: LocalDate, + themeId: Long, + userId: Long, + isPending: Boolean = false + ) { + val schedule = ScheduleEntity( + id = tsidFactory.next(), + date = date, + time = LocalTime.now(), + themeId = themeId, + status = if (isPending) ScheduleStatus.HOLD else ScheduleStatus.RESERVED + ).also { + scheduleRepository.save(it) + } + + ReservationEntity( + id = tsidFactory.next(), + userId = userId, + scheduleId = schedule.id, + reserverName = "이상돌", + reserverContact = "01012345678", + participantCount = 4, + requirement = "잘부탁드려요!", + status = if (isPending) ReservationStatus.PENDING else ReservationStatus.CONFIRMED, + ).also { + reservationRepository.save(it) + } + } } -- 2.47.2 From 1d41d517b1485020df7f307708922fed50a66668 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 16:04:58 +0900 Subject: [PATCH 60/73] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20&=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EB=8A=94=20?= =?UTF-8?q?Public=EC=9C=BC=EB=A1=9C=20=EA=B6=8C=ED=95=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/theme/docs/ThemeApi.kt | 10 ++--- .../kotlin/roomescape/theme/ThemeApiTest.kt | 44 ------------------- 2 files changed, 5 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index e11e4dfd..ef83799b 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly -import roomescape.auth.web.support.UserOnly +import roomescape.auth.web.support.Public import roomescape.common.dto.response.CommonApiResponse import roomescape.theme.web.* @@ -45,13 +45,13 @@ interface ThemeAPIV2 { @Valid @RequestBody themeUpdateRequest: ThemeUpdateRequest ): ResponseEntity> - @UserOnly - @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) + @Public + @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findUserThemes(): ResponseEntity> - @UserOnly - @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) + @Public + @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findThemesByIds(request: ThemeIdListRetrieveResponse): ResponseEntity> } diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 7e7799ba..fb53975c 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -283,29 +283,6 @@ class ThemeApiTest( } context("입력된 모든 ID에 대한 테마를 조회한다.") { - val endpoint = "/themes/retrieve" - - context("권한이 없으면 접근할 수 없다.") { - test("비회원") { - runExceptionTest( - method = HttpMethod.POST, - requestBody = ThemeIdListRetrieveResponse(themeIds = listOf()), - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND - ) - } - - test("관리자") { - runExceptionTest( - token = authUtil.defaultAdminLogin(), - method = HttpMethod.POST, - requestBody = ThemeIdListRetrieveResponse(themeIds = listOf()), - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.ACCESS_DENIED - ) - } - } - test("정상 응답") { val adminToken = authUtil.defaultAdminLogin() val themeSize = 3 @@ -406,27 +383,6 @@ class ThemeApiTest( } context("예약 페이지에서 테마를 조회한다.") { - val endpoint = "/v2/themes" - - context("권한이 없으면 접근할 수 없다.") { - test("비회원") { - runExceptionTest( - method = HttpMethod.GET, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.TOKEN_NOT_FOUND - ) - } - - test("관리자") { - runExceptionTest( - token = authUtil.defaultAdminLogin(), - method = HttpMethod.GET, - endpoint = endpoint, - expectedErrorCode = AuthErrorCode.ACCESS_DENIED - ) - } - } - test("공개된 테마의 전체 정보가 조회된다.") { val token = authUtil.defaultAdminLogin() listOf( -- 2.47.2 From e1941052f919d4854e0b7acba9cd6dc0c4a8fa11 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 16:05:21 +0900 Subject: [PATCH 61/73] =?UTF-8?q?refactor:=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=20=EC=A1=B0=ED=9A=8C=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/api/reservation/reservationAPIV2.ts | 12 ++- .../src/api/reservation/reservationTypesV2.ts | 8 +- frontend/src/css/home-page-v2.css | 99 +++++++++++++++++++ frontend/src/pages/v2/HomePageV2.tsx | 69 +++++++++++-- 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/frontend/src/api/reservation/reservationAPIV2.ts b/frontend/src/api/reservation/reservationAPIV2.ts index 01992108..9bb14321 100644 --- a/frontend/src/api/reservation/reservationAPIV2.ts +++ b/frontend/src/api/reservation/reservationAPIV2.ts @@ -1,5 +1,11 @@ import apiClient from '../apiClient'; -import type { PendingReservationCreateRequest, PendingReservationCreateResponse, ReservationDetailRetrieveResponse, ReservationSummaryRetrieveListResponse } from './reservationTypesV2'; +import type { + MostReservedThemeIdListResponse, + PendingReservationCreateRequest, + PendingReservationCreateResponse, + ReservationDetailRetrieveResponse, + ReservationSummaryRetrieveListResponse +} from './reservationTypesV2'; export const createPendingReservation = async (request: PendingReservationCreateRequest): Promise => { return await apiClient.post('/reservations/pending', request); @@ -21,3 +27,7 @@ export const fetchSummaryByMember = async (): Promise => { return await apiClient.get(`/reservations/${reservationId}/detail`); } + +export const fetchMostReservedThemeIds = async (count: number = 10): Promise => { + return await apiClient.get(`/reservations/popular-themes?count=${count}`, false); +} \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationTypesV2.ts b/frontend/src/api/reservation/reservationTypesV2.ts index 3dc595fa..e028fe4c 100644 --- a/frontend/src/api/reservation/reservationTypesV2.ts +++ b/frontend/src/api/reservation/reservationTypesV2.ts @@ -1,5 +1,5 @@ -import type { MemberSummaryRetrieveResponse } from "@_api/member/memberTypes"; -import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes"; +import type {MemberSummaryRetrieveResponse} from "@_api/member/memberTypes"; +import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; export const ReservationStatusV2 = { PENDING: 'PENDING', @@ -56,3 +56,7 @@ export interface ReservationDetail { applicationDateTime: string; payment: PaymentRetrieveResponse; } + +export interface MostReservedThemeIdListResponse { + themeIds: string[]; +} \ No newline at end of file diff --git a/frontend/src/css/home-page-v2.css b/frontend/src/css/home-page-v2.css index d4b839a2..2728cb68 100644 --- a/frontend/src/css/home-page-v2.css +++ b/frontend/src/css/home-page-v2.css @@ -31,6 +31,7 @@ gap: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + cursor: pointer; } .theme-ranking-item-v2:hover { @@ -64,3 +65,101 @@ color: #505a67; margin: 0; } + +/* Modal Styles */ +.theme-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.theme-modal-content { + background-color: #ffffff; + padding: 30px; + border-radius: 16px; + width: 90%; + max-width: 600px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + gap: 20px; +} + +.modal-thumbnail { + width: 100%; + height: 250px; + object-fit: cover; + border-radius: 12px; +} + +.modal-theme-info h2 { + font-size: 26px; + font-weight: 700; + color: #333d4b; + margin: 0 0 10px; +} + +.modal-theme-info p { + font-size: 16px; + color: #505a67; + line-height: 1.6; + margin: 0 0 15px; +} + +.theme-details { + background-color: #f8f9fa; + border-radius: 8px; + padding: 15px; +} + +.theme-details p { + margin: 5px 0; + font-size: 15px; + color: #333d4b; +} + +.theme-details strong { + color: #191919; +} + +.modal-buttons { + display: flex; + gap: 15px; + margin-top: 15px; +} + +.modal-button { + flex: 1; + padding: 12px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.modal-button.reserve { + background-color: #007bff; + color: white; +} + +.modal-button.reserve:hover { + background-color: #0056b3; +} + +.modal-button.close { + background-color: #6c757d; + color: white; +} + +.modal-button.close:hover { + background-color: #5a6268; +} diff --git a/frontend/src/pages/v2/HomePageV2.tsx b/frontend/src/pages/v2/HomePageV2.tsx index bad52d27..ed33b1ea 100644 --- a/frontend/src/pages/v2/HomePageV2.tsx +++ b/frontend/src/pages/v2/HomePageV2.tsx @@ -1,14 +1,31 @@ -import React, { useEffect, useState } from 'react'; -import { mostReservedThemes } from '../../api/theme/themeAPI'; -import '../../css/home-page-v2.css'; +import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPIV2'; +import '@_css/home-page-v2.css'; +import React, {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {findThemesByIds} from '../../api/theme/themeAPI'; +import {type UserThemeRetrieveResponse} from '../../api/theme/themeTypes'; const HomePageV2: React.FC = () => { - const [ranking, setRanking] = useState([]); + const [ranking, setRanking] = useState([]); + const [selectedTheme, setSelectedTheme] = useState(null); + const navigate = useNavigate(); useEffect(() => { const fetchData = async () => { try { - const response = await mostReservedThemes(10); + const themeIds = await fetchMostReservedThemeIds().then(res => { + const themeIds = res.themeIds; + if (themeIds.length === 0) { + setRanking([]); + return; + } + return themeIds; + }) + + if (themeIds === undefined) return; + if (themeIds.length === 0) return; + + const response = await findThemesByIds({ themeIds: themeIds }); setRanking(response.themes); } catch (err) { console.error('Error fetching ranking:', err); @@ -18,20 +35,56 @@ const HomePageV2: React.FC = () => { fetchData(); }, []); + const handleThemeClick = (theme: UserThemeRetrieveResponse) => { + setSelectedTheme(theme); + }; + + const handleCloseModal = () => { + setSelectedTheme(null); + }; + + const handleReservationClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (selectedTheme) { + navigate('/v2-1/reservation', { state: { themeId: selectedTheme.id } }); + } + }; + return (

인기 테마

{ranking.map(theme => ( -
- {theme.name} +
handleThemeClick(theme)}> + {theme.name}
{theme.name}
-

{theme.description}

))}
+ + {selectedTheme && ( +
+
e.stopPropagation()}> + {selectedTheme.name} +
+

{selectedTheme.name}

+

{selectedTheme.description}

+
+

난이도: {selectedTheme.difficulty}

+

가격: {selectedTheme.price.toLocaleString()}원

+

예상 시간: {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

+

이용 가능 인원: {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

+
+
+
+ + +
+
+
+ )}
); }; -- 2.47.2 From b0f67543be61b424a4ff7b56c816e78933279b4f Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 16:54:17 +0900 Subject: [PATCH 62/73] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/user/userAPI.ts | 6 ++ frontend/src/api/user/userTypes.ts | 27 +++++ frontend/src/css/signup-page-v2.css | 6 ++ frontend/src/pages/SignupPage.tsx | 160 +++++++++++++++++++++++----- 4 files changed, 171 insertions(+), 28 deletions(-) create mode 100644 frontend/src/api/user/userAPI.ts create mode 100644 frontend/src/api/user/userTypes.ts diff --git a/frontend/src/api/user/userAPI.ts b/frontend/src/api/user/userAPI.ts new file mode 100644 index 00000000..20b14d56 --- /dev/null +++ b/frontend/src/api/user/userAPI.ts @@ -0,0 +1,6 @@ +import apiClient from "@_api/apiClient"; +import type { UserCreateRequest, UserCreateResponse } from "./userTypes"; + +export const signup = async (data: UserCreateRequest): Promise => { + return await apiClient.post('/users', data, false); +}; \ No newline at end of file diff --git a/frontend/src/api/user/userTypes.ts b/frontend/src/api/user/userTypes.ts new file mode 100644 index 00000000..94804f51 --- /dev/null +++ b/frontend/src/api/user/userTypes.ts @@ -0,0 +1,27 @@ +export interface UserCreateRequest { + /** not empty */ + name: string; + + /** not empty, email format */ + email: string; + + /** length >= 8 */ + password: string; + + /** not empty, pattern: ^010([0-9]{3,4})([0-9]{4})$ */ + phone: string; + + /** nullable */ + regionCode?: string | null; +} + +export interface UserCreateResponse { + id: number; + name: string; +} + +export interface UserContactRetrieveResponse { + id: number; + name: string; + phone: string; +} diff --git a/frontend/src/css/signup-page-v2.css b/frontend/src/css/signup-page-v2.css index 1fc04326..7918185b 100644 --- a/frontend/src/css/signup-page-v2.css +++ b/frontend/src/css/signup-page-v2.css @@ -63,3 +63,9 @@ .signup-form-v2 .btn-primary:hover { background-color: #1B64DA; } + +.error-text { + color: #E53E3E; + font-size: 12px; + margin-top: 4px; +} \ No newline at end of file diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index ed2c208d..0ac27557 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -1,42 +1,146 @@ -import React, { useState } from 'react'; +import { signup } from '@_api/user/userAPI'; +import type { UserCreateRequest, UserCreateResponse } from '@_api/user/userTypes'; +import '@_css/signup-page-v2.css'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { signup } from '@_api/member/memberAPI'; -import type { SignupRequest } from '@_api/member/memberTypes'; + +const MIN_PASSWORD_LENGTH = 8; const SignupPage: React.FC = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [errors, setErrors] = useState>({}); + const [hasSubmitted, setHasSubmitted] = useState(false); + const navigate = useNavigate(); - const handleSignup = async () => { - const request: SignupRequest = { email, password, name }; - await signup(request) - .then((response) => { - alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`); - navigate('/login') - }) - .catch(error => { - console.error(error); - }); + const validate = () => { + const newErrors: Record = {}; + + if (!name.trim()) { + newErrors.name = '이름을 입력해주세요.'; + } + if (!email.trim()) { + newErrors.email = '이메일을 입력해주세요.'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + newErrors.email = '올바른 이메일 형식을 입력해주세요.'; + } + if (password.length < MIN_PASSWORD_LENGTH) { + newErrors.password = `비밀번호는 최소 ${MIN_PASSWORD_LENGTH}자리 이상이어야 합니다.`; + } + if (!phone.trim()) { + newErrors.phone = '전화번호를 입력해주세요.'; + } else if (!/^010([0-9]{3,4})([0-9]{4})$/.test(phone)) { + newErrors.phone = '올바른 휴대폰 번호 형식이 아닙니다. (예: 01012345678)'; + } + + return newErrors; + }; + + // 제출 이후에는 입력값이 바뀔 때마다 다시 validate 실행 + useEffect(() => { + if (hasSubmitted) { + setErrors(validate()); + } + }, [email, password, name, phone, hasSubmitted]); + + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault(); + setHasSubmitted(true); + + const newErrors = validate(); + setErrors(newErrors); + + if (Object.keys(newErrors).length > 0) return; + + const request: UserCreateRequest = { email, password, name, phone, regionCode: null }; + try { + const response: UserCreateResponse = await signup(request); + alert(`${response.name}님, 회원가입을 축하드려요. 로그인 후 이용해주세요!`); + navigate('/login'); + } catch (error: any) { + const message = + error.response?.data?.message || + '회원가입에 실패했어요. 입력 정보를 확인해주세요.'; + alert(message); + console.error(error); + } }; return ( -
-

Signup

-
- - setEmail(e.target.value)} /> -
-
- - setPassword(e.target.value)} /> -
-
- - setName(e.target.value)} /> -
- +
+

회원가입

+
+
+ + setEmail(e.target.value)} + required + /> + {hasSubmitted && errors.email && ( +

{errors.email}

+ )} +
+ +
+ + setPassword(e.target.value)} + required + /> + {hasSubmitted && errors.password && ( +

{errors.password}

+ )} +
+ +
+ + setName(e.target.value)} + required + /> + {hasSubmitted && errors.name && ( +

{errors.name}

+ )} +
+ +
+ + setPhone(e.target.value)} + required + /> + {hasSubmitted && errors.phone && ( +

{errors.phone}

+ )} +
+ + +
); }; -- 2.47.2 From 041e8d157d98297f7718704e7e27c818e27d1ec0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 16:55:52 +0900 Subject: [PATCH 63/73] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20DTO=EC=97=90=EC=84=9C=20V2=20=EC=A0=91?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/auth/business/AuthService.kt | 8 ++++---- src/main/kotlin/roomescape/auth/docs/AuthAPI.kt | 4 ++-- .../kotlin/roomescape/auth/web/AuthController.kt | 2 +- src/main/kotlin/roomescape/auth/web/AuthDTO.kt | 2 +- src/test/kotlin/roomescape/auth/AuthApiTest.kt | 12 ++++++------ .../roomescape/auth/FailOnSaveLoginHistoryTest.kt | 6 +++--- .../kotlin/roomescape/supports/RestAssuredUtils.kt | 6 +++--- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index c4548244..a1fdac4c 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -9,7 +9,7 @@ import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.web.LoginContext -import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginSuccessResponse import roomescape.common.dto.CurrentUserContext import roomescape.common.dto.LoginCredentials @@ -30,7 +30,7 @@ class AuthService( ) { @Transactional(readOnly = true) fun login( - request: LoginRequestV2, + request: LoginRequest, context: LoginContext ): LoginSuccessResponse { log.info { "[AuthService.login] 로그인 시작: account=${request.account}, type=${request.principalType}, context=${context}" } @@ -83,7 +83,7 @@ class AuthService( } private fun verifyPasswordOrThrow( - request: LoginRequestV2, + request: LoginRequest, credentials: LoginCredentials ) { if (credentials.password != request.password) { @@ -92,7 +92,7 @@ class AuthService( } } - private fun getCredentials(request: LoginRequestV2): Pair> { + private fun getCredentials(request: LoginRequest): Pair> { val extraClaims: MutableMap = mutableMapOf() val credentials: LoginCredentials = when (request.principalType) { PrincipalType.ADMIN -> { diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt index 3eea8f6a..fe80bfc9 100644 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt @@ -9,7 +9,7 @@ import jakarta.servlet.http.HttpServletResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody -import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginSuccessResponse import roomescape.auth.web.support.CurrentUser import roomescape.auth.web.support.Public @@ -25,7 +25,7 @@ interface AuthAPI { ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), ) fun login( - @Valid @RequestBody loginRequest: LoginRequestV2, + @Valid @RequestBody loginRequest: LoginRequest, servletRequest: HttpServletRequest ): ResponseEntity> diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index 7c6abc44..3ecd6973 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -21,7 +21,7 @@ class AuthController( @PostMapping("/login") override fun login( - loginRequest: LoginRequestV2, + loginRequest: LoginRequest, servletRequest: HttpServletRequest ): ResponseEntity> { val response = authService.login(request = loginRequest, context = servletRequest.toLoginContext()) diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt index 4bf06274..03eef491 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt @@ -13,7 +13,7 @@ fun HttpServletRequest.toLoginContext() = LoginContext( userAgent = this.getHeader("User-Agent") ) -data class LoginRequestV2( +data class LoginRequest( val account: String, val password: String, val principalType: PrincipalType diff --git a/src/test/kotlin/roomescape/auth/AuthApiTest.kt b/src/test/kotlin/roomescape/auth/AuthApiTest.kt index 0f0f46b5..7a04b219 100644 --- a/src/test/kotlin/roomescape/auth/AuthApiTest.kt +++ b/src/test/kotlin/roomescape/auth/AuthApiTest.kt @@ -14,7 +14,7 @@ import roomescape.auth.business.CLAIM_PERMISSION_KEY import roomescape.auth.exception.AuthErrorCode import roomescape.auth.infrastructure.jwt.JwtUtils import roomescape.auth.infrastructure.persistence.LoginHistoryRepository -import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginRequest import roomescape.common.dto.PrincipalType import roomescape.member.exception.UserErrorCode import roomescape.member.infrastructure.persistence.UserEntity @@ -63,7 +63,7 @@ class AuthApiTest( context("실패 응답") { test("비밀번호가 틀린 경우") { val admin = authUtil.createAdmin(AdminFixture.default) - val request = LoginRequestV2(admin.account, "wrong_password", PrincipalType.ADMIN) + val request = LoginRequest(admin.account, "wrong_password", PrincipalType.ADMIN) runTest( using = { @@ -86,7 +86,7 @@ class AuthApiTest( test("토큰 생성 과정에서 오류가 발생하는 경우") { val admin = authUtil.createAdmin(AdminFixture.default) - val request = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) + val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) every { jwtUtils.createToken(any(), any()) @@ -118,7 +118,7 @@ class AuthApiTest( it shouldNotBe user.email } - val request = LoginRequestV2(invalidEmail, user.password, PrincipalType.USER) + val request = LoginRequest(invalidEmail, user.password, PrincipalType.USER) runTest( using = { @@ -142,7 +142,7 @@ class AuthApiTest( it shouldNotBe admin.account } - val request = LoginRequestV2(invalidAccount, admin.password, PrincipalType.ADMIN) + val request = LoginRequest(invalidAccount, admin.password, PrincipalType.ADMIN) runTest( using = { @@ -204,7 +204,7 @@ class AuthApiTest( type: PrincipalType, extraAssertions: ((ValidatableResponse) -> Unit)? = null ) { - val request = LoginRequestV2(account, password, type) + val request = LoginRequest(account, password, type) runTest( using = { diff --git a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt index 205e90ac..da8a9aa6 100644 --- a/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt +++ b/src/test/kotlin/roomescape/auth/FailOnSaveLoginHistoryTest.kt @@ -5,7 +5,7 @@ import io.mockk.clearMocks import io.mockk.every import org.springframework.http.HttpStatus import roomescape.auth.infrastructure.persistence.LoginHistoryRepository -import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginRequest import roomescape.common.dto.PrincipalType import roomescape.supports.AdminFixture import roomescape.supports.FunSpecSpringbootTest @@ -28,7 +28,7 @@ class FailOnSaveLoginHistoryTest( test("회원") { val user = authUtil.signup(UserFixture.createRequest) - val request = LoginRequestV2(user.email, user.password, PrincipalType.USER) + val request = LoginRequest(user.email, user.password, PrincipalType.USER) runTest( using = { @@ -45,7 +45,7 @@ class FailOnSaveLoginHistoryTest( test("관리자") { val admin = authUtil.createAdmin(AdminFixture.default) - val request = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) + val request = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) runTest( using = { diff --git a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt index deabfda8..c3c3c766 100644 --- a/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/supports/RestAssuredUtils.kt @@ -14,7 +14,7 @@ import org.springframework.http.HttpStatus import org.springframework.http.MediaType import roomescape.admin.infrastructure.persistence.AdminEntity import roomescape.admin.infrastructure.persistence.AdminRepository -import roomescape.auth.web.LoginRequestV2 +import roomescape.auth.web.LoginRequest import roomescape.common.dto.PrincipalType import roomescape.common.exception.ErrorCode import roomescape.member.infrastructure.persistence.UserEntity @@ -49,7 +49,7 @@ class AuthUtil( if (adminRepository.findByAccount(admin.account) == null) { adminRepository.save(admin) } - val requestBody = LoginRequestV2(admin.account, admin.password, PrincipalType.ADMIN) + val requestBody = LoginRequest(admin.account, admin.password, PrincipalType.ADMIN) return Given { contentType(MediaType.APPLICATION_JSON_VALUE) @@ -72,7 +72,7 @@ class AuthUtil( return Given { contentType(MediaType.APPLICATION_JSON_VALUE) - body(LoginRequestV2(account = user.email, password = user.password, principalType = PrincipalType.USER)) + body(LoginRequest(account = user.email, password = user.password, principalType = PrincipalType.USER)) } When { post("/auth/login") } Then { -- 2.47.2 From 24dd2c492fcb80e817e47ac377757fa4b2c93d51 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 13 Sep 2025 18:14:44 +0900 Subject: [PATCH 64/73] =?UTF-8?q?refactor:=20=EC=83=88=EB=A1=9C=EC=9A=B4?= =?UTF-8?q?=20API=20=EB=AA=85=EC=84=B8=EC=97=90=20=EB=A7=9E=EC=B6=98=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/auth/authAPI.ts | 12 +- frontend/src/api/auth/authTypes.ts | 18 +- frontend/src/api/member/memberAPI.ts | 10 - frontend/src/api/member/memberTypes.ts | 25 - frontend/src/api/payment/PaymentTypes.ts | 4 +- .../src/api/reservation/reservationAPI.ts | 98 ---- .../src/api/reservation/reservationTypes.ts | 135 ------ .../src/api/reservation/reservationTypesV2.ts | 10 +- frontend/src/api/theme/themeTypes.ts | 17 +- frontend/src/api/time/timeAPI.ts | 18 - frontend/src/api/time/timeTypes.ts | 27 -- frontend/src/api/user/userAPI.ts | 8 +- frontend/src/api/user/userTypes.ts | 9 +- frontend/src/components/Navbar.tsx | 8 +- frontend/src/context/AuthContext.tsx | 23 +- frontend/src/css/my-reservation-v2.css | 74 ++- frontend/src/pages/HomePage.tsx | 89 +++- frontend/src/pages/LoginPage.tsx | 54 ++- frontend/src/pages/MyReservationPage.tsx | 430 +++++++++++++++--- .../pages/{v2 => }/ReservationFormPage.tsx | 47 +- frontend/src/pages/ReservationPage.tsx | 198 -------- ...p1PageV21.tsx => ReservationStep1Page.tsx} | 72 ++- ...p2PageV21.tsx => ReservationStep2Page.tsx} | 8 +- ...PageV21.tsx => ReservationSuccessPage.tsx} | 6 +- frontend/src/pages/admin/ReservationPage.tsx | 204 --------- frontend/src/pages/admin/ThemePage.tsx | 82 ---- frontend/src/pages/admin/TimePage.tsx | 123 ----- frontend/src/pages/admin/WaitingPage.tsx | 89 ---- frontend/src/pages/v2/HomePageV2.tsx | 92 ---- frontend/src/pages/v2/LoginPageV2.tsx | 63 --- frontend/src/pages/v2/MyReservationPageV2.tsx | 342 -------------- .../src/pages/v2/ReservationStep1Page.tsx | 156 ------- .../src/pages/v2/ReservationStep2Page.tsx | 120 ----- .../src/pages/v2/ReservationSuccessPage.tsx | 44 -- frontend/src/pages/v2/SignupPageV2.tsx | 70 --- frontend/tsconfig.app.json | 1 + .../payment/business/PaymentService.kt | 54 ++- 37 files changed, 720 insertions(+), 2120 deletions(-) delete mode 100644 frontend/src/api/member/memberAPI.ts delete mode 100644 frontend/src/api/member/memberTypes.ts delete mode 100644 frontend/src/api/reservation/reservationAPI.ts delete mode 100644 frontend/src/api/reservation/reservationTypes.ts delete mode 100644 frontend/src/api/time/timeAPI.ts delete mode 100644 frontend/src/api/time/timeTypes.ts rename frontend/src/pages/{v2 => }/ReservationFormPage.tsx (72%) delete mode 100644 frontend/src/pages/ReservationPage.tsx rename frontend/src/pages/{v2/ReservationStep1PageV21.tsx => ReservationStep1Page.tsx} (88%) rename frontend/src/pages/{v2/ReservationStep2PageV21.tsx => ReservationStep2Page.tsx} (96%) rename frontend/src/pages/{v2/ReservationSuccessPageV21.tsx => ReservationSuccessPage.tsx} (88%) delete mode 100644 frontend/src/pages/admin/ReservationPage.tsx delete mode 100644 frontend/src/pages/admin/ThemePage.tsx delete mode 100644 frontend/src/pages/admin/TimePage.tsx delete mode 100644 frontend/src/pages/admin/WaitingPage.tsx delete mode 100644 frontend/src/pages/v2/HomePageV2.tsx delete mode 100644 frontend/src/pages/v2/LoginPageV2.tsx delete mode 100644 frontend/src/pages/v2/MyReservationPageV2.tsx delete mode 100644 frontend/src/pages/v2/ReservationStep1Page.tsx delete mode 100644 frontend/src/pages/v2/ReservationStep2Page.tsx delete mode 100644 frontend/src/pages/v2/ReservationSuccessPage.tsx delete mode 100644 frontend/src/pages/v2/SignupPageV2.tsx diff --git a/frontend/src/api/auth/authAPI.ts b/frontend/src/api/auth/authAPI.ts index a9f34dfd..30c9b215 100644 --- a/frontend/src/api/auth/authAPI.ts +++ b/frontend/src/api/auth/authAPI.ts @@ -1,19 +1,19 @@ import apiClient from '@_api/apiClient'; -import type { LoginRequest, LoginResponse, LoginCheckResponse } from './authTypes'; +import type { CurrentUserContext, LoginRequest, LoginSuccessResponse } from './authTypes'; -export const login = async (data: LoginRequest): Promise => { - const response = await apiClient.post('/login', data, false); +export const login = async (data: LoginRequest): Promise => { + const response = await apiClient.post('/auth/login', data, false); localStorage.setItem('accessToken', response.accessToken); return response; }; -export const checkLogin = async (): Promise => { - return await apiClient.get('/login/check', true); +export const checkLogin = async (): Promise => { + return await apiClient.get('/auth/login/check', true); }; export const logout = async (): Promise => { - await apiClient.post('/logout', {}, true); + await apiClient.post('/auth/logout', {}, true); localStorage.removeItem('accessToken'); }; diff --git a/frontend/src/api/auth/authTypes.ts b/frontend/src/api/auth/authTypes.ts index 6889425f..426168c4 100644 --- a/frontend/src/api/auth/authTypes.ts +++ b/frontend/src/api/auth/authTypes.ts @@ -1,14 +1,22 @@ +export const PrincipalType = { + ADMIN: 'ADMIN', + USER: 'USER', +} as const; + +export type PrincipalType = typeof PrincipalType[keyof typeof PrincipalType]; + export interface LoginRequest { - email: string; + account: string, password: string; + principalType: PrincipalType; } -export interface LoginResponse { +export interface LoginSuccessResponse { accessToken: string; } -export interface LoginCheckResponse { +export interface CurrentUserContext { + id: string; name: string; - role: 'ADMIN' | 'MEMBER'; + type: PrincipalType; } - diff --git a/frontend/src/api/member/memberAPI.ts b/frontend/src/api/member/memberAPI.ts deleted file mode 100644 index 219e7f3a..00000000 --- a/frontend/src/api/member/memberAPI.ts +++ /dev/null @@ -1,10 +0,0 @@ -import apiClient from "@_api/apiClient"; -import type { MemberRetrieveListResponse, SignupRequest, SignupResponse } from "./memberTypes"; - -export const fetchMembers = async (): Promise => { - return await apiClient.get('/members', true); -}; - -export const signup = async (data: SignupRequest): Promise => { - return await apiClient.post('/members', data, false); -}; diff --git a/frontend/src/api/member/memberTypes.ts b/frontend/src/api/member/memberTypes.ts deleted file mode 100644 index 6dc36555..00000000 --- a/frontend/src/api/member/memberTypes.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface MemberRetrieveResponse { - id: string; - name: string; -} - -export interface MemberRetrieveListResponse { - members: MemberRetrieveResponse[]; -} - -export interface SignupRequest { - email: string; - password: string; - name: string; -} - -export interface SignupResponse { - id: string; - name: string; -} - -export interface MemberSummaryRetrieveResponse { - id: string; - name: string; - email: string; -} diff --git a/frontend/src/api/payment/PaymentTypes.ts b/frontend/src/api/payment/PaymentTypes.ts index 93b015df..c35958ba 100644 --- a/frontend/src/api/payment/PaymentTypes.ts +++ b/frontend/src/api/payment/PaymentTypes.ts @@ -34,8 +34,8 @@ export interface PaymentRetrieveResponse { status: 'DONE' | 'CANCELED'; requestedAt: string; approvedAt: string; - detail: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail; - cancellation?: CanceledPaymentDetailResponse; + detail?: CardPaymentDetail | BankTransferPaymentDetail | EasyPayPrepaidPaymentDetail; + cancel?: CanceledPaymentDetailResponse; } export interface CardPaymentDetail { diff --git a/frontend/src/api/reservation/reservationAPI.ts b/frontend/src/api/reservation/reservationAPI.ts deleted file mode 100644 index ca370e74..00000000 --- a/frontend/src/api/reservation/reservationAPI.ts +++ /dev/null @@ -1,98 +0,0 @@ -import apiClient from "@_api/apiClient"; -import type { - AdminReservationCreateRequest, - MyReservationRetrieveListResponse, - ReservationCreateRequest, - ReservationCreateResponse, - ReservationCreateWithPaymentRequest, - ReservationDetailV2, - ReservationPaymentRequest, - ReservationPaymentResponse, - ReservationRetrieveListResponse, - ReservationRetrieveResponse, - ReservationSearchQuery, - ReservationSummaryListV2, - WaitingCreateRequest -} from "./reservationTypes"; - -// GET /reservations -export const fetchReservations = async (): Promise => { - return await apiClient.get('/reservations', true); -}; - -// GET /reservations-mine -export const fetchMyReservations = async (): Promise => { - return await apiClient.get('/reservations-mine', true); -}; - -// GET /reservations/search -export const searchReservations = async (params: ReservationSearchQuery): Promise => { - const query = new URLSearchParams(); - if (params.themeId) query.append('themeId', params.themeId.toString()); - if (params.memberId) query.append('memberId', params.memberId.toString()); - if (params.dateFrom) query.append('dateFrom', params.dateFrom); - if (params.dateTo) query.append('dateTo', params.dateTo); - return await apiClient.get(`/reservations/search?${query.toString()}`, true); -}; - -// DELETE /reservations/{id} -export const cancelReservationByAdmin = async (id: string): Promise => { - return await apiClient.del(`/reservations/${id}`, true); -}; - -// POST /reservations -export const createReservationWithPayment = async (data: ReservationCreateWithPaymentRequest): Promise => { - return await apiClient.post('/reservations', data, true); -}; - -// POST /reservations/admin -export const createReservationByAdmin = async (data: AdminReservationCreateRequest): Promise => { - return await apiClient.post('/reservations/admin', data, true); -}; - -// GET /reservations/waiting -export const fetchWaitingReservations = async (): Promise => { - return await apiClient.get('/reservations/waiting', true); -}; - -// POST /reservations/waiting -export const createWaiting = async (data: WaitingCreateRequest): Promise => { - return await apiClient.post('/reservations/waiting', data, true); -}; - -// DELETE /reservations/waiting/{id} -export const cancelWaiting = async (id: string): Promise => { - return await apiClient.del(`/reservations/waiting/${id}`, true); -}; - -// POST /reservations/waiting/{id}/confirm -export const confirmWaiting = async (id: string): Promise => { - return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true); -}; - -// POST /reservations/waiting/{id}/reject -export const rejectWaiting = async (id: string): Promise => { - return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true); -}; - -// POST /v2/reservations -export const createPendingReservation = async (data: ReservationCreateRequest): Promise => { - return await apiClient.post('/v2/reservations', data, true); -}; - -// POST /v2/reservations/{id}/pay -export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise => { - return await apiClient.post(`/v2/reservations/${id}/pay`, data, true); -}; - - - -// GET /v2/reservations -export const fetchMyReservationsV2 = async (): Promise => { - return await apiClient.get('/v2/reservations', true); -}; - -// GET /v2/reservations/{id}/details -export const fetchReservationDetailV2 = async (id: string): Promise => { - return await apiClient.get(`/v2/reservations/${id}/details`, true); -}; \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts deleted file mode 100644 index 29d20a57..00000000 --- a/frontend/src/api/reservation/reservationTypes.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { MemberRetrieveResponse, MemberSummaryRetrieveResponse } from '@_api/member/memberTypes'; -import type { PaymentRetrieveResponse, PaymentType } from '@_api/payment/PaymentTypes'; -import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes'; -import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; - -export const ReservationStatus = { - PENDING: 'PENDING', - CONFIRMED: 'CONFIRMED', - CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED', - WAITING: 'WAITING', - CANCELED_BY_USER: 'CANCELED_BY_USER', - AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED' -} as const; - -export type ReservationStatus = - | typeof ReservationStatus.PENDING - | typeof ReservationStatus.CONFIRMED - | typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - | typeof ReservationStatus.WAITING - | typeof ReservationStatus.CANCELED_BY_USER - | typeof ReservationStatus.AUTOMATICALLY_CANCELED; - -export interface MyReservationRetrieveResponse { - id: string; - themeName: string; - date: string; - time: string; - status: ReservationStatus; - rank: number; - paymentKey: string | null; - amount: number | null; -} - -export interface MyReservationRetrieveListResponse { - reservations: MyReservationRetrieveResponse[]; -} - -export interface ReservationRetrieveResponse { - id: string; - date: string; - member: MemberRetrieveResponse; - time: TimeRetrieveResponse; - theme: ThemeRetrieveResponse; - status: ReservationStatus; -} - -export interface ReservationRetrieveListResponse { - reservations: ReservationRetrieveResponse[]; -} - -export interface AdminReservationCreateRequest { - date: string; - timeId: string; - themeId: string; - memberId: string; -} - -export interface ReservationCreateWithPaymentRequest { - date: string; - timeId: string; - themeId: string; - paymentKey: string; - orderId: string; - amount: number; - paymentType: string; -} - -export interface WaitingCreateRequest { - date: string; - timeId: string; - themeId: string; -} - -export interface ReservationSearchQuery { - themeId?: string; - memberId?: string; - dateFrom?: string; - dateTo?: string; -} - -export const PaymentStatus = { - IN_PROGRESS: '결제 진행 중', - DONE: '결제 완료', - CANCELED: '결제 취소', - ABORTED: '결제 중단', - EXPIRED: '시간 만료', -} - -export type PaymentStatus = - | typeof PaymentStatus.IN_PROGRESS - | typeof PaymentStatus.DONE - | typeof PaymentStatus.CANCELED - | typeof PaymentStatus.ABORTED - | typeof PaymentStatus.EXPIRED; - - -export interface ReservationCreateRequest { - date: string; - timeId: string; - themeId: string; -} - -export interface ReservationCreateResponse { - reservationId: string; - memberEmail: string; - date: string; - startAt: string; - themeName: string; -} - -export interface ReservationPaymentRequest { - paymentKey: string; - orderId: string; - amount: number; - paymentType: PaymentType; -} - -export interface ReservationPaymentResponse { - reservationId: string; - reservationStatus: ReservationStatus; - paymentId: string; - paymentStatus: PaymentStatus; -} - - - -export interface ReservationDetailV2 { - id: string; - user: MemberSummaryRetrieveResponse; - themeName: string; - date: string; - startAt: string; - applicationDateTime: string; - payment: PaymentRetrieveResponse; -} diff --git a/frontend/src/api/reservation/reservationTypesV2.ts b/frontend/src/api/reservation/reservationTypesV2.ts index e028fe4c..bc6eb4e9 100644 --- a/frontend/src/api/reservation/reservationTypesV2.ts +++ b/frontend/src/api/reservation/reservationTypesV2.ts @@ -1,5 +1,5 @@ -import type {MemberSummaryRetrieveResponse} from "@_api/member/memberTypes"; -import type {PaymentRetrieveResponse} from "@_api/payment/PaymentTypes"; +import type { PaymentRetrieveResponse } from "@_api/payment/PaymentTypes"; +import type { UserContactRetrieveResponse } from "@_api/user/userTypes"; export const ReservationStatusV2 = { PENDING: 'PENDING', @@ -42,7 +42,7 @@ export interface ReservationSummaryRetrieveListResponse { export interface ReservationDetailRetrieveResponse { id: string; - member: MemberSummaryRetrieveResponse; + user: UserContactRetrieveResponse; applicationDateTime: string; payment: PaymentRetrieveResponse; } @@ -52,8 +52,8 @@ export interface ReservationDetail { themeName: string; date: string; startAt: string; - member: MemberSummaryRetrieveResponse; - applicationDateTime: string; + user: UserContactRetrieveResponse; + applicationDateTime: string; payment: PaymentRetrieveResponse; } diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index aa022f84..7fe1327f 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -147,9 +147,16 @@ export interface ThemeRetrieveListResponseV2 { // @ts-ignore export enum Difficulty { - VERY_EASY = 'VERY_EASY', - EASY = 'EASY', - NORMAL = 'NORMAL', - HARD = 'HARD', - VERY_HARD = 'VERY_HARD', + VERY_EASY = '매우 쉬움', + EASY = '쉬움', + NORMAL = '보통', + HARD = '어려움', + VERY_HARD = '매우 어려움', +} + +export function mapThemeResponse(res: any): UserThemeRetrieveResponse { + return { + ...res, + difficulty: Difficulty[res.difficulty as keyof typeof Difficulty], + } } \ No newline at end of file diff --git a/frontend/src/api/time/timeAPI.ts b/frontend/src/api/time/timeAPI.ts deleted file mode 100644 index 656f90e9..00000000 --- a/frontend/src/api/time/timeAPI.ts +++ /dev/null @@ -1,18 +0,0 @@ -import apiClient from "@_api/apiClient"; -import type { TimeCreateRequest, TimeCreateResponse, TimeRetrieveListResponse, TimeWithAvailabilityListResponse } from "./timeTypes"; - -export const createTime = async (data: TimeCreateRequest): Promise => { - return await apiClient.post('/times', data, true); -} - -export const fetchTimes = async (): Promise => { - return await apiClient.get('/times', true); -}; - -export const delTime = async (id: string): Promise => { - return await apiClient.del(`/times/${id}`, true); -}; - -export const fetchTimesWithAvailability = async (date: string, themeId: string): Promise => { - return await apiClient.get(`/times/search?date=${date}&themeId=${themeId}`, true); -}; diff --git a/frontend/src/api/time/timeTypes.ts b/frontend/src/api/time/timeTypes.ts deleted file mode 100644 index acb7c350..00000000 --- a/frontend/src/api/time/timeTypes.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface TimeCreateRequest { - startAt: string; -} - -export interface TimeCreateResponse { - id: string; - startAt: string; -} - -export interface TimeRetrieveResponse { - id: string; - startAt: string; -} - -export interface TimeRetrieveListResponse { - times: TimeCreateResponse[]; -} - -export interface TimeWithAvailabilityResponse { - id: string; - startAt: string; - isAvailable: boolean; -} - -export interface TimeWithAvailabilityListResponse { - times: TimeWithAvailabilityResponse[]; -} \ No newline at end of file diff --git a/frontend/src/api/user/userAPI.ts b/frontend/src/api/user/userAPI.ts index 20b14d56..7388f7d7 100644 --- a/frontend/src/api/user/userAPI.ts +++ b/frontend/src/api/user/userAPI.ts @@ -1,6 +1,10 @@ import apiClient from "@_api/apiClient"; -import type { UserCreateRequest, UserCreateResponse } from "./userTypes"; +import type { UserContactRetrieveResponse, UserCreateRequest, UserCreateResponse } from "./userTypes"; export const signup = async (data: UserCreateRequest): Promise => { return await apiClient.post('/users', data, false); -}; \ No newline at end of file +}; + +export const fetchContact = async (): Promise => { + return await apiClient.get('/users/contact', true); +} diff --git a/frontend/src/api/user/userTypes.ts b/frontend/src/api/user/userTypes.ts index 94804f51..34a861e0 100644 --- a/frontend/src/api/user/userTypes.ts +++ b/frontend/src/api/user/userTypes.ts @@ -16,12 +16,17 @@ export interface UserCreateRequest { } export interface UserCreateResponse { - id: number; + id: string; name: string; } export interface UserContactRetrieveResponse { - id: number; + id: string; name: string; phone: string; } + +export interface OperatorInfo { + id: string; + name: string; +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 82168dba..ceff4b6d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -21,20 +21,20 @@ const Navbar: React.FC = () => {