From aba4a28ad5b32737f6cd65d28663ff49767c51dd Mon Sep 17 00:00:00 2001 From: Kimdonghwan Date: Wed, 11 Mar 2026 20:08:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=95=A0=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/com/souzip/application/admin/required/CityCommandPort.java # src/main/java/com/souzip/application/admin/required/CityQueryPort.java # src/main/java/com/souzip/application/admin/required/CountryQueryPort.java --- .../souzip/application/admin/required/CityQueryPort.java | 9 +++++++++ .../application/admin/required/CountryQueryPort.java | 8 ++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/main/java/com/souzip/application/admin/required/CityQueryPort.java create mode 100644 src/main/java/com/souzip/application/admin/required/CountryQueryPort.java diff --git a/src/main/java/com/souzip/application/admin/required/CityQueryPort.java b/src/main/java/com/souzip/application/admin/required/CityQueryPort.java new file mode 100644 index 0000000..2181d2f --- /dev/null +++ b/src/main/java/com/souzip/application/admin/required/CityQueryPort.java @@ -0,0 +1,9 @@ +package com.souzip.application.admin.required; + +import com.souzip.domain.city.entity.City; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CityQueryPort { + Page getCities(Long countryId, String keyword, Pageable pageable); +} diff --git a/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java b/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java new file mode 100644 index 0000000..2f7e7f3 --- /dev/null +++ b/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java @@ -0,0 +1,8 @@ +package com.souzip.application.admin.required; + +import com.souzip.domain.country.entity.Country; +import java.util.List; + +public interface CountryQueryPort { + List getCountries(String keyword); +} From c2fab8e684fa4546791b1a5ded32d1534ce7c690 Mon Sep 17 00:00:00 2001 From: Kimdonghwan Date: Wed, 11 Mar 2026 21:38:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jffi937159141578569382.dylib | Bin 0 -> 172832 bytes .../admin/required/CityQueryPort.java | 9 --------- .../admin/required/CountryQueryPort.java | 8 -------- 3 files changed, 17 deletions(-) create mode 100644 jffi937159141578569382.dylib delete mode 100644 src/main/java/com/souzip/application/admin/required/CityQueryPort.java delete mode 100644 src/main/java/com/souzip/application/admin/required/CountryQueryPort.java diff --git a/jffi937159141578569382.dylib b/jffi937159141578569382.dylib new file mode 100644 index 0000000000000000000000000000000000000000..28edf13e3a4303e1a16ca57632c6fc28b77f265a GIT binary patch literal 172832 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

{`424UvCEKSFELR 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 getCities(Long countryId, String keyword, Pageable pageable); -} diff --git a/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java b/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java deleted file mode 100644 index 2f7e7f3..0000000 --- a/src/main/java/com/souzip/application/admin/required/CountryQueryPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.application.admin.required; - -import com.souzip.domain.country.entity.Country; -import java.util.List; - -public interface CountryQueryPort { - List getCountries(String keyword); -} From 641d6fef1b73f23a1c4d52f32a9309165ed1f5de Mon Sep 17 00:00:00 2001 From: Kimdonghwan Date: Wed, 11 Mar 2026 21:39:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jffi937159141578569382.dylib | Bin 172832 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 jffi937159141578569382.dylib diff --git a/jffi937159141578569382.dylib b/jffi937159141578569382.dylib deleted file mode 100644 index 28edf13e3a4303e1a16ca57632c6fc28b77f265a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 172832 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

{`424UvCEKSFELR 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 Date: Wed, 11 Mar 2026 21:48:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?revert:=20755d2a7=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- src/docs/asciidoc/admin/location.adoc | 20 +- ...24\354\235\270\353\252\250\353\215\270.md" | 88 +-- .../adapter/config/AdminProperties.java | 16 - .../persistence/admin/AdminInitializer.java | 45 -- .../admin/annotation/AdminAccess.java | 12 - .../admin/annotation/SuperAdminOnly.java | 12 - .../admin/annotation/ViewerAccess.java | 12 - .../admin/encoder/SecurePasswordEncoder.java | 20 - .../admin/jwt/JwtTokenProviderAdapter.java | 23 - .../souzip/adapter/webapi/admin/AdminApi.java | 63 -- .../adapter/webapi/admin/AdminAuthApi.java | 46 -- .../webapi/admin/AdminLocationApi.java | 91 --- .../adapter/webapi/admin/AdminNoticeApi.java | 12 +- .../webapi/admin/dto/AdminLoginRequest.java | 12 - .../webapi/admin/dto/AdminLoginResponse.java | 24 - .../webapi/admin/dto/AdminRefreshRequest.java | 9 - .../admin/dto/AdminRefreshResponse.java | 15 - .../admin/dto/AdminRegisterResponse.java | 20 - .../webapi/admin/dto/AdminResponse.java | 25 - .../webapi/admin/dto/CityResponse.java | 25 - .../webapi/admin/dto/CountryResponse.java | 15 - .../application/admin/AdminAuthService.java | 127 ---- .../admin/AdminLocationModifyService.java | 50 -- .../admin/AdminLocationQueryService.java | 39 -- .../application/admin/AdminModifyService.java | 48 -- .../application/admin/AdminQueryService.java | 32 - .../admin/dto/AdminLoginResult.java | 10 - .../admin/dto/AdminPageResult.java | 12 - .../admin/dto/AdminRefreshResult.java | 7 - .../admin/provided/AdminFinder.java | 13 - .../admin/provided/AdminLocationFinder.java | 14 - .../admin/provided/AdminLocationModifier.java | 14 - .../admin/provided/AdminModifier.java | 12 - .../admin/required/AdminRepository.java | 28 - .../admin/required/TokenProvider.java | 10 - .../assembler/NoticeResponseAssembler.java | 11 +- .../java/com/souzip/domain/admin/Admin.java | 48 -- .../domain/admin/AdminRefreshToken.java | 46 -- .../domain/admin/AdminRegisterRequest.java | 46 -- .../souzip/domain/admin/PasswordEncoder.java | 8 - .../admin/application/AdminAuthService.java | 145 +++++ .../application/AdminCityQueryUseCase.java | 10 + .../application/AdminCountryQueryUseCase.java | 9 + .../application/AdminManagementService.java | 120 ++++ .../application/AdminManagementUseCase.java | 27 + .../command/AdminCreateCityCommand.java | 9 + .../command/AdminDeleteCityCommand.java | 5 + .../command/AdminLoginCommand.java | 6 + .../command/AdminUpdateCityCommand.java | 8 + .../AdminUpdateCityPriorityCommand.java | 6 + .../command/InviteAdminCommand.java | 9 + .../application/port/CityCommandPort.java | 17 + .../admin/application/port/CityQueryPort.java | 17 + .../application/port/CountryQueryPort.java | 12 + .../query/AdminCityQueryService.java | 24 + .../query/AdminCountryQueryService.java | 22 + .../application/query/CitySearchQuery.java | 12 + .../admin/exception/AdminErrorCode.java | 2 +- .../admin/exception/AdminException.java | 1 - .../encoder/AdminPasswordEncoderImpl.java | 23 + .../infrastructure/entity/AdminEntity.java | 44 ++ .../entity/AdminRefreshTokenEntity.java | 36 + .../infrastructure/init/AdminInitializer.java | 46 ++ .../infrastructure/init/AdminProperties.java | 17 + .../persistence/AdminJpaRepository.java | 25 + .../persistence/AdminMapper.java | 33 + .../AdminRefreshTokenJpaRepository.java | 19 + .../persistence/AdminRefreshTokenMapper.java | 28 + .../AdminRefreshTokenRepositoryImpl.java | 45 ++ .../persistence/AdminRepositoryImpl.java | 78 +++ .../persistence/CityCommandAdapter.java | 58 ++ .../persistence/CityQueryAdapter.java | 43 ++ .../persistence/CountryQueryAdapter.java | 24 + .../AdminRefreshTokenCleanupScheduler.java | 10 +- .../security/annotation/AdminAccess.java | 16 + .../security}/annotation/CurrentAdminId.java | 2 +- .../security/annotation/SuperAdminOnly.java | 16 + .../security/annotation/ViewerAccess.java | 15 + .../jwt/AdminJwtAuthenticationFilter.java | 25 +- .../CurrentAdminIdArgumentResolver.java | 17 +- .../com/souzip/domain/admin/model/Admin.java | 65 ++ .../admin/model/AdminPasswordEncoder.java | 8 + .../domain/admin/model/AdminRefreshToken.java | 48 ++ .../domain/admin/{ => model}/AdminRole.java | 2 +- .../souzip/domain/admin/model/Password.java | 26 + .../souzip/domain/admin/model/Username.java | 35 + .../presentation/AdminAuthController.java | 46 ++ .../AdminManagementController.java | 158 +++++ .../request/AdminLoginRequest.java | 17 + .../request/AdminRefreshRequest.java | 10 + .../request/CreateCityRequest.java | 21 + .../request/InviteAdminRequest.java | 24 + .../request/UpdateCityRequest.java | 12 + .../response/AdminLoginResponse.java | 24 + .../response/AdminRefreshResponse.java | 15 + .../presentation/response/AdminResponse.java | 41 ++ .../response/InviteAdminResponse.java | 19 + .../AdminRefreshTokenRepository.java | 10 +- .../admin/repository/AdminRepository.java | 26 + .../command/CityCommandService.java | 165 +++-- .../command/UpdateCityCommand.java | 4 +- .../com/souzip/domain/city/entity/City.java | 4 +- .../domain/city/entity/CityCreateRequest.java | 22 - .../domain/city/entity/CityUpdateRequest.java | 19 - .../query/CountryQueryService.java | 5 +- .../com/souzip/global/config/WebConfig.java | 5 +- .../souzip/global/exception/ErrorCode.java | 6 + .../security/config/SecurityConfig.java | 4 +- src/main/resources/META-INF/orm.xml | 239 +++---- .../adapter/webapi/admin/AdminApiTest.java | 159 ----- .../webapi/admin/AdminAuthApiTest.java | 208 ------ .../webapi/admin/AdminLocationApiTest.java | 282 -------- .../webapi/admin/AdminNoticeApiTest.java | 3 +- .../admin/AdminAuthServiceTest.java | 134 ---- .../admin/AdminModifyServiceTest.java | 90 --- .../admin/AdminQueryServiceTest.java | 69 -- .../java/com/souzip/docs/RestDocsSupport.java | 2 +- .../com/souzip/domain/admin/AdminFixture.java | 59 -- .../domain/admin/AdminRefreshTokenTest.java | 83 --- .../com/souzip/domain/admin/AdminTest.java | 81 --- .../application/AdminAuthServiceTest.java | 209 ++++++ .../AdminCityQueryServiceTest.java | 89 +++ .../AdminCountryQueryServiceTest.java | 86 +++ .../AdminManagementServiceTest.java | 287 ++++++++ .../fixture/TestAdminPasswordEncoder.java | 16 + .../persistence/AdminMapperTest.java | 59 ++ .../AdminRefreshTokenMapperTest.java | 65 ++ .../AdminRefreshTokenRepositoryTest.java | 125 ++++ .../persistence/AdminRepositoryTest.java | 166 +++++ .../admin/model/AdminRefreshTokenTest.java | 56 ++ .../souzip/domain/admin/model/AdminTest.java | 31 + .../domain/admin/model/PasswordTest.java | 23 + .../domain/admin/model/UsernameTest.java | 48 ++ .../presentation/AdminAuthControllerTest.java | 242 +++++++ .../AdminManagementControllerTest.java | 613 ++++++++++++++++++ 136 files changed, 3980 insertions(+), 2648 deletions(-) delete mode 100644 src/main/java/com/souzip/adapter/config/AdminProperties.java delete mode 100644 src/main/java/com/souzip/adapter/persistence/admin/AdminInitializer.java delete mode 100644 src/main/java/com/souzip/adapter/security/admin/annotation/AdminAccess.java delete mode 100644 src/main/java/com/souzip/adapter/security/admin/annotation/SuperAdminOnly.java delete mode 100644 src/main/java/com/souzip/adapter/security/admin/annotation/ViewerAccess.java delete mode 100644 src/main/java/com/souzip/adapter/security/admin/encoder/SecurePasswordEncoder.java delete mode 100644 src/main/java/com/souzip/adapter/security/admin/jwt/JwtTokenProviderAdapter.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/AdminApi.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/AdminAuthApi.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/AdminLocationApi.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginRequest.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginResponse.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshRequest.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshResponse.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRegisterResponse.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/AdminResponse.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/CityResponse.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/CountryResponse.java delete mode 100644 src/main/java/com/souzip/application/admin/AdminAuthService.java delete mode 100644 src/main/java/com/souzip/application/admin/AdminLocationModifyService.java delete mode 100644 src/main/java/com/souzip/application/admin/AdminLocationQueryService.java delete mode 100644 src/main/java/com/souzip/application/admin/AdminModifyService.java delete mode 100644 src/main/java/com/souzip/application/admin/AdminQueryService.java delete mode 100644 src/main/java/com/souzip/application/admin/dto/AdminLoginResult.java delete mode 100644 src/main/java/com/souzip/application/admin/dto/AdminPageResult.java delete mode 100644 src/main/java/com/souzip/application/admin/dto/AdminRefreshResult.java delete mode 100644 src/main/java/com/souzip/application/admin/provided/AdminFinder.java delete mode 100644 src/main/java/com/souzip/application/admin/provided/AdminLocationFinder.java delete mode 100644 src/main/java/com/souzip/application/admin/provided/AdminLocationModifier.java delete mode 100644 src/main/java/com/souzip/application/admin/provided/AdminModifier.java delete mode 100644 src/main/java/com/souzip/application/admin/required/AdminRepository.java delete mode 100644 src/main/java/com/souzip/application/admin/required/TokenProvider.java delete mode 100644 src/main/java/com/souzip/domain/admin/Admin.java delete mode 100644 src/main/java/com/souzip/domain/admin/AdminRefreshToken.java delete mode 100644 src/main/java/com/souzip/domain/admin/AdminRegisterRequest.java delete mode 100644 src/main/java/com/souzip/domain/admin/PasswordEncoder.java create mode 100644 src/main/java/com/souzip/domain/admin/application/AdminAuthService.java create mode 100644 src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java create mode 100644 src/main/java/com/souzip/domain/admin/application/AdminCountryQueryUseCase.java create mode 100644 src/main/java/com/souzip/domain/admin/application/AdminManagementService.java create mode 100644 src/main/java/com/souzip/domain/admin/application/AdminManagementUseCase.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/AdminCreateCityCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/AdminDeleteCityCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/AdminLoginCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityPriorityCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/command/InviteAdminCommand.java create mode 100644 src/main/java/com/souzip/domain/admin/application/port/CityCommandPort.java create mode 100644 src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java create mode 100644 src/main/java/com/souzip/domain/admin/application/port/CountryQueryPort.java create mode 100644 src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java create mode 100644 src/main/java/com/souzip/domain/admin/application/query/AdminCountryQueryService.java create mode 100644 src/main/java/com/souzip/domain/admin/application/query/CitySearchQuery.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/encoder/AdminPasswordEncoderImpl.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminEntity.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminRefreshTokenEntity.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/init/AdminInitializer.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/init/AdminProperties.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminJpaRepository.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapper.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenJpaRepository.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapper.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryImpl.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryImpl.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityCommandAdapter.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/persistence/CountryQueryAdapter.java rename src/main/java/com/souzip/{adapter => domain/admin/infrastructure}/scheduler/AdminRefreshTokenCleanupScheduler.java (83%) create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/AdminAccess.java rename src/main/java/com/souzip/{adapter/security/admin => domain/admin/infrastructure/security}/annotation/CurrentAdminId.java (79%) create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/SuperAdminOnly.java create mode 100644 src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/ViewerAccess.java rename src/main/java/com/souzip/{adapter/security/admin => domain/admin/infrastructure/security}/jwt/AdminJwtAuthenticationFilter.java (83%) rename src/main/java/com/souzip/{adapter/security/admin => domain/admin/infrastructure/security}/resolver/CurrentAdminIdArgumentResolver.java (67%) create mode 100644 src/main/java/com/souzip/domain/admin/model/Admin.java create mode 100644 src/main/java/com/souzip/domain/admin/model/AdminPasswordEncoder.java create mode 100644 src/main/java/com/souzip/domain/admin/model/AdminRefreshToken.java rename src/main/java/com/souzip/domain/admin/{ => model}/AdminRole.java (59%) create mode 100644 src/main/java/com/souzip/domain/admin/model/Password.java create mode 100644 src/main/java/com/souzip/domain/admin/model/Username.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/request/AdminLoginRequest.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/request/AdminRefreshRequest.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/request/CreateCityRequest.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/request/InviteAdminRequest.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/request/UpdateCityRequest.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/response/AdminLoginResponse.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/response/AdminRefreshResponse.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java create mode 100644 src/main/java/com/souzip/domain/admin/presentation/response/InviteAdminResponse.java rename src/main/java/com/souzip/{application/admin/required => domain/admin/repository}/AdminRefreshTokenRepository.java (60%) create mode 100644 src/main/java/com/souzip/domain/admin/repository/AdminRepository.java delete mode 100644 src/main/java/com/souzip/domain/city/entity/CityCreateRequest.java delete mode 100644 src/main/java/com/souzip/domain/city/entity/CityUpdateRequest.java delete mode 100644 src/test/java/com/souzip/adapter/webapi/admin/AdminApiTest.java delete mode 100644 src/test/java/com/souzip/adapter/webapi/admin/AdminAuthApiTest.java delete mode 100644 src/test/java/com/souzip/adapter/webapi/admin/AdminLocationApiTest.java delete mode 100644 src/test/java/com/souzip/application/admin/AdminAuthServiceTest.java delete mode 100644 src/test/java/com/souzip/application/admin/AdminModifyServiceTest.java delete mode 100644 src/test/java/com/souzip/application/admin/AdminQueryServiceTest.java delete mode 100644 src/test/java/com/souzip/domain/admin/AdminFixture.java delete mode 100644 src/test/java/com/souzip/domain/admin/AdminRefreshTokenTest.java delete mode 100644 src/test/java/com/souzip/domain/admin/AdminTest.java create mode 100644 src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java create mode 100644 src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java create mode 100644 src/test/java/com/souzip/domain/admin/application/AdminCountryQueryServiceTest.java create mode 100644 src/test/java/com/souzip/domain/admin/application/AdminManagementServiceTest.java create mode 100644 src/test/java/com/souzip/domain/admin/fixture/TestAdminPasswordEncoder.java create mode 100644 src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapperTest.java create mode 100644 src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapperTest.java create mode 100644 src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java create mode 100644 src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java create mode 100644 src/test/java/com/souzip/domain/admin/model/AdminRefreshTokenTest.java create mode 100644 src/test/java/com/souzip/domain/admin/model/AdminTest.java create mode 100644 src/test/java/com/souzip/domain/admin/model/PasswordTest.java create mode 100644 src/test/java/com/souzip/domain/admin/model/UsernameTest.java create mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java create mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java diff --git a/.gitignore b/.gitignore index 4680a52..0295d34 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ build/ !**/src/test/**/build/ src/main/resources/static/docs/ -src/main/resources/.env +.env ### STS ### .apt_generated diff --git a/src/docs/asciidoc/admin/location.adoc b/src/docs/asciidoc/admin/location.adoc index e7ac8db..795b828 100644 --- a/src/docs/asciidoc/admin/location.adoc +++ b/src/docs/asciidoc/admin/location.adoc @@ -32,15 +32,6 @@ operation::admin/get-cities[snippets='http-request,request-headers,query-paramet operation::admin/create-city[snippets='http-request,request-headers,request-fields,http-response,response-fields'] -[[city-update]] -=== λ„μ‹œ μˆ˜μ • - -λ„μ‹œ 정보λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€. - -* SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€. - -operation::admin/update-city[snippets='http-request,request-headers,path-parameters,request-fields,http-response,response-fields'] - [[city-delete]] === λ„μ‹œ μ‚­μ œ @@ -60,4 +51,13 @@ operation::admin/delete-city[snippets='http-request,request-headers,path-paramet * μš°μ„ μˆœμœ„λŠ” 1 이상이어야 ν•©λ‹ˆλ‹€. * 같은 κ΅­κ°€ λ‚΄μ—μ„œ μš°μ„ μˆœμœ„κ°€ μ€‘λ³΅λ˜μ§€ μ•Šλ„λ‘ μžλ™μœΌλ‘œ μ‘°μ •λ©λ‹ˆλ‹€. -operation::admin/update-city-priority[snippets='http-request,request-headers,path-parameters,query-parameters,http-response,response-fields'] \ No newline at end of file +operation::admin/update-city-priority[snippets='http-request,request-headers,path-parameters,query-parameters,http-response,response-fields'] + +[[city-priority-reset]] +=== λ„μ‹œ μš°μ„ μˆœμœ„ μ΄ˆκΈ°ν™” + +λ„μ‹œμ˜ 검색 μš°μ„ μˆœμœ„λ₯Ό μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€. + +* SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€. + +operation::admin/reset-city-priority[snippets='http-request,request-headers,path-parameters,http-response,response-fields'] diff --git "a/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" "b/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" index 008edb4..70b1dec 100644 --- "a/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" +++ "b/src/docs/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" @@ -1,7 +1,6 @@ # Souzip 도메인 λͺ¨λΈ ## 도메인 λͺ¨λΈ λ§Œλ“€κΈ° - - λ“£κ³  배우기 - 'μ€‘μš”ν•œ 것'λ“€ μ°ΎκΈ° (κ°œλ… 식별) - 'μ—°κ²° 고리' μ°ΎκΈ° (관계 μ •μ˜) @@ -10,83 +9,17 @@ μ˜ˆμ‹œ) 클래슀 λ‹€μ΄μ–΄κ·Έλž¨ - 이야기 ν•˜κ³  닀듬기 (반볡) + ## Souzip 도메인 --- ## 도메인 λͺ¨λΈ -### κ΄€λ¦¬μž (Admin) - -_Entity_ - -#### 속성 - -- `id`: `UUID` -- `username`: 아이디 -- `password`: λΉ„λ°€λ²ˆν˜Έ (μ•”ν˜Έν™”) -- `role`: μ—­ν•  (SUPER_ADMIN, ADMIN, VIEWER) -- `lastLoginAt`: λ§ˆμ§€λ§‰ 둜그인 μ‹œκ°„ -- `createdAt`: 등둝일 -- `updatedAt`: μˆ˜μ •μΌ - -#### ν–‰μœ„ - -- `static register(AdminRegisterRequest, PasswordEncoder)`: κ΄€λ¦¬μž 등둝 -- `login()`: 둜그인 μ‹œκ°„ μ—…λ°μ΄νŠΈ -- `matchesPassword(String, PasswordEncoder)`: λΉ„λ°€λ²ˆν˜Έ 검증 - -#### κ·œμΉ™ - -- μ•„μ΄λ””λŠ” 2자 이상 20자 μ΄ν•˜ -- λΉ„λ°€λ²ˆν˜ΈλŠ” 8자 이상 -- 역할은 ν•„μˆ˜ (null λΆˆκ°€) -- SUPER_ADMIN은 μ΄ˆλŒ€ν•  수 μ—†μŒ -- μ•„μ΄λ””λŠ” 쀑볡될 수 μ—†μŒ - -#### 관계 - -- **AdminRefreshToken**: 1:1 - ---- - -### κ΄€λ¦¬μž λ¦¬ν”„λ ˆμ‹œ 토큰 (AdminRefreshToken) - -_Entity_ - -#### 속성 - -- `id`: `UUID` -- `adminId`: κ΄€λ¦¬μž ID -- `token`: λ¦¬ν”„λ ˆμ‹œ 토큰 κ°’ -- `expiresAt`: 만료일 -- `createdAt`: 등둝일 - -#### ν–‰μœ„ - -- `static create(UUID, String, LocalDateTime)`: 토큰 생성 -- `updateToken(String, LocalDateTime)`: 토큰 κ°±μ‹  -- `isExpired()`: 만료 μ—¬λΆ€ 확인 - -#### κ·œμΉ™ - -- λͺ¨λ“  ν•„λ“œλŠ” ν•„μˆ˜ (null λΆˆκ°€) -- 만료일이 μ§€λ‚˜λ©΄ μ‚¬μš© λΆˆκ°€ -- 만료 10일 이내이면 μžλ™ κ°±μ‹  -- 유효 기간은 30일 - -#### 관계 - -- **Admin**: N:1 - ---- - ### μœ„μΉ˜ (Location) - _Entity_ #### 속성 - - `id`: `Long` - `name`: μž₯μ†Œλͺ… - `address`: μ£Όμ†Œ @@ -96,25 +29,20 @@ _Entity_ - `updatedAt`: μˆ˜μ •μΌ #### ν–‰μœ„ - - `static create()`: μœ„μΉ˜ 생성 #### κ·œμΉ™ - - μ’Œν‘œλŠ” μœ νš¨ν•œ λ²”μœ„μ—¬μ•Ό ν•œλ‹€. (μœ„λ„: -90~90, 경도: -180~180) #### 관계 - - **Souvenir**: 직접 μ°Έμ‘° μ—†μŒ. Souvenir 생성 μ‹œ Location의 정보λ₯Ό λ³΅μ‚¬ν•˜μ—¬ μ‚¬μš© --- ### 파일 (File) - _Entity_ #### 속성 - - `id`: `Long` - `entityType`: μ—”ν‹°ν‹° νƒ€μž… - `entityId`: μ—”ν‹°ν‹° ID @@ -127,17 +55,14 @@ _Entity_ - `updatedAt`: μˆ˜μ •μΌ #### ν–‰μœ„ - - `static register()`: 파일 등둝 #### κ·œμΉ™ - - λͺ¨λ“  ν•„λ“œλŠ” ν•„μˆ˜ (null λΆˆκ°€) - displayOrderκ°€ μ§€μ •λ˜μ§€ μ•ŠμœΌλ©΄ μžλ™μœΌλ‘œ λ§ˆμ§€λ§‰ μˆœμ„œ + 1둜 μ„€μ • - λ™μΌν•œ (entityType, entityId, displayOrder) 쑰합은 μœ μΌν•΄μ•Ό 함 #### μ™ΈλΆ€ μ˜μ‘΄μ„± - - **NCP Object Storage**: μ‹€μ œ 파일 μ €μž₯μ†Œ - ν—ˆμš© ν™•μž₯자: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp` - μ΅œλŒ€ 파일 크기: 50MB @@ -146,11 +71,9 @@ _Entity_ --- ### 곡지사항 (Notice) - _Entity_ #### 속성 - - `id`: `Long` - `title`: 제λͺ© - `content`: λ‚΄μš© @@ -160,7 +83,6 @@ _Entity_ - `updatedAt`: μˆ˜μ •μΌ #### ν–‰μœ„ - - `static register(NoticeRegisterRequest)`: 곡지사항 등둝 - `update(NoticeUpdateRequest)`: 곡지사항 μˆ˜μ • (제λͺ©, λ‚΄μš©, μƒνƒœ) - `activate()`: ν™œμ„±ν™” @@ -168,7 +90,6 @@ _Entity_ - `isActive()`: ν™œμ„± μƒνƒœ 확인 #### κ·œμΉ™ - - λͺ¨λ“  ν•„λ“œλŠ” ν•„μˆ˜ (null λΆˆκ°€) - κ΄€λ¦¬μžλ§Œ μž‘μ„±/μˆ˜μ • κ°€λŠ₯ - μƒνƒœλŠ” ACTIVE λ˜λŠ” INACTIVE @@ -176,21 +97,16 @@ _Entity_ --- ### μ’Œν‘œ (Coordinate) - _Value Object_ #### 속성 - - `latitude`: μœ„λ„ - `longitude`: 경도 #### κ·œμΉ™ - - μœ„λ„λŠ” -90 ~ 90 λ²”μœ„ - κ²½λ„λŠ” -180 ~ 180 λ²”μœ„ #### μ‚¬μš©μ²˜ - - Location -- City -- Souvenir (ν–₯ν›„ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ˜ˆμ •) \ No newline at end of file +- Souvenir (ν–₯ν›„ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ˜ˆμ •) diff --git a/src/main/java/com/souzip/adapter/config/AdminProperties.java b/src/main/java/com/souzip/adapter/config/AdminProperties.java deleted file mode 100644 index 76855f3..0000000 --- a/src/main/java/com/souzip/adapter/config/AdminProperties.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.souzip.adapter.config; - -import lombok.Getter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Getter -@Component -public class AdminProperties { - - @Value("${admin.initial.username}") - private String username; - - @Value("${admin.initial.password}") - private String password; -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/persistence/admin/AdminInitializer.java b/src/main/java/com/souzip/adapter/persistence/admin/AdminInitializer.java deleted file mode 100644 index a82db45..0000000 --- a/src/main/java/com/souzip/adapter/persistence/admin/AdminInitializer.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.souzip.adapter.persistence.admin; - -import com.souzip.adapter.config.AdminProperties; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRegisterRequest; -import com.souzip.domain.admin.AdminRole; -import com.souzip.domain.admin.PasswordEncoder; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.stereotype.Component; - -@Slf4j -@RequiredArgsConstructor -@Component -public class AdminInitializer implements ApplicationRunner { - - private final AdminRepository adminRepository; - private final AdminProperties adminProperties; - private final PasswordEncoder passwordEncoder; - - @Override - public void run(ApplicationArguments args) { - adminRepository.findByUsername(adminProperties.getUsername()).ifPresentOrElse( - admin -> log.info("초기 μ–΄λ“œλ―Ό 계정이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€. username={}", admin.getUsername()), - this::createInitialAdmin - ); - } - - private void createInitialAdmin() { - Admin admin = Admin.register( - AdminRegisterRequest.of( - adminProperties.getUsername(), - adminProperties.getPassword(), - AdminRole.SUPER_ADMIN - ), - passwordEncoder - ); - adminRepository.save(admin); - log.info("초기 μ–΄λ“œλ―Ό 계정이 μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€. username={}", adminProperties.getUsername()); - } -} diff --git a/src/main/java/com/souzip/adapter/security/admin/annotation/AdminAccess.java b/src/main/java/com/souzip/adapter/security/admin/annotation/AdminAccess.java deleted file mode 100644 index 73b4f16..0000000 --- a/src/main/java/com/souzip/adapter/security/admin/annotation/AdminAccess.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.adapter.security.admin.annotation; - -import org.springframework.security.access.prepost.PreAuthorize; - -import java.lang.annotation.*; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") -public @interface AdminAccess { -} diff --git a/src/main/java/com/souzip/adapter/security/admin/annotation/SuperAdminOnly.java b/src/main/java/com/souzip/adapter/security/admin/annotation/SuperAdminOnly.java deleted file mode 100644 index 1e48fdc..0000000 --- a/src/main/java/com/souzip/adapter/security/admin/annotation/SuperAdminOnly.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.adapter.security.admin.annotation; - -import org.springframework.security.access.prepost.PreAuthorize; - -import java.lang.annotation.*; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@PreAuthorize("hasRole('SUPER_ADMIN')") -public @interface SuperAdminOnly { -} diff --git a/src/main/java/com/souzip/adapter/security/admin/annotation/ViewerAccess.java b/src/main/java/com/souzip/adapter/security/admin/annotation/ViewerAccess.java deleted file mode 100644 index 0a070f4..0000000 --- a/src/main/java/com/souzip/adapter/security/admin/annotation/ViewerAccess.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.adapter.security.admin.annotation; - -import org.springframework.security.access.prepost.PreAuthorize; - -import java.lang.annotation.*; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -@PreAuthorize("hasAnyRole('VIEWER', 'ADMIN', 'SUPER_ADMIN')") -public @interface ViewerAccess { -} diff --git a/src/main/java/com/souzip/adapter/security/admin/encoder/SecurePasswordEncoder.java b/src/main/java/com/souzip/adapter/security/admin/encoder/SecurePasswordEncoder.java deleted file mode 100644 index 1204d15..0000000 --- a/src/main/java/com/souzip/adapter/security/admin/encoder/SecurePasswordEncoder.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.souzip.adapter.security.admin.encoder; - -import com.souzip.domain.admin.PasswordEncoder; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Component; - -@Component -public class SecurePasswordEncoder implements PasswordEncoder { - private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); - - @Override - public String encode(String password) { - return bCryptPasswordEncoder.encode(password); - } - - @Override - public boolean matches(String password, String passwordHash) { - return bCryptPasswordEncoder.matches(password, passwordHash); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/security/admin/jwt/JwtTokenProviderAdapter.java b/src/main/java/com/souzip/adapter/security/admin/jwt/JwtTokenProviderAdapter.java deleted file mode 100644 index 41b29f2..0000000 --- a/src/main/java/com/souzip/adapter/security/admin/jwt/JwtTokenProviderAdapter.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.souzip.adapter.security.admin.jwt; - -import com.souzip.application.admin.required.TokenProvider; -import com.souzip.global.security.jwt.JwtTokenProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class JwtTokenProviderAdapter implements TokenProvider { - - private final JwtTokenProvider jwtTokenProvider; - - @Override - public String generateAccessToken(String subject) { - return jwtTokenProvider.generateToken(subject); - } - - @Override - public String generateRefreshToken(String subject) { - return jwtTokenProvider.generateRefreshToken(subject); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminApi.java deleted file mode 100644 index 7123a14..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminApi.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.security.admin.annotation.CurrentAdminId; -import com.souzip.adapter.security.admin.annotation.SuperAdminOnly; -import com.souzip.adapter.webapi.admin.dto.AdminRegisterResponse; -import com.souzip.adapter.webapi.admin.dto.AdminResponse; -import com.souzip.application.admin.provided.AdminFinder; -import com.souzip.application.admin.provided.AdminModifier; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRegisterRequest; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RequiredArgsConstructor -@RequestMapping("/api/admin") -@RestController -public class AdminApi { - - private final AdminFinder adminFinder; - private final AdminModifier adminModifier; - - @SuperAdminOnly - @PostMapping("/register") - public SuccessResponse register(@Valid @RequestBody AdminRegisterRequest request) { - return SuccessResponse.of( - AdminRegisterResponse.from(adminModifier.register(request)), - "κ΄€λ¦¬μž 등둝이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." - ); - } - - @SuperAdminOnly - @GetMapping - public SuccessResponse> getAdmins( - @ModelAttribute PaginationRequest paginationRequest - ) { - Page page = adminFinder.findAll(paginationRequest.toPageable()); - - List admins = page.getContent().stream() - .map(AdminResponse::from) - .toList(); - - return SuccessResponse.of(PaginationResponse.of(page, admins)); - } - - @SuperAdminOnly - @DeleteMapping("/{adminId}") - public SuccessResponse deleteAdmin( - @PathVariable UUID adminId, - @CurrentAdminId UUID requesterId - ) { - adminModifier.delete(adminId, requesterId); - - return SuccessResponse.of("κ΄€λ¦¬μžκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminAuthApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminAuthApi.java deleted file mode 100644 index a6e0c31..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminAuthApi.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.security.admin.annotation.CurrentAdminId; -import com.souzip.adapter.webapi.admin.dto.AdminLoginRequest; -import com.souzip.adapter.webapi.admin.dto.AdminLoginResponse; -import com.souzip.adapter.webapi.admin.dto.AdminRefreshRequest; -import com.souzip.adapter.webapi.admin.dto.AdminRefreshResponse; -import com.souzip.application.admin.AdminAuthService; -import com.souzip.global.common.dto.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -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 java.util.UUID; - -@RequiredArgsConstructor -@RequestMapping("/api/admin/auth") -@RestController -public class AdminAuthApi { - - private final AdminAuthService adminAuthService; - - @PostMapping("/login") - public SuccessResponse login(@Valid @RequestBody AdminLoginRequest request) { - return SuccessResponse.of( - AdminLoginResponse.from(adminAuthService.login(request.username(), request.password())) - ); - } - - @PostMapping("/refresh") - public SuccessResponse refresh(@Valid @RequestBody AdminRefreshRequest request) { - return SuccessResponse.of( - AdminRefreshResponse.from(adminAuthService.refresh(request.refreshToken())) - ); - } - - @PostMapping("/logout") - public SuccessResponse logout(@CurrentAdminId UUID adminId) { - adminAuthService.logout(adminId); - - return SuccessResponse.of(null, "λ‘œκ·Έμ•„μ›ƒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminLocationApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminLocationApi.java deleted file mode 100644 index 68df71b..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminLocationApi.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.security.admin.annotation.AdminAccess; -import com.souzip.adapter.security.admin.annotation.ViewerAccess; -import com.souzip.adapter.webapi.admin.dto.CityResponse; -import com.souzip.adapter.webapi.admin.dto.CountryResponse; -import com.souzip.application.admin.provided.AdminLocationFinder; -import com.souzip.application.admin.provided.AdminLocationModifier; -import com.souzip.domain.city.entity.City; -import com.souzip.domain.city.entity.CityCreateRequest; -import com.souzip.domain.city.entity.CityUpdateRequest; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RequiredArgsConstructor -@RequestMapping("/api/admin") -@RestController -public class AdminLocationApi { - - private final AdminLocationFinder adminLocationFinder; - private final AdminLocationModifier adminLocationModifier; - - @ViewerAccess - @GetMapping("/countries") - public SuccessResponse> getCountries( - @RequestParam(required = false) String keyword - ) { - return SuccessResponse.of( - adminLocationFinder.getCountries(keyword).stream() - .map(CountryResponse::from) - .toList() - ); - } - - @ViewerAccess - @GetMapping("/cities") - public SuccessResponse> getCities( - @RequestParam Long countryId, - @RequestParam(required = false) String keyword, - @ModelAttribute PaginationRequest paginationRequest - ) { - Page page = adminLocationFinder.getCities(countryId, keyword, paginationRequest.toPageable()); - - List cities = page.getContent().stream() - .map(CityResponse::from) - .toList(); - - return SuccessResponse.of(PaginationResponse.of(page, cities)); - } - - @AdminAccess - @PostMapping("/cities") - public SuccessResponse createCity(@Valid @RequestBody CityCreateRequest request) { - adminLocationModifier.createCity(request); - return SuccessResponse.of("λ„μ‹œκ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } - - @AdminAccess - @PatchMapping("/cities/{cityId}") - public SuccessResponse updateCity( - @PathVariable Long cityId, - @Valid @RequestBody CityUpdateRequest request - ) { - adminLocationModifier.updateCity(cityId, request); - return SuccessResponse.of("λ„μ‹œ 정보가 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } - - @AdminAccess - @DeleteMapping("/cities/{cityId}") - public SuccessResponse deleteCity(@PathVariable Long cityId) { - adminLocationModifier.deleteCity(cityId); - return SuccessResponse.of("λ„μ‹œκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } - - @AdminAccess - @PatchMapping("/cities/{cityId}/priority") - public SuccessResponse updateCityPriority( - @PathVariable Long cityId, - @RequestParam(required = false) Integer priority - ) { - adminLocationModifier.updateCityPriority(cityId, priority); - return SuccessResponse.of("μš°μ„ μˆœμœ„κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java index cd008b8..6b7c940 100644 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java @@ -1,22 +1,22 @@ package com.souzip.adapter.webapi.admin; -import com.souzip.adapter.security.admin.annotation.AdminAccess; -import com.souzip.adapter.security.admin.annotation.CurrentAdminId; -import com.souzip.adapter.security.admin.annotation.ViewerAccess; import com.souzip.adapter.webapi.admin.dto.NoticeRequest; import com.souzip.application.notice.dto.NoticeResponse; import com.souzip.application.notice.provided.NoticeFinder; import com.souzip.application.notice.provided.NoticeRegister; +import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; import com.souzip.domain.notice.Notice; import com.souzip.global.common.dto.SuccessResponse; import jakarta.validation.Valid; +import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; import java.util.List; -import java.util.Optional; import java.util.UUID; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RequestMapping("/api/admin/notices") @RequiredArgsConstructor diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginRequest.java deleted file mode 100644 index ff90069..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; - -public record AdminLoginRequest( - @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") - String username, - - @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") - String password -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginResponse.java deleted file mode 100644 index 9604322..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminLoginResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.application.admin.dto.AdminLoginResult; -import com.souzip.domain.admin.AdminRole; - -import java.util.UUID; - -public record AdminLoginResponse( - String accessToken, - String refreshToken, - UUID id, - String username, - AdminRole role -) { - public static AdminLoginResponse from(AdminLoginResult result) { - return new AdminLoginResponse( - result.accessToken(), - result.refreshToken(), - result.admin().getId(), - result.admin().getUsername(), - result.admin().getRole() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshRequest.java deleted file mode 100644 index 15f2866..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; - -public record AdminRefreshRequest( - @NotBlank(message = "λ¦¬ν”„λ ˆμ‹œ 토큰은 ν•„μˆ˜μž…λ‹ˆλ‹€.") - String refreshToken -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshResponse.java deleted file mode 100644 index e9ac14b..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRefreshResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.application.admin.dto.AdminRefreshResult; - -public record AdminRefreshResponse( - String accessToken, - String refreshToken -) { - public static AdminRefreshResponse from(AdminRefreshResult result) { - return new AdminRefreshResponse( - result.accessToken(), - result.refreshToken() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRegisterResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRegisterResponse.java deleted file mode 100644 index 827a783..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminRegisterResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRole; - -import java.util.UUID; - -public record AdminRegisterResponse( - UUID adminId, - String username, - AdminRole role -) { - public static AdminRegisterResponse from(Admin admin) { - return new AdminRegisterResponse( - admin.getId(), - admin.getUsername(), - admin.getRole() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminResponse.java deleted file mode 100644 index daebacc..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/AdminResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRole; - -import java.time.LocalDateTime; -import java.util.UUID; - -public record AdminResponse( - UUID id, - String username, - AdminRole role, - LocalDateTime lastLoginAt, - LocalDateTime createdAt -) { - public static AdminResponse from(Admin admin) { - return new AdminResponse( - admin.getId(), - admin.getUsername(), - admin.getRole(), - admin.getLastLoginAt(), - admin.getCreatedAt() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/CityResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/CityResponse.java deleted file mode 100644 index 808ecf2..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/CityResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.domain.city.entity.City; - -import java.math.BigDecimal; - -public record CityResponse( - Long id, - String nameEn, - String nameKr, - BigDecimal latitude, - BigDecimal longitude, - Integer priority -) { - public static CityResponse from(City city) { - return new CityResponse( - city.getId(), - city.getNameEn(), - city.getNameKr(), - city.getLatitude(), - city.getLongitude(), - city.getPriority() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/CountryResponse.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/CountryResponse.java deleted file mode 100644 index 1bdbdd3..0000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/CountryResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import com.souzip.domain.country.entity.Country; - -public record CountryResponse( - Long id, - String nameKr -) { - public static CountryResponse from(Country country) { - return new CountryResponse( - country.getId(), - country.getNameKr() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/AdminAuthService.java b/src/main/java/com/souzip/application/admin/AdminAuthService.java deleted file mode 100644 index f2d1c8b..0000000 --- a/src/main/java/com/souzip/application/admin/AdminAuthService.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.dto.AdminLoginResult; -import com.souzip.application.admin.dto.AdminRefreshResult; -import com.souzip.application.admin.required.AdminRefreshTokenRepository; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.application.admin.required.TokenProvider; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRefreshToken; -import com.souzip.domain.admin.PasswordEncoder; -import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; -import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; -import com.souzip.domain.admin.exception.AdminLoginFailedException; -import com.souzip.domain.admin.exception.AdminNotFoundException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Slf4j -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class AdminAuthService { - - private static final int REFRESH_TOKEN_VALIDITY_DAYS = 30; - private static final int REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS = 10; - - private final AdminRepository adminRepository; - private final AdminRefreshTokenRepository refreshTokenRepository; - private final PasswordEncoder passwordEncoder; - private final TokenProvider tokenProvider; - - @Transactional - public AdminLoginResult login(String username, String password) { - Admin admin = findAndValidateAdmin(username, password); - - admin.login(); - adminRepository.save(admin); - - String accessToken = tokenProvider.generateAccessToken(admin.getId().toString()); - String refreshToken = tokenProvider.generateRefreshToken(admin.getId().toString()); - - saveOrUpdateRefreshToken(admin.getId(), refreshToken); - - return new AdminLoginResult(admin, accessToken, refreshToken); - } - - @Transactional - public AdminRefreshResult refresh(String refreshTokenValue) { - AdminRefreshToken refreshToken = findValidRefreshToken(refreshTokenValue); - Admin admin = adminRepository.findById(refreshToken.getAdminId()) - .orElseThrow(AdminNotFoundException::new); - - String newAccessToken = tokenProvider.generateAccessToken(admin.getId().toString()); - - if (isExpiringSoon(refreshToken)) { - return renewRefreshToken(refreshToken, newAccessToken); - } - - return new AdminRefreshResult(newAccessToken, refreshToken.getToken()); - } - - @Transactional - public void logout(UUID adminId) { - refreshTokenRepository.findByAdminId(adminId) - .ifPresent(refreshTokenRepository::delete); - } - - private Admin findAndValidateAdmin(String username, String password) { - Admin admin = adminRepository.findByUsername(username) - .orElseThrow(AdminNotFoundException::new); - - if (!admin.matchesPassword(password, passwordEncoder)) { - throw new AdminLoginFailedException(); - } - - return admin; - } - - private AdminRefreshToken findValidRefreshToken(String tokenValue) { - AdminRefreshToken refreshToken = refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(AdminInvalidRefreshTokenException::new); - - if (refreshToken.isExpired()) { - refreshTokenRepository.delete(refreshToken); - throw new AdminExpiredRefreshTokenException(); - } - - return refreshToken; - } - - private AdminRefreshResult renewRefreshToken(AdminRefreshToken refreshToken, String newAccessToken) { - String newRefreshToken = tokenProvider.generateRefreshToken( - refreshToken.getAdminId().toString() - ); - - LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - refreshToken.updateToken(newRefreshToken, expiresAt); - - refreshTokenRepository.save(refreshToken); - - return new AdminRefreshResult(newAccessToken, newRefreshToken); - } - - private void saveOrUpdateRefreshToken(UUID adminId, String tokenValue) { - LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - refreshTokenRepository.findByAdminId(adminId) - .ifPresentOrElse( - token -> { - token.updateToken(tokenValue, expiresAt); - refreshTokenRepository.save(token); - }, - () -> refreshTokenRepository.save( - AdminRefreshToken.create(adminId, tokenValue, expiresAt) - ) - ); - } - - private boolean isExpiringSoon(AdminRefreshToken refreshToken) { - return refreshToken.getExpiresAt() - .isBefore(LocalDateTime.now().plusDays(REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS)); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/AdminLocationModifyService.java b/src/main/java/com/souzip/application/admin/AdminLocationModifyService.java deleted file mode 100644 index 062e002..0000000 --- a/src/main/java/com/souzip/application/admin/AdminLocationModifyService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.provided.AdminLocationModifier; -import com.souzip.domain.city.application.command.*; -import com.souzip.domain.city.application.port.CityManagementPort; -import com.souzip.domain.city.entity.CityCreateRequest; -import com.souzip.domain.city.entity.CityUpdateRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Transactional -@RequiredArgsConstructor -@Service -public class AdminLocationModifyService implements AdminLocationModifier { - - private final CityManagementPort cityManagementPort; - - @Override - public void createCity(CityCreateRequest request) { - cityManagementPort.createCity(new CreateCityCommand( - request.nameEn(), - request.nameKr(), - request.coordinate().getLatitude().doubleValue(), - request.coordinate().getLongitude().doubleValue(), - request.countryId() - )); - } - - @Override - public void updateCity(Long cityId, CityUpdateRequest request) { - cityManagementPort.updateCity(new UpdateCityCommand( - cityId, - request.nameEn(), - request.nameKr(), - request.coordinate().getLatitude().doubleValue(), - request.coordinate().getLongitude().doubleValue() - )); - } - - @Override - public void deleteCity(Long cityId) { - cityManagementPort.deleteCity(new DeleteCityCommand(cityId)); - } - - @Override - public void updateCityPriority(Long cityId, Integer priority) { - cityManagementPort.updateCityPriority(new UpdateCityPriorityCommand(cityId, priority)); - } -} diff --git a/src/main/java/com/souzip/application/admin/AdminLocationQueryService.java b/src/main/java/com/souzip/application/admin/AdminLocationQueryService.java deleted file mode 100644 index f07e0b7..0000000 --- a/src/main/java/com/souzip/application/admin/AdminLocationQueryService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.provided.AdminLocationFinder; -import com.souzip.domain.city.entity.City; -import com.souzip.domain.city.repository.CityRepository; -import com.souzip.domain.country.entity.Country; -import com.souzip.domain.country.repository.CountryRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class AdminLocationQueryService implements AdminLocationFinder { - - private final CityRepository cityRepository; - private final CountryRepository countryRepository; - - @Override - public Page getCities(Long countryId, String keyword, Pageable pageable) { - if (keyword == null || keyword.isBlank()) { - return cityRepository.findByCountryIdWithPaging(countryId, pageable); - } - return cityRepository.searchByKeyword(countryId, keyword, pageable); - } - - @Override - public List getCountries(String keyword) { - if (keyword == null || keyword.isBlank()) { - return countryRepository.findAllByOrderByNameKrAsc(); - } - return countryRepository.findByKeywordOrderByNameKrAsc(keyword); - } -} diff --git a/src/main/java/com/souzip/application/admin/AdminModifyService.java b/src/main/java/com/souzip/application/admin/AdminModifyService.java deleted file mode 100644 index 1672312..0000000 --- a/src/main/java/com/souzip/application/admin/AdminModifyService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.provided.AdminFinder; -import com.souzip.application.admin.provided.AdminModifier; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRegisterRequest; -import com.souzip.domain.admin.AdminRole; -import com.souzip.domain.admin.PasswordEncoder; -import com.souzip.domain.admin.exception.AdminErrorCode; -import com.souzip.domain.admin.exception.AdminException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class AdminModifyService implements AdminModifier { - - private final AdminRepository adminRepository; - private final AdminFinder adminFinder; - private final PasswordEncoder passwordEncoder; - - @Transactional - @Override - public Admin register(AdminRegisterRequest request) { - if (request.role() == AdminRole.SUPER_ADMIN) { - throw new AdminException(AdminErrorCode.CANNOT_INVITE_SUPER_ADMIN); - } - - if (adminRepository.existsByUsername(request.username())) { - throw new AdminException(AdminErrorCode.ADMIN_USERNAME_DUPLICATED); - } - - return adminRepository.save(Admin.register(request, passwordEncoder)); - } - - @Transactional - @Override - public void delete(UUID adminId, UUID requesterId) { - Admin admin = adminFinder.findById(adminId); - - adminRepository.delete(admin); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/AdminQueryService.java b/src/main/java/com/souzip/application/admin/AdminQueryService.java deleted file mode 100644 index aa751d8..0000000 --- a/src/main/java/com/souzip/application/admin/AdminQueryService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.provided.AdminFinder; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.exception.AdminNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class AdminQueryService implements AdminFinder { - - private final AdminRepository adminRepository; - - @Override - public Admin findById(UUID adminId) { - return adminRepository.findById(adminId) - .orElseThrow(AdminNotFoundException::new); - } - - @Override - public Page findAll(Pageable pageable) { - return adminRepository.findAll(pageable); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/dto/AdminLoginResult.java b/src/main/java/com/souzip/application/admin/dto/AdminLoginResult.java deleted file mode 100644 index a2706fc..0000000 --- a/src/main/java/com/souzip/application/admin/dto/AdminLoginResult.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.application.admin.dto; - -import com.souzip.domain.admin.Admin; - -public record AdminLoginResult( - Admin admin, - String accessToken, - String refreshToken -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/dto/AdminPageResult.java b/src/main/java/com/souzip/application/admin/dto/AdminPageResult.java deleted file mode 100644 index 409d9fa..0000000 --- a/src/main/java/com/souzip/application/admin/dto/AdminPageResult.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.application.admin.dto; - -import com.souzip.domain.admin.Admin; - -import java.util.List; - -public record AdminPageResult( - List admins, - long total, - int totalPages -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/dto/AdminRefreshResult.java b/src/main/java/com/souzip/application/admin/dto/AdminRefreshResult.java deleted file mode 100644 index 69b2132..0000000 --- a/src/main/java/com/souzip/application/admin/dto/AdminRefreshResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.souzip.application.admin.dto; - -public record AdminRefreshResult( - String accessToken, - String refreshToken -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/provided/AdminFinder.java b/src/main/java/com/souzip/application/admin/provided/AdminFinder.java deleted file mode 100644 index 63227a6..0000000 --- a/src/main/java/com/souzip/application/admin/provided/AdminFinder.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.application.admin.provided; - -import com.souzip.domain.admin.Admin; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.UUID; - -public interface AdminFinder { - Admin findById(UUID adminId); - - Page findAll(Pageable pageable); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/provided/AdminLocationFinder.java b/src/main/java/com/souzip/application/admin/provided/AdminLocationFinder.java deleted file mode 100644 index 5b87646..0000000 --- a/src/main/java/com/souzip/application/admin/provided/AdminLocationFinder.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.souzip.application.admin.provided; - -import com.souzip.domain.city.entity.City; -import com.souzip.domain.country.entity.Country; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.List; - -public interface AdminLocationFinder { - Page getCities(Long countryId, String keyword, Pageable pageable); - - List getCountries(String keyword); -} diff --git a/src/main/java/com/souzip/application/admin/provided/AdminLocationModifier.java b/src/main/java/com/souzip/application/admin/provided/AdminLocationModifier.java deleted file mode 100644 index d8e345c..0000000 --- a/src/main/java/com/souzip/application/admin/provided/AdminLocationModifier.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.souzip.application.admin.provided; - -import com.souzip.domain.city.entity.CityCreateRequest; -import com.souzip.domain.city.entity.CityUpdateRequest; - -public interface AdminLocationModifier { - void createCity(CityCreateRequest request); - - void updateCity(Long cityId, CityUpdateRequest request); - - void deleteCity(Long cityId); - - void updateCityPriority(Long cityId, Integer priority); -} diff --git a/src/main/java/com/souzip/application/admin/provided/AdminModifier.java b/src/main/java/com/souzip/application/admin/provided/AdminModifier.java deleted file mode 100644 index 0988f9b..0000000 --- a/src/main/java/com/souzip/application/admin/provided/AdminModifier.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.souzip.application.admin.provided; - -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminRegisterRequest; - -import java.util.UUID; - -public interface AdminModifier { - Admin register(AdminRegisterRequest request); - - void delete(UUID adminId, UUID requesterId); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/required/AdminRepository.java b/src/main/java/com/souzip/application/admin/required/AdminRepository.java deleted file mode 100644 index 641ccca..0000000 --- a/src/main/java/com/souzip/application/admin/required/AdminRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.souzip.application.admin.required; - -import com.souzip.domain.admin.Admin; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface AdminRepository extends Repository { - Optional findByUsername(String username); - - Optional findById(UUID id); - - Admin save(Admin admin); - - boolean existsByUsername(String username); - - void delete(Admin admin); - - Page findAll(Pageable pageable); - - @Query("SELECT a FROM Admin a WHERE a.id IN :ids") - List findAllByIds(List ids); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/admin/required/TokenProvider.java b/src/main/java/com/souzip/application/admin/required/TokenProvider.java deleted file mode 100644 index 58392d3..0000000 --- a/src/main/java/com/souzip/application/admin/required/TokenProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.application.admin.required; - -import org.springframework.stereotype.Component; - -@Component -public interface TokenProvider { - String generateAccessToken(String subject); - - String generateRefreshToken(String subject); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/application/notice/assembler/NoticeResponseAssembler.java b/src/main/java/com/souzip/application/notice/assembler/NoticeResponseAssembler.java index 6c3ddc7..57cc7d4 100644 --- a/src/main/java/com/souzip/application/notice/assembler/NoticeResponseAssembler.java +++ b/src/main/java/com/souzip/application/notice/assembler/NoticeResponseAssembler.java @@ -1,22 +1,21 @@ package com.souzip.application.notice.assembler; -import com.souzip.application.admin.required.AdminRepository; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.provided.FileFinder; import com.souzip.application.notice.dto.NoticeAuthorResponse; import com.souzip.application.notice.dto.NoticeResponse; -import com.souzip.domain.admin.Admin; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.repository.AdminRepository; import com.souzip.domain.file.EntityType; import com.souzip.domain.notice.Notice; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; @RequiredArgsConstructor @Component @@ -83,7 +82,7 @@ private Map fetchAuthorMap(List notices) { private NoticeAuthorResponse toAuthorResponse(Admin admin) { return NoticeAuthorResponse.of( admin.getId(), - admin.getUsername() + admin.getUsername().value() ); } } diff --git a/src/main/java/com/souzip/domain/admin/Admin.java b/src/main/java/com/souzip/domain/admin/Admin.java deleted file mode 100644 index e039052..0000000 --- a/src/main/java/com/souzip/domain/admin/Admin.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.souzip.domain.admin; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Admin { - - private UUID id; - - private String username; - - private String password; - - private AdminRole role; - - private LocalDateTime lastLoginAt; - - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; - - public static Admin register(AdminRegisterRequest request, PasswordEncoder passwordEncoder) { - Admin admin = new Admin(); - - admin.id = UUID.randomUUID(); - admin.username = request.username(); - admin.password = passwordEncoder.encode(request.password()); - admin.role = request.role(); - admin.createdAt = LocalDateTime.now(); - admin.updatedAt = LocalDateTime.now(); - - return admin; - } - - public void login() { - this.lastLoginAt = LocalDateTime.now(); - } - - public boolean matchesPassword(String password, PasswordEncoder passwordEncoder) { - return passwordEncoder.matches(password, this.password); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/AdminRefreshToken.java b/src/main/java/com/souzip/domain/admin/AdminRefreshToken.java deleted file mode 100644 index cef4b67..0000000 --- a/src/main/java/com/souzip/domain/admin/AdminRefreshToken.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.domain.admin; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static java.util.Objects.requireNonNull; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class AdminRefreshToken { - - private UUID id; - - private UUID adminId; - - private String token; - - private LocalDateTime expiresAt; - - private LocalDateTime createdAt; - - public static AdminRefreshToken create(UUID adminId, String token, LocalDateTime expiresAt) { - AdminRefreshToken refreshToken = new AdminRefreshToken(); - - refreshToken.id = UUID.randomUUID(); - refreshToken.adminId = requireNonNull(adminId, "μ–΄λ“œλ―Ό IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); - refreshToken.token = requireNonNull(token, "토큰은 ν•„μˆ˜μž…λ‹ˆλ‹€."); - refreshToken.expiresAt = requireNonNull(expiresAt, "λ§Œλ£ŒμΌμ€ ν•„μˆ˜μž…λ‹ˆλ‹€."); - refreshToken.createdAt = LocalDateTime.now(); - - return refreshToken; - } - - public void updateToken(String token, LocalDateTime expiresAt) { - this.token = requireNonNull(token, "토큰은 ν•„μˆ˜μž…λ‹ˆλ‹€."); - this.expiresAt = requireNonNull(expiresAt, "λ§Œλ£ŒμΌμ€ ν•„μˆ˜μž…λ‹ˆλ‹€."); - } - - public boolean isExpired() { - return LocalDateTime.now().isAfter(this.expiresAt); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/AdminRegisterRequest.java b/src/main/java/com/souzip/domain/admin/AdminRegisterRequest.java deleted file mode 100644 index 60de1cf..0000000 --- a/src/main/java/com/souzip/domain/admin/AdminRegisterRequest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.domain.admin; - -import com.souzip.domain.admin.exception.AdminErrorCode; -import com.souzip.domain.admin.exception.AdminException; - -import static java.util.Objects.requireNonNull; - -public record AdminRegisterRequest( - String username, - String password, - AdminRole role -) { - private static final int MIN_USERNAME_LENGTH = 2; - private static final int MAX_USERNAME_LENGTH = 20; - private static final int MIN_PASSWORD_LENGTH = 8; - - public AdminRegisterRequest { - username = validateUsername(username); - validatePassword(password); - requireNonNull(role, "역할은 ν•„μˆ˜μž…λ‹ˆλ‹€."); - } - - public static AdminRegisterRequest of(String username, String password, AdminRole role) { - return new AdminRegisterRequest(username, password, role); - } - - private static String validateUsername(String username) { - requireNonNull(username, "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); - - String sanitized = username.trim(); - - if (sanitized.length() < MIN_USERNAME_LENGTH || sanitized.length() > MAX_USERNAME_LENGTH) { - throw new AdminException(AdminErrorCode.INVALID_USERNAME_LENGTH); - } - - return sanitized; - } - - private static void validatePassword(String password) { - requireNonNull(password, "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€."); - - if (password.length() < MIN_PASSWORD_LENGTH) { - throw new AdminException(AdminErrorCode.INVALID_PASSWORD); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/PasswordEncoder.java b/src/main/java/com/souzip/domain/admin/PasswordEncoder.java deleted file mode 100644 index d5b0a77..0000000 --- a/src/main/java/com/souzip/domain/admin/PasswordEncoder.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.domain.admin; - -public interface PasswordEncoder { - - String encode(String password); - - boolean matches(String password, String encodedPassword); -} diff --git a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java new file mode 100644 index 0000000..73c9282 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java @@ -0,0 +1,145 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.command.AdminLoginCommand; +import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; +import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; +import com.souzip.domain.admin.exception.AdminLoginFailedException; +import com.souzip.domain.admin.exception.AdminNotFoundException; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import com.souzip.domain.admin.model.AdminRefreshToken; +import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; +import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AdminAuthService { + + private static final int REFRESH_TOKEN_VALIDITY_DAYS = 30; + private static final int REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS = 10; + + private final AdminRepository adminRepository; + private final AdminRefreshTokenRepository refreshTokenRepository; + private final AdminPasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public AdminLoginResult login(AdminLoginCommand command) { + Admin admin = adminRepository.findByUsername(new Username(command.username())) + .orElseThrow(AdminNotFoundException::new); + + validatePassword(admin, command.password()); + + admin.recordLoginSuccess(); + Admin savedAdmin = adminRepository.save(admin); + + String accessToken = jwtTokenProvider.generateToken(savedAdmin.getId().toString()); + String refreshToken = jwtTokenProvider.generateRefreshToken(savedAdmin.getId().toString()); + + saveRefreshToken(savedAdmin, refreshToken); + + return new AdminLoginResult(savedAdmin, accessToken, refreshToken); + } + + @Transactional + public RefreshResult refresh(String refreshTokenValue) { + AdminRefreshToken refreshToken = findRefreshToken(refreshTokenValue); + validateRefreshToken(refreshToken); + + Admin admin = adminRepository.findById(refreshToken.getAdminId()) + .orElseThrow(AdminNotFoundException::new); + + String newAccessToken = jwtTokenProvider.generateToken(admin.getId().toString()); + + if (isRefreshTokenExpiringSoon(refreshToken)) { + return renewRefreshToken(refreshToken, admin, newAccessToken); + } + + return new RefreshResult(newAccessToken, refreshToken.getToken()); + } + + @Transactional + public void logout(UUID adminId) { + refreshTokenRepository.findByAdminId(adminId) + .ifPresent(refreshTokenRepository::delete); + } + + private void validatePassword(Admin admin, String password) { + if (!admin.matchesPassword(password, passwordEncoder)) { + throw new AdminLoginFailedException(); + } + } + + private void saveRefreshToken(Admin admin, String tokenValue) { + LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); + + refreshTokenRepository.findByAdminId(admin.getId()) + .ifPresentOrElse( + token -> updateExistingToken(token, tokenValue, expiresAt), + () -> createNewToken(admin.getId(), tokenValue, expiresAt) + ); + } + + private void updateExistingToken(AdminRefreshToken token, String tokenValue, LocalDateTime expiresAt) { + token.updateToken(tokenValue, expiresAt); + refreshTokenRepository.save(token); + } + + private void createNewToken(UUID adminId, String tokenValue, LocalDateTime expiresAt) { + AdminRefreshToken newToken = AdminRefreshToken.create(adminId, tokenValue, expiresAt); + refreshTokenRepository.save(newToken); + } + + private AdminRefreshToken findRefreshToken(String tokenValue) { + return refreshTokenRepository.findByToken(tokenValue) + .orElseThrow(AdminInvalidRefreshTokenException::new); + } + + private void validateRefreshToken(AdminRefreshToken refreshToken) { + if (refreshToken.isExpired()) { + deleteExpiredToken(refreshToken); + throw new AdminExpiredRefreshTokenException(); + } + } + + private void deleteExpiredToken(AdminRefreshToken refreshToken) { + refreshTokenRepository.delete(refreshToken); + log.info("만료된 Admin Refresh Token μ‚­μ œ: token={}", refreshToken.getId()); + } + + private boolean isRefreshTokenExpiringSoon(AdminRefreshToken refreshToken) { + LocalDateTime threshold = LocalDateTime.now().plusDays(REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS); + return refreshToken.getExpiresAt().isBefore(threshold); + } + + private RefreshResult renewRefreshToken(AdminRefreshToken refreshToken, Admin admin, String newAccessToken) { + String newRefreshToken = jwtTokenProvider.generateRefreshToken(admin.getId().toString()); + updateExistingToken(refreshToken, newRefreshToken, LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS)); + + log.info("Admin Refresh Token κ°±μ‹ : adminId={}", admin.getId()); + + return new RefreshResult(newAccessToken, newRefreshToken); + } + + public record AdminLoginResult( + Admin admin, + String accessToken, + String refreshToken + ) {} + + public record RefreshResult( + String accessToken, + String refreshToken + ) {} +} diff --git a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java new file mode 100644 index 0000000..0000b79 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java @@ -0,0 +1,10 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; +import com.souzip.domain.admin.application.query.CitySearchQuery; +import com.souzip.global.common.dto.pagination.PaginationResponse; + +public interface AdminCityQueryUseCase { + + PaginationResponse getCities(CitySearchQuery query); +} diff --git a/src/main/java/com/souzip/domain/admin/application/AdminCountryQueryUseCase.java b/src/main/java/com/souzip/domain/admin/application/AdminCountryQueryUseCase.java new file mode 100644 index 0000000..af22774 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/AdminCountryQueryUseCase.java @@ -0,0 +1,9 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.port.CountryQueryPort.CountryQueryResult; +import java.util.List; + +public interface AdminCountryQueryUseCase { + + List getCountries(String keyword); +} diff --git a/src/main/java/com/souzip/domain/admin/application/AdminManagementService.java b/src/main/java/com/souzip/domain/admin/application/AdminManagementService.java new file mode 100644 index 0000000..46536dd --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/AdminManagementService.java @@ -0,0 +1,120 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.command.InviteAdminCommand; +import com.souzip.domain.admin.application.port.CityCommandPort; +import com.souzip.domain.admin.exception.AdminErrorCode; +import com.souzip.domain.admin.exception.AdminException; +import com.souzip.domain.admin.exception.AdminNotFoundException; +import com.souzip.domain.admin.infrastructure.encoder.AdminPasswordEncoderImpl; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.repository.AdminRepository; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class AdminManagementService implements AdminManagementUseCase { + + private final AdminRepository adminRepository; + private final AdminPasswordEncoderImpl passwordEncoder; + private final CityCommandPort cityCommandPort; + + @Override + public AdminPageResult getAdmins(int pageNo, int pageSize) { + int offset = (pageNo - 1) * pageSize; + List admins = adminRepository.findAllExcludingSuperAdmin(offset, pageSize); + long total = adminRepository.countExcludingSuperAdmin(); + int totalPages = (int) Math.ceil((double) total / pageSize); + + return new AdminPageResult(admins, pageNo, pageSize, total, totalPages); + } + + @Transactional + @Override + public Admin inviteAdmin(InviteAdminCommand command) { + validateNotSuperAdmin(command.role()); + validateUsernameNotDuplicated(command.username()); + + Admin admin = createAdmin(command); + return adminRepository.save(admin); + } + + @Transactional + @Override + public void deleteAdmin(UUID adminId, UUID requesterId) { + Admin adminToDelete = adminRepository.findById(adminId) + .orElseThrow(AdminNotFoundException::new); + + adminRepository.delete(adminToDelete); + } + + @Transactional + @Override + public void createCity(AdminCreateCityCommand command) { + cityCommandPort.createCity(command); + } + + @Transactional + @Override + public void updateCity(AdminUpdateCityCommand command) { + cityCommandPort.updateCity(command); + } + + @Transactional + @Override + public void deleteCity(AdminDeleteCityCommand command) { + cityCommandPort.deleteCity(command); + } + + @Transactional + @Override + public void updateCityPriority(AdminUpdateCityPriorityCommand command) { + cityCommandPort.updateCityPriority(command); + } + + private void validateNotSuperAdmin(AdminRole role) { + if (isSuperAdmin(role)) { + throw new AdminException(AdminErrorCode.CANNOT_INVITE_SUPER_ADMIN); + } + } + + private boolean isSuperAdmin(AdminRole role) { + return role == AdminRole.SUPER_ADMIN; + } + + private void validateUsernameNotDuplicated(String username) { + if (isUsernameDuplicated(username)) { + throw new AdminException(AdminErrorCode.ADMIN_USERNAME_DUPLICATED); + } + } + + private boolean isUsernameDuplicated(String username) { + return adminRepository.existsByUsername(username); + } + + private Admin createAdmin(InviteAdminCommand command) { + return Admin.create( + command.username(), + command.password(), + command.role(), + passwordEncoder + ); + } + + public record AdminPageResult( + List admins, + int pageNo, + int pageSize, + long total, + int totalPages + ) {} +} diff --git a/src/main/java/com/souzip/domain/admin/application/AdminManagementUseCase.java b/src/main/java/com/souzip/domain/admin/application/AdminManagementUseCase.java new file mode 100644 index 0000000..da8c21b --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/AdminManagementUseCase.java @@ -0,0 +1,27 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.command.InviteAdminCommand; +import com.souzip.domain.admin.model.Admin; +import java.util.UUID; + +public interface AdminManagementUseCase { + + AdminPageResult getAdmins(int pageNo, int pageSize); + + Admin inviteAdmin(InviteAdminCommand command); + + void deleteAdmin(UUID adminId, UUID requesterId); + + void updateCityPriority(AdminUpdateCityPriorityCommand command); + + void updateCity(AdminUpdateCityCommand command); + + void createCity(AdminCreateCityCommand command); + + void deleteCity(AdminDeleteCityCommand command); +} diff --git a/src/main/java/com/souzip/domain/admin/application/command/AdminCreateCityCommand.java b/src/main/java/com/souzip/domain/admin/application/command/AdminCreateCityCommand.java new file mode 100644 index 0000000..4ea9583 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/AdminCreateCityCommand.java @@ -0,0 +1,9 @@ +package com.souzip.domain.admin.application.command; + +public record AdminCreateCityCommand( + String nameEn, + String nameKr, + Double latitude, + Double longitude, + Long countryId +) {} diff --git a/src/main/java/com/souzip/domain/admin/application/command/AdminDeleteCityCommand.java b/src/main/java/com/souzip/domain/admin/application/command/AdminDeleteCityCommand.java new file mode 100644 index 0000000..b7caa8f --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/AdminDeleteCityCommand.java @@ -0,0 +1,5 @@ +package com.souzip.domain.admin.application.command; + +public record AdminDeleteCityCommand( + Long cityId +) {} diff --git a/src/main/java/com/souzip/domain/admin/application/command/AdminLoginCommand.java b/src/main/java/com/souzip/domain/admin/application/command/AdminLoginCommand.java new file mode 100644 index 0000000..fe2e8ed --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/AdminLoginCommand.java @@ -0,0 +1,6 @@ +package com.souzip.domain.admin.application.command; + +public record AdminLoginCommand( + String username, + String password +) {} diff --git a/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityCommand.java b/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityCommand.java new file mode 100644 index 0000000..45c2fd7 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityCommand.java @@ -0,0 +1,8 @@ +package com.souzip.domain.admin.application.command; + +public record AdminUpdateCityCommand( + Long cityId, + String nameEn, + String nameKr +) { +} diff --git a/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityPriorityCommand.java b/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityPriorityCommand.java new file mode 100644 index 0000000..36d696d --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/AdminUpdateCityPriorityCommand.java @@ -0,0 +1,6 @@ +package com.souzip.domain.admin.application.command; + +public record AdminUpdateCityPriorityCommand( + Long cityId, + Integer newPriority +) {} diff --git a/src/main/java/com/souzip/domain/admin/application/command/InviteAdminCommand.java b/src/main/java/com/souzip/domain/admin/application/command/InviteAdminCommand.java new file mode 100644 index 0000000..af824f9 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/command/InviteAdminCommand.java @@ -0,0 +1,9 @@ +package com.souzip.domain.admin.application.command; + +import com.souzip.domain.admin.model.AdminRole; + +public record InviteAdminCommand( + String username, + String password, + AdminRole role +) {} diff --git a/src/main/java/com/souzip/domain/admin/application/port/CityCommandPort.java b/src/main/java/com/souzip/domain/admin/application/port/CityCommandPort.java new file mode 100644 index 0000000..2e1ec4b --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/port/CityCommandPort.java @@ -0,0 +1,17 @@ +package com.souzip.domain.admin.application.port; + +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; + +public interface CityCommandPort { + + void createCity(AdminCreateCityCommand command); + + void deleteCity(AdminDeleteCityCommand command); + + void updateCityPriority(AdminUpdateCityPriorityCommand command); + + void updateCity(AdminUpdateCityCommand command); +} diff --git a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java new file mode 100644 index 0000000..49ac778 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java @@ -0,0 +1,17 @@ +package com.souzip.domain.admin.application.port; + +import com.souzip.global.common.dto.pagination.PaginationResponse; +import java.time.LocalDateTime; + +public interface CityQueryPort { + + PaginationResponse getCities(Long countryId, String keyword, int pageNo, int pageSize); + + record CityQueryResult( + Long id, + String nameKr, + String nameEn, + Integer priority, + LocalDateTime updatedAt + ) {} +} diff --git a/src/main/java/com/souzip/domain/admin/application/port/CountryQueryPort.java b/src/main/java/com/souzip/domain/admin/application/port/CountryQueryPort.java new file mode 100644 index 0000000..f1d6147 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/port/CountryQueryPort.java @@ -0,0 +1,12 @@ +package com.souzip.domain.admin.application.port; + +import java.util.List; + +public interface CountryQueryPort { + List getCountries(String keyword); + + record CountryQueryResult( + Long id, + String nameKr + ) {} +} diff --git a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java new file mode 100644 index 0000000..e59ca11 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java @@ -0,0 +1,24 @@ +package com.souzip.domain.admin.application.query; + +import com.souzip.domain.admin.application.AdminCityQueryUseCase; +import com.souzip.domain.admin.application.port.CityQueryPort; +import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class AdminCityQueryService implements AdminCityQueryUseCase { + + private final CityQueryPort cityQueryPort; + + @Override + public PaginationResponse getCities(CitySearchQuery query) { + return cityQueryPort.getCities( + query.countryId(), query.keyword(), query.pageNo(), query.pageSize() + ); + } +} diff --git a/src/main/java/com/souzip/domain/admin/application/query/AdminCountryQueryService.java b/src/main/java/com/souzip/domain/admin/application/query/AdminCountryQueryService.java new file mode 100644 index 0000000..0d4fa6e --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/query/AdminCountryQueryService.java @@ -0,0 +1,22 @@ +package com.souzip.domain.admin.application.query; + +import com.souzip.domain.admin.application.AdminCountryQueryUseCase; +import com.souzip.domain.admin.application.port.CountryQueryPort; +import com.souzip.domain.admin.application.port.CountryQueryPort.CountryQueryResult; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class AdminCountryQueryService implements AdminCountryQueryUseCase { + + private final CountryQueryPort countryQueryPort; + + @Override + public List getCountries(String keyword) { + return countryQueryPort.getCountries(keyword); + } +} diff --git a/src/main/java/com/souzip/domain/admin/application/query/CitySearchQuery.java b/src/main/java/com/souzip/domain/admin/application/query/CitySearchQuery.java new file mode 100644 index 0000000..b0db506 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/application/query/CitySearchQuery.java @@ -0,0 +1,12 @@ +package com.souzip.domain.admin.application.query; + +public record CitySearchQuery( + Long countryId, + String keyword, + int pageNo, + int pageSize +) { + public static CitySearchQuery of(Long countryId, String keyword, int pageNo, int pageSize) { + return new CitySearchQuery(countryId, keyword, pageNo, pageSize); + } +} diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java index d608fc6..d71940c 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java @@ -5,8 +5,8 @@ public enum AdminErrorCode implements BaseErrorCode { + INVALID_USERNAME_EMPTY(HttpStatus.BAD_REQUEST, "μ•„μ΄λ””λŠ” λΉ„μ–΄μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€."), INVALID_USERNAME_LENGTH(HttpStatus.BAD_REQUEST, "μ•„μ΄λ””λŠ” 2자 이상 20자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€."), - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ 8자 이상이어야 ν•©λ‹ˆλ‹€."), CANNOT_INVITE_SUPER_ADMIN(HttpStatus.BAD_REQUEST, "졜고 κ΄€λ¦¬μžλŠ” μ΄ˆλŒ€ν•  수 μ—†μŠ΅λ‹ˆλ‹€."), ADMIN_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έκ°€ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminException.java b/src/main/java/com/souzip/domain/admin/exception/AdminException.java index af6193b..1645bfa 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminException.java @@ -3,7 +3,6 @@ import com.souzip.global.exception.BusinessException; public class AdminException extends BusinessException { - public AdminException(AdminErrorCode errorCode) { super(errorCode); } diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/encoder/AdminPasswordEncoderImpl.java b/src/main/java/com/souzip/domain/admin/infrastructure/encoder/AdminPasswordEncoderImpl.java new file mode 100644 index 0000000..69b81c9 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/encoder/AdminPasswordEncoderImpl.java @@ -0,0 +1,23 @@ +package com.souzip.domain.admin.infrastructure.encoder; + +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class AdminPasswordEncoderImpl implements AdminPasswordEncoder { + + private final PasswordEncoder passwordEncoder; + + @Override + public String encode(String rawPassword) { + return passwordEncoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return passwordEncoder.matches(rawPassword, encodedPassword); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminEntity.java b/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminEntity.java new file mode 100644 index 0000000..108741f --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminEntity.java @@ -0,0 +1,44 @@ +package com.souzip.domain.admin.infrastructure.entity; + +import com.souzip.domain.admin.model.AdminRole; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "admin") +@EntityListeners(AuditingEntityListener.class) +@Entity +public class AdminEntity { + + @Id + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AdminRole role; + + private LocalDateTime lastLoginAt; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminRefreshTokenEntity.java b/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminRefreshTokenEntity.java new file mode 100644 index 0000000..5b4e19a --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/entity/AdminRefreshTokenEntity.java @@ -0,0 +1,36 @@ +package com.souzip.domain.admin.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "admin_refresh_token") +@EntityListeners(AuditingEntityListener.class) +@Entity +public class AdminRefreshTokenEntity { + + @Id + @Column(columnDefinition = "uuid", updatable = false, nullable = false) + private UUID id; + + @Column(nullable = false) + private UUID adminId; + + @Column(nullable = false, unique = true, length = 500) + private String token; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminInitializer.java b/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminInitializer.java new file mode 100644 index 0000000..37e77ca --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminInitializer.java @@ -0,0 +1,46 @@ +package com.souzip.domain.admin.infrastructure.init; + +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.repository.AdminRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@EnableConfigurationProperties(AdminProperties.class) +@Component +public class AdminInitializer implements ApplicationRunner { + + private final AdminRepository adminRepository; + private final AdminProperties adminProperties; + private final AdminPasswordEncoder passwordEncoder; + + @Override + public void run(ApplicationArguments args) { + Username username = new Username(adminProperties.getUsername()); + + adminRepository.findByUsername(username).ifPresentOrElse( + admin -> log.info("초기 μ–΄λ“œλ―Ό 계정이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€. username={}", username.value()), + () -> createInitialAdmin(username) + ); + } + + private void createInitialAdmin(Username username) { + Admin admin = Admin.create( + username.value(), + adminProperties.getPassword(), + AdminRole.SUPER_ADMIN, + passwordEncoder + ); + + adminRepository.save(admin); + log.info("초기 μ–΄λ“œλ―Ό 계정이 μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€. username={}", username.value()); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminProperties.java b/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminProperties.java new file mode 100644 index 0000000..6cbe01d --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/init/AdminProperties.java @@ -0,0 +1,17 @@ +package com.souzip.domain.admin.infrastructure.init; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "admin.initial") +public class +AdminProperties { + private final String username; + private final String password; + + public AdminProperties(String username, String password) { + this.username = username; + this.password = password; + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminJpaRepository.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminJpaRepository.java new file mode 100644 index 0000000..c85c0dc --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminJpaRepository.java @@ -0,0 +1,25 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.infrastructure.entity.AdminEntity; +import com.souzip.domain.admin.model.AdminRole; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminJpaRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + + Page findByRoleNot(AdminRole role, Pageable pageable); + + long countByRoleNot(AdminRole role); + + List findAllByIdIn(List ids); +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapper.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapper.java new file mode 100644 index 0000000..a062f68 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapper.java @@ -0,0 +1,33 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.infrastructure.entity.AdminEntity; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.Password; +import com.souzip.domain.admin.model.Username; +import org.springframework.stereotype.Component; + +@Component +public class AdminMapper { + + public Admin toDomain(AdminEntity entity) { + return Admin.restore( + entity.getId(), + new Username(entity.getUsername()), + Password.of(entity.getPassword()), + entity.getRole(), + entity.getLastLoginAt(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public AdminEntity toEntity(Admin admin) { + return AdminEntity.builder() + .id(admin.getId()) + .username(admin.getUsername().value()) + .password(admin.getPassword().getEncodedValue()) + .role(admin.getRole()) + .lastLoginAt(admin.getLastLoginAt()) + .build(); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenJpaRepository.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenJpaRepository.java new file mode 100644 index 0000000..2d2231b --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenJpaRepository.java @@ -0,0 +1,19 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.infrastructure.entity.AdminRefreshTokenEntity; +import java.time.LocalDateTime; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public interface AdminRefreshTokenJpaRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByAdminId(UUID adminId); + + int deleteAllByExpiresAtBefore(LocalDateTime dateTime); +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapper.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapper.java new file mode 100644 index 0000000..0103998 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapper.java @@ -0,0 +1,28 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.infrastructure.entity.AdminRefreshTokenEntity; +import com.souzip.domain.admin.model.AdminRefreshToken; +import org.springframework.stereotype.Component; + +@Component +public class AdminRefreshTokenMapper { + + public AdminRefreshTokenEntity toEntity(AdminRefreshToken domain) { + return AdminRefreshTokenEntity.builder() + .id(domain.getId()) + .adminId(domain.getAdminId()) + .token(domain.getToken()) + .expiresAt(domain.getExpiresAt()) + .build(); + } + + public AdminRefreshToken toDomain(AdminRefreshTokenEntity entity) { + return AdminRefreshToken.restore( + entity.getId(), + entity.getAdminId(), + entity.getToken(), + entity.getExpiresAt(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryImpl.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryImpl.java new file mode 100644 index 0000000..8782cff --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.model.AdminRefreshToken; +import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@Repository +public class AdminRefreshTokenRepositoryImpl implements AdminRefreshTokenRepository { + + private final AdminRefreshTokenJpaRepository jpaRepository; + private final AdminRefreshTokenMapper mapper; + + @Override + public AdminRefreshToken save(AdminRefreshToken refreshToken) { + return mapper.toDomain(jpaRepository.save(mapper.toEntity(refreshToken))); + } + + @Override + public Optional findByToken(String token) { + return jpaRepository.findByToken(token) + .map(mapper::toDomain); + } + + @Override + public Optional findByAdminId(UUID adminId) { + return jpaRepository.findByAdminId(adminId) + .map(mapper::toDomain); + } + + @Override + public void delete(AdminRefreshToken refreshToken) { + jpaRepository.delete(mapper.toEntity(refreshToken)); + } + + @Override + public int deleteAllByExpiresAtBefore(LocalDateTime dateTime) { + return jpaRepository.deleteAllByExpiresAtBefore(dateTime); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryImpl.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryImpl.java new file mode 100644 index 0000000..4ae9a08 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.repository.AdminRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AdminRepositoryImpl implements AdminRepository { + + private final AdminJpaRepository jpaRepository; + private final AdminMapper mapper; + + @Override + public Optional findByUsername(Username username) { + return jpaRepository.findByUsername(username.value()) + .map(mapper::toDomain); + } + + @Override + public Optional findById(UUID id) { + return jpaRepository.findById(id) + .map(mapper::toDomain); + } + + @Override + public Admin save(Admin admin) { + return mapper.toDomain(jpaRepository.save(mapper.toEntity(admin))); + } + + @Override + public boolean existsByUsername(String username) { + return jpaRepository.existsByUsername(username); + } + + @Override + public List findAllExcludingSuperAdmin(int offset, int limit) { + Pageable pageable = PageRequest.of( + offset / limit, + limit, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + return jpaRepository.findByRoleNot(AdminRole.SUPER_ADMIN, pageable) + .stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public long countExcludingSuperAdmin() { + return jpaRepository.countByRoleNot(AdminRole.SUPER_ADMIN); + } + + @Override + public void delete(Admin admin) { + jpaRepository.delete(mapper.toEntity(admin)); + } + + @Override + public List findAllByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + + return jpaRepository.findAllByIdIn(ids).stream() + .map(mapper::toDomain) + .toList(); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityCommandAdapter.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityCommandAdapter.java new file mode 100644 index 0000000..853bc38 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityCommandAdapter.java @@ -0,0 +1,58 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.port.CityCommandPort; +import com.souzip.domain.city.application.command.CreateCityCommand; +import com.souzip.domain.city.application.command.DeleteCityCommand; +import com.souzip.domain.city.application.command.UpdateCityCommand; +import com.souzip.domain.city.application.command.UpdateCityPriorityCommand; +import com.souzip.domain.city.application.port.CityManagementPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CityCommandAdapter implements CityCommandPort { + + private final CityManagementPort cityManagementPort; + + @Override + public void createCity(AdminCreateCityCommand adminCommand) { + CreateCityCommand cityCommand = new CreateCityCommand( + adminCommand.nameEn(), + adminCommand.nameKr(), + adminCommand.latitude(), + adminCommand.longitude(), + adminCommand.countryId() + ); + cityManagementPort.createCity(cityCommand); + } + + @Override + public void updateCity(AdminUpdateCityCommand adminCommand) { + UpdateCityCommand cityCommand = new UpdateCityCommand( + adminCommand.cityId(), + adminCommand.nameEn(), + adminCommand.nameKr() + ); + cityManagementPort.updateCity(cityCommand); + } + + @Override + public void deleteCity(AdminDeleteCityCommand adminCommand) { + DeleteCityCommand cityCommand = new DeleteCityCommand(adminCommand.cityId()); + cityManagementPort.deleteCity(cityCommand); + } + + @Override + public void updateCityPriority(AdminUpdateCityPriorityCommand adminCommand) { + UpdateCityPriorityCommand cityCommand = new UpdateCityPriorityCommand( + adminCommand.cityId(), + adminCommand.newPriority() + ); + cityManagementPort.updateCityPriority(cityCommand); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java new file mode 100644 index 0000000..4bd49b6 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java @@ -0,0 +1,43 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.application.port.CityQueryPort; +import com.souzip.domain.city.application.port.CityAdminPort; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CityQueryAdapter implements CityQueryPort { + + private final CityAdminPort cityAdminPort; + + @Override + public PaginationResponse getCities( + Long countryId, + String keyword, + int pageNo, + int pageSize + ) { + Page page = cityAdminPort.getCities( + countryId, + keyword, + PageRequest.of(pageNo - 1, pageSize) + ); + + List content = page.getContent().stream() + .map(c -> new CityQueryResult( + c.id(), + c.nameKr(), + c.nameEn(), + c.priority(), + c.updatedAt() + )) + .toList(); + + return PaginationResponse.of(page, content); + } +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CountryQueryAdapter.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CountryQueryAdapter.java new file mode 100644 index 0000000..06027f2 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CountryQueryAdapter.java @@ -0,0 +1,24 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.application.port.CountryQueryPort; +import com.souzip.domain.country.application.port.CountryAdminPort; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CountryQueryAdapter implements CountryQueryPort { + + private final CountryAdminPort countryAdminPort; + + @Override + public List getCountries(String keyword) { + return countryAdminPort.getCountries(keyword).stream() + .map(c -> new CountryQueryResult( + c.id(), + c.nameKr() + )) + .toList(); + } +} diff --git a/src/main/java/com/souzip/adapter/scheduler/AdminRefreshTokenCleanupScheduler.java b/src/main/java/com/souzip/domain/admin/infrastructure/scheduler/AdminRefreshTokenCleanupScheduler.java similarity index 83% rename from src/main/java/com/souzip/adapter/scheduler/AdminRefreshTokenCleanupScheduler.java rename to src/main/java/com/souzip/domain/admin/infrastructure/scheduler/AdminRefreshTokenCleanupScheduler.java index 1694506..f7b6887 100644 --- a/src/main/java/com/souzip/adapter/scheduler/AdminRefreshTokenCleanupScheduler.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/scheduler/AdminRefreshTokenCleanupScheduler.java @@ -1,15 +1,13 @@ -package com.souzip.adapter.scheduler; +package com.souzip.domain.admin.infrastructure.scheduler; - -import com.souzip.application.admin.required.AdminRefreshTokenRepository; +import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Slf4j @RequiredArgsConstructor @Component @@ -23,6 +21,6 @@ public void cleanUpExpiredAdminRefreshTokens() { LocalDateTime now = LocalDateTime.now(); int deletedCount = adminRefreshTokenRepository.deleteAllByExpiresAtBefore(now); log.info("[Admin Token Scheduler] 만료된 Admin Refresh Token {}개 μ‚­μ œ μ™„λ£Œ (μ‹€ν–‰ μ‹œκ°: {})", - deletedCount, now); + deletedCount, now); } } diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/AdminAccess.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/AdminAccess.java new file mode 100644 index 0000000..17a9abe --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/AdminAccess.java @@ -0,0 +1,16 @@ +package com.souzip.domain.admin.infrastructure.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") +public @interface AdminAccess { +} diff --git a/src/main/java/com/souzip/adapter/security/admin/annotation/CurrentAdminId.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/CurrentAdminId.java similarity index 79% rename from src/main/java/com/souzip/adapter/security/admin/annotation/CurrentAdminId.java rename to src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/CurrentAdminId.java index 93a3987..b976d74 100644 --- a/src/main/java/com/souzip/adapter/security/admin/annotation/CurrentAdminId.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/CurrentAdminId.java @@ -1,4 +1,4 @@ -package com.souzip.adapter.security.admin.annotation; +package com.souzip.domain.admin.infrastructure.security.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/SuperAdminOnly.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/SuperAdminOnly.java new file mode 100644 index 0000000..74f4601 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/SuperAdminOnly.java @@ -0,0 +1,16 @@ +package com.souzip.domain.admin.infrastructure.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@PreAuthorize("hasRole('SUPER_ADMIN')") +public @interface SuperAdminOnly { +} diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/ViewerAccess.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/ViewerAccess.java new file mode 100644 index 0000000..5572d37 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/annotation/ViewerAccess.java @@ -0,0 +1,15 @@ +package com.souzip.domain.admin.infrastructure.security.annotation; + +import org.springframework.security.access.prepost.PreAuthorize; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@PreAuthorize("hasAnyRole('VIEWER', 'ADMIN', 'SUPER_ADMIN')") +public @interface ViewerAccess { +} diff --git a/src/main/java/com/souzip/adapter/security/admin/jwt/AdminJwtAuthenticationFilter.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java similarity index 83% rename from src/main/java/com/souzip/adapter/security/admin/jwt/AdminJwtAuthenticationFilter.java rename to src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java index 36dc06d..f99ddbd 100644 --- a/src/main/java/com/souzip/adapter/security/admin/jwt/AdminJwtAuthenticationFilter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java @@ -1,7 +1,7 @@ -package com.souzip.adapter.security.admin.jwt; +package com.souzip.domain.admin.infrastructure.security.jwt; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.Admin; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.repository.AdminRepository; import com.souzip.global.security.jwt.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -10,8 +10,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.GrantedAuthority; // ← μΆ”κ°€ +import org.springframework.security.core.authority.SimpleGrantedAuthority; // ← μΆ”κ°€ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -19,7 +19,7 @@ import java.io.IOException; import java.util.Collections; -import java.util.List; +import java.util.List; // ← μΆ”κ°€ import java.util.UUID; @Slf4j @@ -35,11 +35,9 @@ public class AdminJwtAuthenticationFilter extends OncePerRequestFilter { private final AdminRepository adminRepository; @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain - ) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { if (isAdminPath(request)) { try { @@ -104,7 +102,6 @@ private boolean isTokenInvalid(String token) { private Admin getAdminFromToken(String token) { String adminId = jwtTokenProvider.getUserIdFromToken(token); - return adminRepository.findById(UUID.fromString(adminId)).orElse(null); } @@ -114,11 +111,11 @@ private boolean isAdminAbsent(Admin admin) { private void setAuthentication(Admin admin) { List authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) + new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) ); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(admin, null, authorities); + new UsernamePasswordAuthenticationToken(admin, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/com/souzip/adapter/security/admin/resolver/CurrentAdminIdArgumentResolver.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/resolver/CurrentAdminIdArgumentResolver.java similarity index 67% rename from src/main/java/com/souzip/adapter/security/admin/resolver/CurrentAdminIdArgumentResolver.java rename to src/main/java/com/souzip/domain/admin/infrastructure/security/resolver/CurrentAdminIdArgumentResolver.java index 6bb0d49..7a8437d 100644 --- a/src/main/java/com/souzip/adapter/security/admin/resolver/CurrentAdminIdArgumentResolver.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/resolver/CurrentAdminIdArgumentResolver.java @@ -1,7 +1,7 @@ -package com.souzip.adapter.security.admin.resolver; +package com.souzip.domain.admin.infrastructure.security.resolver; -import com.souzip.adapter.security.admin.annotation.CurrentAdminId; -import com.souzip.domain.admin.Admin; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -19,15 +19,14 @@ public class CurrentAdminIdArgumentResolver implements HandlerMethodArgumentReso @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentAdminId.class) - && parameter.getParameterType().equals(UUID.class); + && parameter.getParameterType().equals(UUID.class); } @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory ) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); diff --git a/src/main/java/com/souzip/domain/admin/model/Admin.java b/src/main/java/com/souzip/domain/admin/model/Admin.java new file mode 100644 index 0000000..9a58e76 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/model/Admin.java @@ -0,0 +1,65 @@ +package com.souzip.domain.admin.model; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Admin { + + private final UUID id; + private final Username username; + private final Password password; + private final AdminRole role; + private LocalDateTime lastLoginAt; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public static Admin create( + String username, + String rawPassword, + AdminRole role, + AdminPasswordEncoder encoder + ) { + return new Admin( + UUID.randomUUID(), + new Username(username), + Password.encode(rawPassword, encoder), + role, + null, + LocalDateTime.now(), + LocalDateTime.now() + ); + } + + public static Admin restore( + UUID id, + Username username, + Password password, + AdminRole role, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + return new Admin( + id, + username, + password, + role, + lastLoginAt, + createdAt, + updatedAt + ); + } + + public void recordLoginSuccess() { + this.lastLoginAt = LocalDateTime.now(); + } + + public boolean matchesPassword(String rawPassword, AdminPasswordEncoder encoder) { + return this.password.matches(rawPassword, encoder); + } +} diff --git a/src/main/java/com/souzip/domain/admin/model/AdminPasswordEncoder.java b/src/main/java/com/souzip/domain/admin/model/AdminPasswordEncoder.java new file mode 100644 index 0000000..f83691d --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/model/AdminPasswordEncoder.java @@ -0,0 +1,8 @@ +package com.souzip.domain.admin.model; + +public interface AdminPasswordEncoder { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/src/main/java/com/souzip/domain/admin/model/AdminRefreshToken.java b/src/main/java/com/souzip/domain/admin/model/AdminRefreshToken.java new file mode 100644 index 0000000..985dcb0 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/model/AdminRefreshToken.java @@ -0,0 +1,48 @@ +package com.souzip.domain.admin.model; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class AdminRefreshToken { + + private final UUID id; + private final UUID adminId; + private String token; + private LocalDateTime expiresAt; + private final LocalDateTime createdAt; + + public static AdminRefreshToken create(UUID adminId, String token, LocalDateTime expiresAt) { + return new AdminRefreshToken( + UUID.randomUUID(), + adminId, + token, + expiresAt, + LocalDateTime.now() + ); + } + + public static AdminRefreshToken restore( + UUID id, + UUID adminId, + String token, + LocalDateTime expiresAt, + LocalDateTime createdAt + ) { + return new AdminRefreshToken(id, adminId, token, expiresAt, createdAt); + } + + public void updateToken(String token, LocalDateTime expiresAt) { + this.token = token; + this.expiresAt = expiresAt; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiresAt); + } +} diff --git a/src/main/java/com/souzip/domain/admin/AdminRole.java b/src/main/java/com/souzip/domain/admin/model/AdminRole.java similarity index 59% rename from src/main/java/com/souzip/domain/admin/AdminRole.java rename to src/main/java/com/souzip/domain/admin/model/AdminRole.java index 4509377..8b4eeb1 100644 --- a/src/main/java/com/souzip/domain/admin/AdminRole.java +++ b/src/main/java/com/souzip/domain/admin/model/AdminRole.java @@ -1,4 +1,4 @@ -package com.souzip.domain.admin; +package com.souzip.domain.admin.model; public enum AdminRole { SUPER_ADMIN, ADMIN, VIEWER diff --git a/src/main/java/com/souzip/domain/admin/model/Password.java b/src/main/java/com/souzip/domain/admin/model/Password.java new file mode 100644 index 0000000..b3aa9ab --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/model/Password.java @@ -0,0 +1,26 @@ +package com.souzip.domain.admin.model; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class Password { + + private final String encodedValue; + + public static Password encode(String rawPassword, AdminPasswordEncoder encoder) { + return new Password(encoder.encode(rawPassword)); + } + + public static Password of(String encodedValue) { + return new Password(encodedValue); + } + + public boolean matches(String rawPassword, AdminPasswordEncoder encoder) { + return encoder.matches(rawPassword, this.encodedValue); + } + + public String getEncodedValue() { + return encodedValue; + } +} diff --git a/src/main/java/com/souzip/domain/admin/model/Username.java b/src/main/java/com/souzip/domain/admin/model/Username.java new file mode 100644 index 0000000..1eb2fcf --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/model/Username.java @@ -0,0 +1,35 @@ +package com.souzip.domain.admin.model; + +import com.souzip.domain.admin.exception.AdminErrorCode; +import com.souzip.domain.admin.exception.InvalidUsernameException; + +public record Username(String value) { + + private static final int MIN_LENGTH = 2; + private static final int MAX_LENGTH = 20; + + public Username { + validateNotBlank(value); + validateLengthInRange(value); + } + + private static void validateNotBlank(String value) { + if (value == null || value.isBlank()) { + throw new InvalidUsernameException(AdminErrorCode.INVALID_USERNAME_EMPTY); + } + } + + private static void validateLengthInRange(String value) { + if (isBelowMinLength(value) || isAboveMaxLength(value)) { + throw new InvalidUsernameException(AdminErrorCode.INVALID_USERNAME_LENGTH); + } + } + + private static boolean isBelowMinLength(String value) { + return value.length() < MIN_LENGTH; + } + + private static boolean isAboveMaxLength(String value) { + return value.length() > MAX_LENGTH; + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java new file mode 100644 index 0000000..8e3ca7d --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java @@ -0,0 +1,46 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.domain.admin.application.AdminAuthService; +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.presentation.request.AdminLoginRequest; +import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; +import com.souzip.domain.admin.presentation.response.AdminLoginResponse; +import com.souzip.domain.admin.presentation.response.AdminRefreshResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.global.security.annotation.RequireAuth; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RequestMapping("/api/admin/auth") +@RestController +public class AdminAuthController { + + private final AdminAuthService adminAuthService; + + @PostMapping("/login") + public SuccessResponse login(@Valid @RequestBody AdminLoginRequest request) { + AdminLoginResult result = adminAuthService.login(request.toCommand()); + return SuccessResponse.of(AdminLoginResponse.from(result)); + } + + @PostMapping("/refresh") + public SuccessResponse refresh(@Valid @RequestBody AdminRefreshRequest request) { + RefreshResult result = adminAuthService.refresh(request.refreshToken()); + return SuccessResponse.of(AdminRefreshResponse.from(result)); + } + + @PostMapping("/logout") + @RequireAuth + public SuccessResponse logout(@CurrentAdminId UUID adminId) { + adminAuthService.logout(adminId); + return SuccessResponse.of(null, "λ‘œκ·Έμ•„μ›ƒλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java new file mode 100644 index 0000000..3a5b135 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java @@ -0,0 +1,158 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.domain.admin.application.AdminCityQueryUseCase; +import com.souzip.domain.admin.application.AdminCountryQueryUseCase; +import com.souzip.domain.admin.application.AdminManagementUseCase; +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.command.InviteAdminCommand; +import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; +import com.souzip.domain.admin.application.port.CountryQueryPort.CountryQueryResult; +import com.souzip.domain.admin.application.query.CitySearchQuery; +import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.domain.admin.infrastructure.security.annotation.SuperAdminOnly; +import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.presentation.request.CreateCityRequest; +import com.souzip.domain.admin.presentation.request.InviteAdminRequest; +import com.souzip.domain.admin.presentation.request.UpdateCityRequest; +import com.souzip.domain.admin.presentation.response.AdminResponse; +import com.souzip.domain.admin.presentation.response.InviteAdminResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.common.dto.pagination.PaginationRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/admin") +@RestController +public class AdminManagementController { + + private final AdminManagementUseCase adminManagementUseCase; + private final AdminCityQueryUseCase adminCityQueryUseCase; + private final AdminCountryQueryUseCase adminCountryQueryUseCase; + + @SuperAdminOnly + @PostMapping("/invite") + public SuccessResponse inviteAdmin( + @Valid @RequestBody InviteAdminRequest request + ) { + Admin admin = adminManagementUseCase.inviteAdmin(new InviteAdminCommand( + request.username(), + request.password(), + request.role() + )); + return SuccessResponse.of(InviteAdminResponse.from(admin), "κ΄€λ¦¬μž μ΄ˆλŒ€κ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @SuperAdminOnly + @GetMapping("/list") + public SuccessResponse> getAdmins( + @ModelAttribute PaginationRequest paginationRequest + ) { + return SuccessResponse.of(AdminResponse.ofPageResult( + adminManagementUseCase.getAdmins( + paginationRequest.getPageNo(), + paginationRequest.getPageSize() + ) + )); + } + + @SuperAdminOnly + @DeleteMapping("/{adminId}") + public SuccessResponse deleteAdmin( + @PathVariable UUID adminId, + @CurrentAdminId UUID requesterId + ) { + adminManagementUseCase.deleteAdmin(adminId, requesterId); + return SuccessResponse.of(null, "κ΄€λ¦¬μžκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @ViewerAccess + @GetMapping("/countries") + public SuccessResponse> getCountries( + @RequestParam(required = false) String keyword + ) { + return SuccessResponse.of(adminCountryQueryUseCase.getCountries(keyword)); + } + + @ViewerAccess + @GetMapping("/cities") + public SuccessResponse> getCities( + @RequestParam Long countryId, + @RequestParam(required = false) String keyword, + @ModelAttribute PaginationRequest paginationRequest + ) { + CitySearchQuery query = CitySearchQuery.of( + countryId, + keyword, + paginationRequest.getPageNo(), + paginationRequest.getPageSize() + ); + return SuccessResponse.of(adminCityQueryUseCase.getCities(query)); + } + + @AdminAccess + @PostMapping("/cities") + public SuccessResponse createCity( + @Valid @RequestBody CreateCityRequest request + ) { + adminManagementUseCase.createCity(new AdminCreateCityCommand( + request.nameEn(), + request.nameKr(), + request.latitude(), + request.longitude(), + request.countryId() + )); + return SuccessResponse.of(null, "λ„μ‹œκ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @AdminAccess + @DeleteMapping("/cities/{cityId}") + public SuccessResponse deleteCity( + @PathVariable Long cityId + ) { + adminManagementUseCase.deleteCity(new AdminDeleteCityCommand(cityId)); + return SuccessResponse.of(null, "λ„μ‹œκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @AdminAccess + @PatchMapping("/cities/{cityId}/priority") + public SuccessResponse updateCityPriority( + @PathVariable Long cityId, + @RequestParam(required = false) Integer priority + ) { + adminManagementUseCase.updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, priority)); + return SuccessResponse.of(null, "μš°μ„ μˆœμœ„κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + @AdminAccess + @PatchMapping("/cities/{cityId}/name") + public SuccessResponse updateCityName( + @PathVariable Long cityId, + @Valid @RequestBody UpdateCityRequest request + ) { + adminManagementUseCase.updateCity(new AdminUpdateCityCommand( + cityId, + request.nameEn(), + request.nameKr() + )); + return SuccessResponse.of(null, "λ„μ‹œ 이름이 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/request/AdminLoginRequest.java b/src/main/java/com/souzip/domain/admin/presentation/request/AdminLoginRequest.java new file mode 100644 index 0000000..d234fc8 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/request/AdminLoginRequest.java @@ -0,0 +1,17 @@ +package com.souzip.domain.admin.presentation.request; + +import com.souzip.domain.admin.application.command.AdminLoginCommand; +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginRequest( + + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String username, + + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + String password +) { + public AdminLoginCommand toCommand() { + return new AdminLoginCommand(username, password); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/request/AdminRefreshRequest.java b/src/main/java/com/souzip/domain/admin/presentation/request/AdminRefreshRequest.java new file mode 100644 index 0000000..fa9cfcd --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/request/AdminRefreshRequest.java @@ -0,0 +1,10 @@ +package com.souzip.domain.admin.presentation.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminRefreshRequest( + + @NotBlank(message = "λ¦¬ν”„λ ˆμ‹œ 토큰은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + String refreshToken +) { +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/request/CreateCityRequest.java b/src/main/java/com/souzip/domain/admin/presentation/request/CreateCityRequest.java new file mode 100644 index 0000000..87960d7 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/request/CreateCityRequest.java @@ -0,0 +1,21 @@ +package com.souzip.domain.admin.presentation.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateCityRequest( + @NotBlank(message = "λ„μ‹œ 영문λͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String nameEn, + + @NotBlank(message = "λ„μ‹œ ν•œκΈ€λͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String nameKr, + + @NotNull(message = "μœ„λ„λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + Double latitude, + + @NotNull(message = "경도λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + Double longitude, + + @NotNull(message = "λ‚˜λΌ IDλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + Long countryId +) {} diff --git a/src/main/java/com/souzip/domain/admin/presentation/request/InviteAdminRequest.java b/src/main/java/com/souzip/domain/admin/presentation/request/InviteAdminRequest.java new file mode 100644 index 0000000..bb3c89c --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/request/InviteAdminRequest.java @@ -0,0 +1,24 @@ +package com.souzip.domain.admin.presentation.request; + +import com.souzip.domain.admin.model.AdminRole; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record InviteAdminRequest( + + @NotBlank(message = "μ•„μ΄λ””λŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 2, max = 20, message = "μ•„μ΄λ””λŠ” 2-20자 사이여야 ν•©λ‹ˆλ‹€.") + @Pattern(regexp = USERNAME_PATTERN, message = "μ•„μ΄λ””λŠ” 영문, 숫자, μ–Έλ”μŠ€μ½”μ–΄, ν•œκΈ€λ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€.") + String username, + + @NotBlank(message = "λΉ„λ°€λ²ˆν˜ΈλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") + @Size(min = 8, message = "λΉ„λ°€λ²ˆν˜ΈλŠ” μ΅œμ†Œ 8자 이상이어야 ν•©λ‹ˆλ‹€.") + String password, + + @NotNull(message = "역할은 ν•„μˆ˜μž…λ‹ˆλ‹€.") + AdminRole role +) { + private static final String USERNAME_PATTERN = "^[a-zA-Z0-9_κ°€-힣]+$"; +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/request/UpdateCityRequest.java b/src/main/java/com/souzip/domain/admin/presentation/request/UpdateCityRequest.java new file mode 100644 index 0000000..a59a413 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/request/UpdateCityRequest.java @@ -0,0 +1,12 @@ +package com.souzip.domain.admin.presentation.request; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateCityRequest( + @NotBlank(message = "영문 λ„μ‹œλͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String nameEn, + + @NotBlank(message = "ν•œκΈ€ λ„μ‹œλͺ…을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String nameKr +) { +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/AdminLoginResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/AdminLoginResponse.java new file mode 100644 index 0000000..ef89f1e --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminLoginResponse.java @@ -0,0 +1,24 @@ +package com.souzip.domain.admin.presentation.response; + +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.model.AdminRole; + +import java.util.UUID; + +public record AdminLoginResponse( + String accessToken, + String refreshToken, + UUID id, + String username, + AdminRole role +) { + public static AdminLoginResponse from(AdminLoginResult result) { + return new AdminLoginResponse( + result.accessToken(), + result.refreshToken(), + result.admin().getId(), + result.admin().getUsername().value(), + result.admin().getRole() + ); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/AdminRefreshResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/AdminRefreshResponse.java new file mode 100644 index 0000000..f1e1f53 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminRefreshResponse.java @@ -0,0 +1,15 @@ +package com.souzip.domain.admin.presentation.response; + +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; + +public record AdminRefreshResponse( + String accessToken, + String refreshToken +) { + public static AdminRefreshResponse from(RefreshResult result) { + return new AdminRefreshResponse( + result.accessToken(), + result.refreshToken() + ); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java new file mode 100644 index 0000000..37db88e --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java @@ -0,0 +1,41 @@ +package com.souzip.domain.admin.presentation.response; + +import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record AdminResponse( + UUID id, + String username, + AdminRole role, + LocalDateTime lastLoginAt, + LocalDateTime createdAt +) { + public static AdminResponse from(Admin admin) { + return new AdminResponse( + admin.getId(), + admin.getUsername().value(), + admin.getRole(), + admin.getLastLoginAt(), + admin.getCreatedAt() + ); + } + + public static PaginationResponse ofPageResult(AdminPageResult result) { + List content = result.admins().stream() + .map(AdminResponse::from) + .toList(); + + return PaginationResponse.of( + content, + result.pageNo(), + result.pageSize(), + result.total(), + result.totalPages() + ); + } +} diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/InviteAdminResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/InviteAdminResponse.java new file mode 100644 index 0000000..4e228c4 --- /dev/null +++ b/src/main/java/com/souzip/domain/admin/presentation/response/InviteAdminResponse.java @@ -0,0 +1,19 @@ +package com.souzip.domain.admin.presentation.response; + +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import java.util.UUID; + +public record InviteAdminResponse( + UUID adminId, + String username, + AdminRole role +) { + public static InviteAdminResponse from(Admin admin) { + return new InviteAdminResponse( + admin.getId(), + admin.getUsername().value(), + admin.getRole() + ); + } +} diff --git a/src/main/java/com/souzip/application/admin/required/AdminRefreshTokenRepository.java b/src/main/java/com/souzip/domain/admin/repository/AdminRefreshTokenRepository.java similarity index 60% rename from src/main/java/com/souzip/application/admin/required/AdminRefreshTokenRepository.java rename to src/main/java/com/souzip/domain/admin/repository/AdminRefreshTokenRepository.java index ef2283a..d84bec2 100644 --- a/src/main/java/com/souzip/application/admin/required/AdminRefreshTokenRepository.java +++ b/src/main/java/com/souzip/domain/admin/repository/AdminRefreshTokenRepository.java @@ -1,13 +1,13 @@ -package com.souzip.application.admin.required; +package com.souzip.domain.admin.repository; -import com.souzip.domain.admin.AdminRefreshToken; -import org.springframework.data.repository.Repository; +import com.souzip.domain.admin.model.AdminRefreshToken; import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; -public interface AdminRefreshTokenRepository extends Repository { +public interface AdminRefreshTokenRepository { + AdminRefreshToken save(AdminRefreshToken refreshToken); Optional findByToken(String token); @@ -17,4 +17,4 @@ public interface AdminRefreshTokenRepository extends Repository findByUsername(Username username); + + Optional findById(UUID id); + + Admin save(Admin admin); + + boolean existsByUsername(String username); + + List findAllExcludingSuperAdmin(int offset, int limit); + + long countExcludingSuperAdmin(); + + void delete(Admin admin); + + List findAllByIds(List ids); +} diff --git a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java index 23e250a..08eb790 100644 --- a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java +++ b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java @@ -1,100 +1,95 @@ - package com.souzip.domain.city.application.command; +package com.souzip.domain.city.application.command; - import com.souzip.domain.city.application.port.CityManagementPort; - import com.souzip.domain.city.entity.City; - import com.souzip.domain.city.event.CityCreatedEvent; - import com.souzip.domain.city.event.CityDeletedEvent; - import com.souzip.domain.city.event.CityPriorityUpdatedEvent; - import com.souzip.domain.city.repository.CityRepository; - import com.souzip.domain.city.service.CityPriorityDomainService; - import com.souzip.domain.country.entity.Country; - import com.souzip.domain.country.repository.CountryRepository; - import com.souzip.global.exception.BusinessException; - import com.souzip.global.exception.ErrorCode; - import java.math.BigDecimal; - import lombok.RequiredArgsConstructor; - import org.springframework.context.ApplicationEventPublisher; - import org.springframework.stereotype.Service; - import org.springframework.transaction.annotation.Transactional; +import com.souzip.domain.city.application.port.CityManagementPort; +import com.souzip.domain.city.entity.City; +import com.souzip.domain.city.event.CityCreatedEvent; +import com.souzip.domain.city.event.CityDeletedEvent; +import com.souzip.domain.city.event.CityPriorityUpdatedEvent; +import com.souzip.domain.city.repository.CityRepository; +import com.souzip.domain.city.service.CityPriorityDomainService; +import com.souzip.domain.country.entity.Country; +import com.souzip.domain.country.repository.CountryRepository; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; - @RequiredArgsConstructor - @Service - public class CityCommandService implements CityManagementPort { +@RequiredArgsConstructor +@Service +public class CityCommandService implements CityManagementPort { - private final CityRepository cityRepository; - private final CountryRepository countryRepository; - private final ApplicationEventPublisher eventPublisher; - private final CityPriorityDomainService cityPriorityDomainService; + private final CityRepository cityRepository; + private final CountryRepository countryRepository; + private final ApplicationEventPublisher eventPublisher; + private final CityPriorityDomainService cityPriorityDomainService; - @Transactional - @Override - public void createCity(CreateCityCommand command) { - Country country = findCountryById(command.countryId()); - City city = City.create( - command.nameEn(), - command.nameKr(), - BigDecimal.valueOf(command.latitude()), - BigDecimal.valueOf(command.longitude()), - country - ); - cityRepository.save(city); + @Transactional + @Override + public void createCity(CreateCityCommand command) { + Country country = findCountryById(command.countryId()); + City city = City.create( + command.nameEn(), + command.nameKr(), + BigDecimal.valueOf(command.latitude()), + BigDecimal.valueOf(command.longitude()), + country + ); + cityRepository.save(city); - eventPublisher.publishEvent(CityCreatedEvent.of( - city.getId(), - country.getId() - )); - } + eventPublisher.publishEvent(CityCreatedEvent.of( + city.getId(), + country.getId() + )); + } - @Transactional - @Override - public void updateCity(UpdateCityCommand command) { - City city = findCityById(command.cityId()); - city.update( - command.nameEn(), - command.nameKr(), - BigDecimal.valueOf(command.latitude()), - BigDecimal.valueOf(command.longitude()) - ); - } + @Transactional + @Override + public void updateCity(UpdateCityCommand command) { + City city = findCityById(command.cityId()); + city.updateName(command.nameEn(), command.nameKr()); + } - @Transactional - @Override - public void deleteCity(DeleteCityCommand command) { - City city = findCityById(command.cityId()); - cityRepository.delete(city); + @Transactional + @Override + public void deleteCity(DeleteCityCommand command) { + City city = findCityById(command.cityId()); + cityRepository.delete(city); - eventPublisher.publishEvent(CityDeletedEvent.of(city.getId())); - } + eventPublisher.publishEvent(CityDeletedEvent.of(city.getId())); + } - @Transactional - @Override - public void updateCityPriority(UpdateCityPriorityCommand command) { - City city = findCityByIdWithLock(command.cityId()); - Integer oldPriority = city.getPriority(); - Long countryId = city.getCountry().getId(); + @Transactional + @Override + public void updateCityPriority(UpdateCityPriorityCommand command) { + City city = findCityByIdWithLock(command.cityId()); + Integer oldPriority = city.getPriority(); + Long countryId = city.getCountry().getId(); - cityPriorityDomainService.adjustPriorities(city.getId(), oldPriority, command.newPriority(), countryId); - city.updatePriority(command.newPriority()); + cityPriorityDomainService.adjustPriorities(city.getId(), oldPriority, command.newPriority(), countryId); + city.updatePriority(command.newPriority()); - eventPublisher.publishEvent(CityPriorityUpdatedEvent.of( - city.getId(), - oldPriority, - command.newPriority() - )); - } + eventPublisher.publishEvent(CityPriorityUpdatedEvent.of( + city.getId(), + oldPriority, + command.newPriority() + )); + } - private City findCityByIdWithLock(Long cityId) { - return cityRepository.findByIdWithLock(cityId) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ„μ‹œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); - } + private City findCityByIdWithLock(Long cityId) { + return cityRepository.findByIdWithLock(cityId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ„μ‹œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + } - private City findCityById(Long cityId) { - return cityRepository.findById(cityId) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ„μ‹œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); - } + private City findCityById(Long cityId) { + return cityRepository.findById(cityId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ„μ‹œλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + } - private Country findCountryById(Long countryId) { - return countryRepository.findById(countryId) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ‚˜λΌλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); - } + private Country findCountryById(Long countryId) { + return countryRepository.findById(countryId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "λ‚˜λΌλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); } +} diff --git a/src/main/java/com/souzip/domain/city/application/command/UpdateCityCommand.java b/src/main/java/com/souzip/domain/city/application/command/UpdateCityCommand.java index 0beede3..a848b41 100644 --- a/src/main/java/com/souzip/domain/city/application/command/UpdateCityCommand.java +++ b/src/main/java/com/souzip/domain/city/application/command/UpdateCityCommand.java @@ -3,8 +3,6 @@ public record UpdateCityCommand( Long cityId, String nameEn, - String nameKr, - Double latitude, - Double longitude + String nameKr ) { } diff --git a/src/main/java/com/souzip/domain/city/entity/City.java b/src/main/java/com/souzip/domain/city/entity/City.java index 5fa3460..6d11675 100644 --- a/src/main/java/com/souzip/domain/city/entity/City.java +++ b/src/main/java/com/souzip/domain/city/entity/City.java @@ -62,11 +62,9 @@ public void updatePriority(Integer priority) { this.priority = priority; } - public void update(String nameEn, String nameKr, BigDecimal latitude, BigDecimal longitude) { + public void updateName(String nameEn, String nameKr) { this.nameEn = nameEn; this.nameKr = nameKr; - this.latitude = latitude; - this.longitude = longitude; } private void validatePriority(Integer priority) { diff --git a/src/main/java/com/souzip/domain/city/entity/CityCreateRequest.java b/src/main/java/com/souzip/domain/city/entity/CityCreateRequest.java deleted file mode 100644 index bc2e9a9..0000000 --- a/src/main/java/com/souzip/domain/city/entity/CityCreateRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.souzip.domain.city.entity; - -import com.souzip.domain.shared.Coordinate; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record CityCreateRequest( - @NotBlank(message = "λ„μ‹œ 영문λͺ…은 ν•„μˆ˜μž…λ‹ˆλ‹€.") - String nameEn, - - @NotBlank(message = "λ„μ‹œ ν•œκΈ€λͺ…은 ν•„μˆ˜μž…λ‹ˆλ‹€.") - String nameKr, - - @Valid - @NotNull(message = "μ’Œν‘œλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") - Coordinate coordinate, - - @NotNull(message = "λ‚˜λΌ IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") - Long countryId -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/city/entity/CityUpdateRequest.java b/src/main/java/com/souzip/domain/city/entity/CityUpdateRequest.java deleted file mode 100644 index be3d4cf..0000000 --- a/src/main/java/com/souzip/domain/city/entity/CityUpdateRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.domain.city.entity; - -import com.souzip.domain.shared.Coordinate; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record CityUpdateRequest( - @NotBlank(message = "λ„μ‹œ 영문λͺ…은 ν•„μˆ˜μž…λ‹ˆλ‹€.") - String nameEn, - - @NotBlank(message = "λ„μ‹œ ν•œκΈ€λͺ…은 ν•„μˆ˜μž…λ‹ˆλ‹€.") - String nameKr, - - @Valid - @NotNull(message = "μ’Œν‘œλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.") - Coordinate coordinate -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/country/application/query/CountryQueryService.java b/src/main/java/com/souzip/domain/country/application/query/CountryQueryService.java index 8199d4d..6a5dbc6 100644 --- a/src/main/java/com/souzip/domain/country/application/query/CountryQueryService.java +++ b/src/main/java/com/souzip/domain/country/application/query/CountryQueryService.java @@ -1,6 +1,6 @@ package com.souzip.domain.country.application.query; -import com.souzip.domain.country.application.port.CountryAdminPort; +import com.souzip.domain.country.application.port.CountryAdminPort.CountryAdminResult; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; import java.util.List; @@ -11,11 +11,10 @@ @Transactional(readOnly = true) @RequiredArgsConstructor @Service -public class CountryQueryService implements CountryAdminPort { +public class CountryQueryService { private final CountryRepository countryRepository; - @Override public List getCountries(String keyword) { if (hasNoKeyword(keyword)) { return toResults(countryRepository.findAllByOrderByNameKrAsc()); diff --git a/src/main/java/com/souzip/global/config/WebConfig.java b/src/main/java/com/souzip/global/config/WebConfig.java index d41338c..74c45b3 100644 --- a/src/main/java/com/souzip/global/config/WebConfig.java +++ b/src/main/java/com/souzip/global/config/WebConfig.java @@ -1,14 +1,13 @@ package com.souzip.global.config; -import com.souzip.adapter.security.admin.resolver.CurrentAdminIdArgumentResolver; +import com.souzip.domain.admin.infrastructure.security.resolver.CurrentAdminIdArgumentResolver; import com.souzip.global.security.resolver.CurrentUserIdArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.List; - @RequiredArgsConstructor @Configuration public class WebConfig implements WebMvcConfigurer { diff --git a/src/main/java/com/souzip/global/exception/ErrorCode.java b/src/main/java/com/souzip/global/exception/ErrorCode.java index 2f907bb..2f06c69 100644 --- a/src/main/java/com/souzip/global/exception/ErrorCode.java +++ b/src/main/java/com/souzip/global/exception/ErrorCode.java @@ -52,6 +52,12 @@ public enum ErrorCode implements BaseErrorCode { INVALID_CATEGORY(HttpStatus.BAD_REQUEST, "μœ νš¨ν•˜μ§€ μ•Šμ€ μΉ΄ν…Œκ³ λ¦¬μž…λ‹ˆλ‹€."), + SEARCH_INDEX_NOT_READY(HttpStatus.SERVICE_UNAVAILABLE, "검색 μΈλ±μŠ€κ°€ μ€€λΉ„λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."), + SEARCH_INDEX_CREATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "검색 인덱슀 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + SEARCH_INDEX_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "검색 인덱슀 μ‚­μ œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + SEARCH_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "검색 데이터 μ €μž₯에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."), + SEARCH_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "검색 μ„œλΉ„μŠ€μ— 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), + AI_RECOMMENDATION_NOT_READY(HttpStatus.BAD_REQUEST, "μΆ”μ²œ μ‹œμŠ€ν…œμ„ μœ„ν•΄ κΈ°λ…ν’ˆ μ—…λ‘œλ“œ 이λ ₯이 μžˆμ–΄μ•Ό ν•©λ‹ˆλ‹€."), APPLE_MIGRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ€€λΉ„ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), diff --git a/src/main/java/com/souzip/global/security/config/SecurityConfig.java b/src/main/java/com/souzip/global/security/config/SecurityConfig.java index 0c5c154..a895563 100644 --- a/src/main/java/com/souzip/global/security/config/SecurityConfig.java +++ b/src/main/java/com/souzip/global/security/config/SecurityConfig.java @@ -1,10 +1,10 @@ package com.souzip.global.security.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.adapter.security.admin.jwt.AdminJwtAuthenticationFilter; import com.souzip.global.common.dto.ErrorResponse; -import com.souzip.global.config.CorsProperties; import com.souzip.global.exception.ErrorCode; +import com.souzip.global.config.CorsProperties; +import com.souzip.domain.admin.infrastructure.security.jwt.AdminJwtAuthenticationFilter; import com.souzip.global.security.jwt.JwtAuthenticationFilter; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index dd91060..ead65ae 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -1,154 +1,105 @@ - FIELD + FIELD - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - name - address - -
- - - - - - - - - -
+ + + + name + address + +
+ + + + + + + + + +
- - - - entity_type - entity_id - display_order - -
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + entity_type + entity_id + display_order + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
- - - - - - - - - - - - - - - STRING - - - + +
+ + + + + + + + + + + + + STRING + + + - -
- - - - - - - - - - - - - STRING - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminApiTest.java deleted file mode 100644 index ac4d3f8..0000000 --- a/src/test/java/com/souzip/adapter/webapi/admin/AdminApiTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.application.admin.provided.AdminFinder; -import com.souzip.application.admin.provided.AdminModifier; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminFixture; -import com.souzip.domain.admin.AdminRegisterRequest; -import com.souzip.domain.admin.AdminRole; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - -import java.util.List; -import java.util.UUID; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AdminApiTest extends RestDocsSupport { - - private final AdminFinder adminFinder = mock(AdminFinder.class); - private final AdminModifier adminModifier = mock(AdminModifier.class); - - @Override - protected Object initController() { - return new AdminApi(adminFinder, adminModifier); - } - - @DisplayName("μ–΄λ“œλ―Όμ„ λ“±λ‘ν•œλ‹€") - @Test - void register() throws Exception { - Admin admin = AdminFixture.createAdmin(); - given(adminModifier.register(any(AdminRegisterRequest.class))).willReturn(admin); - - AdminRegisterRequest request = AdminRegisterRequest.of("admin123", "password123", AdminRole.ADMIN); - - mockMvc.perform(post("/api/admin/register") - .header("Authorization", "Bearer access-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.username").value("admin123")) - .andExpect(jsonPath("$.data.role").value("ADMIN")) - .andDo(document("admin/invite-admin", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - requestFields( - fieldWithPath("username").type(JsonFieldType.STRING).description("아이디 (2~20자)"), - fieldWithPath("password").type(JsonFieldType.STRING).description("λΉ„λ°€λ²ˆν˜Έ (8자 이상)"), - fieldWithPath("role").type(JsonFieldType.STRING).description("μ—­ν•  (ADMIN, VIEWER)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.adminId").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό ID"), - fieldWithPath("data.username").type(JsonFieldType.STRING).description("아이디"), - fieldWithPath("data.role").type(JsonFieldType.STRING).description("μ—­ν• "), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("μ–΄λ“œλ―Ό λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€") - @Test - void getAdmins() throws Exception { - List admins = List.of( - AdminFixture.createAdmin("admin1"), - AdminFixture.createAdmin("admin2") - ); - Page page = new PageImpl<>(admins, PageRequest.of(0, 10), 2); - given(adminFinder.findAll(any())).willReturn(page); - - mockMvc.perform(get("/api/admin") - .header("Authorization", "Bearer access-token") - .param("pageNo", "1") - .param("pageSize", "10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content").isArray()) - .andDo(document("admin/get-admins", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - queryParameters( - parameterWithName("pageNo").description("νŽ˜μ΄μ§€ 번호 (1λΆ€ν„° μ‹œμž‘)").optional(), - parameterWithName("pageSize").description("νŽ˜μ΄μ§€ 크기 (κΈ°λ³Έ 10, μ΅œλŒ€ 30)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.content[]").type(JsonFieldType.ARRAY).description("μ–΄λ“œλ―Ό λͺ©λ‘"), - fieldWithPath("data.content[].id").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό ID"), - fieldWithPath("data.content[].username").type(JsonFieldType.STRING).description("아이디"), - fieldWithPath("data.content[].role").type(JsonFieldType.STRING).description("μ—­ν• "), - fieldWithPath("data.content[].lastLoginAt").type(JsonFieldType.STRING).description("λ§ˆμ§€λ§‰ 둜그인 μ‹œκ°„").optional(), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("μƒμ„±μΌμ‹œ"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("νŽ˜μ΄μ§€ 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("ν˜„μž¬ νŽ˜μ΄μ§€"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 νŽ˜μ΄μ§€ 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("νŽ˜μ΄μ§€ 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("μ–΄λ“œλ―Όμ„ μ‚­μ œν•œλ‹€") - @Test - void deleteAdmin() throws Exception { - willDoNothing().given(adminModifier).delete(any(UUID.class), any(UUID.class)); - - mockMvc.perform(delete("/api/admin/{adminId}", UUID.randomUUID()) - .header("Authorization", "Bearer access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("κ΄€λ¦¬μžκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) - .andDo(document("admin/delete-admin", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - pathParameters( - parameterWithName("adminId").description("μ‚­μ œν•  μ–΄λ“œλ―Ό ID") - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminAuthApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminAuthApiTest.java deleted file mode 100644 index b0dba9b..0000000 --- a/src/test/java/com/souzip/adapter/webapi/admin/AdminAuthApiTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.webapi.admin.dto.AdminLoginRequest; -import com.souzip.adapter.webapi.admin.dto.AdminRefreshRequest; -import com.souzip.application.admin.AdminAuthService; -import com.souzip.application.admin.dto.AdminLoginResult; -import com.souzip.application.admin.dto.AdminRefreshResult; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminFixture; -import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; -import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - -import java.util.UUID; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AdminAuthApiTest extends RestDocsSupport { - - private final AdminAuthService adminAuthService = mock(AdminAuthService.class); - - @Override - protected Object initController() { - return new AdminAuthApi(adminAuthService); - } - - @DisplayName("μ–΄λ“œλ―Ό 둜그인") - @Test - void login() throws Exception { - Admin admin = AdminFixture.createAdmin(); - AdminLoginResult result = new AdminLoginResult(admin, "access-token", "refresh-token"); - given(adminAuthService.login(any(), any())).willReturn(result); - - AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); - - mockMvc.perform(post("/api/admin/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) - .andDo(document("admin/login", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("username").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό 아이디"), - fieldWithPath("password").type(JsonFieldType.STRING).description("λΉ„λ°€λ²ˆν˜Έ") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.id").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό ID"), - fieldWithPath("data.username").type(JsonFieldType.STRING).description("아이디"), - fieldWithPath("data.role").type(JsonFieldType.STRING).description("μ—­ν• "), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("μ•‘μ„ΈμŠ€ 토큰"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("λ¦¬ν”„λ ˆμ‹œ 토큰"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("μœ νš¨ν•œ λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μ•‘μ„ΈμŠ€ 토큰 μž¬λ°œκΈ‰") - @Test - void refresh_validToken() throws Exception { - AdminRefreshResult result = new AdminRefreshResult("new-access-token", "refresh-token"); - given(adminAuthService.refresh(any())).willReturn(result); - - AdminRefreshRequest request = new AdminRefreshRequest("refresh-token"); - - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("admin/refresh-valid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("λ¦¬ν”„λ ˆμ‹œ 토큰") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("μƒˆ μ•‘μ„ΈμŠ€ 토큰"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("λ¦¬ν”„λ ˆμ‹œ 토큰"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("만료 μž„λ°• λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μž¬λ°œκΈ‰ μ‹œ 토큰도 κ°±μ‹ ") - @Test - void refresh_expiringSoon() throws Exception { - AdminRefreshResult result = new AdminRefreshResult("new-access-token", "new-refresh-token"); - given(adminAuthService.refresh(any())).willReturn(result); - - AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); - - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("admin/refresh-expiring-soon", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("만료 μž„λ°• λ¦¬ν”„λ ˆμ‹œ 토큰") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("μƒˆ μ•‘μ„ΈμŠ€ 토큰"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("μƒˆ λ¦¬ν”„λ ˆμ‹œ 토큰"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("만료된 λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μž¬λ°œκΈ‰ μ‹œ μ˜ˆμ™Έ λ°œμƒ") - @Test - void refresh_expiredToken() throws Exception { - given(adminAuthService.refresh(any())) - .willThrow(new AdminExpiredRefreshTokenException()); - - AdminRefreshRequest request = new AdminRefreshRequest("expired-token"); - - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andDo(document("admin/refresh-expired-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("만료된 λ¦¬ν”„λ ˆμ‹œ 토큰") - ), - responseFields( - fieldWithPath("traceId").type(JsonFieldType.STRING).description("트레이슀 ID"), - fieldWithPath("message").type(JsonFieldType.STRING).description("μ—λŸ¬ λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μž¬λ°œκΈ‰ μ‹œ μ˜ˆμ™Έ λ°œμƒ") - @Test - void refresh_invalidToken() throws Exception { - given(adminAuthService.refresh(any())) - .willThrow(new AdminInvalidRefreshTokenException()); - - AdminRefreshRequest request = new AdminRefreshRequest("invalid-token"); - - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andDo(document("admin/refresh-invalid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("μœ νš¨ν•˜μ§€ μ•Šμ€ λ¦¬ν”„λ ˆμ‹œ 토큰") - ), - responseFields( - fieldWithPath("traceId").type(JsonFieldType.STRING).description("트레이슀 ID"), - fieldWithPath("message").type(JsonFieldType.STRING).description("μ—λŸ¬ λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("μ–΄λ“œλ―Ό λ‘œκ·Έμ•„μ›ƒ") - @Test - void logout() throws Exception { - willDoNothing().given(adminAuthService).logout(any(UUID.class)); - - mockMvc.perform(post("/api/admin/auth/logout") - .header("Authorization", "Bearer access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("admin/logout", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminLocationApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminLocationApiTest.java deleted file mode 100644 index b8b8195..0000000 --- a/src/test/java/com/souzip/adapter/webapi/admin/AdminLocationApiTest.java +++ /dev/null @@ -1,282 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.city.entity.City; -import com.souzip.domain.city.entity.CityCreateRequest; -import com.souzip.domain.city.entity.CityUpdateRequest; -import com.souzip.domain.country.entity.Country; -import com.souzip.domain.shared.Coordinate; -import java.math.BigDecimal; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; -import com.souzip.application.admin.provided.AdminLocationFinder; -import com.souzip.application.admin.provided.AdminLocationModifier; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willDoNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AdminLocationApiTest extends RestDocsSupport { - - private final AdminLocationFinder adminLocationFinder = mock(AdminLocationFinder.class); - private final AdminLocationModifier adminLocationModifier = mock(AdminLocationModifier.class); - - @Override - protected Object initController() { - return new AdminLocationApi(adminLocationFinder, adminLocationModifier); - } - - @DisplayName("λ‚˜λΌ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€") - @Test - void getCountries() throws Exception { - Country country = createCountry(1L, "λŒ€ν•œλ―Όκ΅­"); - given(adminLocationFinder.getCountries(any())).willReturn(List.of(country)); - - mockMvc.perform(get("/api/admin/countries") - .header("Authorization", "Bearer access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andExpect(jsonPath("$.data[0].nameKr").value("λŒ€ν•œλ―Όκ΅­")) - .andDo(document("admin/get-countries", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - apiResponseFields( - fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("λ‚˜λΌ λͺ©λ‘"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("λ‚˜λΌ ID"), - fieldWithPath("data[].nameKr").type(JsonFieldType.STRING).description("λ‚˜λΌ ν•œκΈ€λͺ…"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("λ„μ‹œ λͺ©λ‘μ„ μ‘°νšŒν•œλ‹€") - @Test - void getCities() throws Exception { - City city = createCity(1L, "Seoul", "μ„œμšΈ"); - Page page = new PageImpl<>(List.of(city), PageRequest.of(0, 10), 1); - given(adminLocationFinder.getCities(anyLong(), any(), any())).willReturn(page); - - mockMvc.perform(get("/api/admin/cities") - .header("Authorization", "Bearer access-token") - .param("countryId", "1") - .param("pageNo", "1") - .param("pageSize", "10")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("admin/get-cities", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - queryParameters( - parameterWithName("countryId").description("λ‚˜λΌ ID"), - parameterWithName("keyword").description("검색어 (ν•œκΈ€λͺ…/영문λͺ…)").optional(), - parameterWithName("pageNo").description("νŽ˜μ΄μ§€ 번호 (1λΆ€ν„° μ‹œμž‘)").optional(), - parameterWithName("pageSize").description("νŽ˜μ΄μ§€ 크기 (κΈ°λ³Έ 10, μ΅œλŒ€ 30)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), - fieldWithPath("data.content[]").type(JsonFieldType.ARRAY).description("λ„μ‹œ λͺ©λ‘"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("λ„μ‹œ ID"), - fieldWithPath("data.content[].nameEn").type(JsonFieldType.STRING).description("영문λͺ…"), - fieldWithPath("data.content[].nameKr").type(JsonFieldType.STRING).description("ν•œκΈ€λͺ…"), - fieldWithPath("data.content[].latitude").type(JsonFieldType.NUMBER).description("μœ„λ„"), - fieldWithPath("data.content[].longitude").type(JsonFieldType.NUMBER).description("경도"), - fieldWithPath("data.content[].priority").type(JsonFieldType.NUMBER).description("μš°μ„ μˆœμœ„").optional(), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("νŽ˜μ΄μ§€ 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("ν˜„μž¬ νŽ˜μ΄μ§€"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 νŽ˜μ΄μ§€ 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("νŽ˜μ΄μ§€ 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 νŽ˜μ΄μ§€ μ—¬λΆ€"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() - ) - )); - } - - @DisplayName("λ„μ‹œλ₯Ό μΆ”κ°€ν•œλ‹€") - @Test - void createCity() throws Exception { - willDoNothing().given(adminLocationModifier).createCity(any(CityCreateRequest.class)); - - CityCreateRequest request = new CityCreateRequest( - "Seoul", "μ„œμšΈ", - Coordinate.of(BigDecimal.valueOf(37.5665), BigDecimal.valueOf(126.9780)), - 1L - ); - - mockMvc.perform(post("/api/admin/cities") - .header("Authorization", "Bearer access-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("λ„μ‹œκ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) - .andDo(document("admin/create-city", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - requestFields( - fieldWithPath("nameEn").type(JsonFieldType.STRING).description("영문λͺ…"), - fieldWithPath("nameKr").type(JsonFieldType.STRING).description("ν•œκΈ€λͺ…"), - fieldWithPath("coordinate.latitude").type(JsonFieldType.NUMBER).description("μœ„λ„"), - fieldWithPath("coordinate.longitude").type(JsonFieldType.NUMBER).description("경도"), - fieldWithPath("countryId").type(JsonFieldType.NUMBER).description("λ‚˜λΌ ID") - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("λ„μ‹œ 정보λ₯Ό μˆ˜μ •ν•œλ‹€") - @Test - void updateCity() throws Exception { - willDoNothing().given(adminLocationModifier).updateCity(anyLong(), any(CityUpdateRequest.class)); - - CityUpdateRequest request = new CityUpdateRequest( - "Seoul", "μ„œμšΈ", - Coordinate.of(BigDecimal.valueOf(37.5665), BigDecimal.valueOf(126.9780)) - ); - - mockMvc.perform(patch("/api/admin/cities/{cityId}", 1L) - .header("Authorization", "Bearer access-token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("λ„μ‹œ 정보가 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) - .andDo(document("admin/update-city", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - pathParameters( - parameterWithName("cityId").description("λ„μ‹œ ID") - ), - requestFields( - fieldWithPath("nameEn").type(JsonFieldType.STRING).description("영문λͺ…"), - fieldWithPath("nameKr").type(JsonFieldType.STRING).description("ν•œκΈ€λͺ…"), - fieldWithPath("coordinate.latitude").type(JsonFieldType.NUMBER).description("μœ„λ„"), - fieldWithPath("coordinate.longitude").type(JsonFieldType.NUMBER).description("경도") - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("λ„μ‹œλ₯Ό μ‚­μ œν•œλ‹€") - @Test - void deleteCity() throws Exception { - willDoNothing().given(adminLocationModifier).deleteCity(anyLong()); - - mockMvc.perform(delete("/api/admin/cities/{cityId}", 1L) - .header("Authorization", "Bearer access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("λ„μ‹œκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) - .andDo(document("admin/delete-city", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - pathParameters( - parameterWithName("cityId").description("λ„μ‹œ ID") - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } - - @DisplayName("λ„μ‹œ μš°μ„ μˆœμœ„λ₯Ό μ„€μ •ν•œλ‹€") - @Test - void updateCityPriority() throws Exception { - willDoNothing().given(adminLocationModifier).updateCityPriority(anyLong(), anyInt()); - - mockMvc.perform(patch("/api/admin/cities/{cityId}/priority", 1L) - .header("Authorization", "Bearer access-token") - .param("priority", "1")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("μš°μ„ μˆœμœ„κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) - .andDo(document("admin/update-city-priority", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer μ•‘μ„ΈμŠ€ 토큰") - ), - pathParameters( - parameterWithName("cityId").description("λ„μ‹œ ID") - ), - queryParameters( - parameterWithName("priority").description("μš°μ„ μˆœμœ„ (λ―Έμž…λ ₯ μ‹œ μ΄ˆκΈ°ν™”)").optional() - ), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") - ) - )); - } - - private Country createCountry(Long id, String nameKr) { - Country country = mock(Country.class); - - given(country.getId()).willReturn(id); - given(country.getNameKr()).willReturn(nameKr); - - return country; - } - - private City createCity(Long id, String nameEn, String nameKr) { - City city = mock(City.class); - - given(city.getId()).willReturn(id); - given(city.getNameEn()).willReturn(nameEn); - given(city.getNameKr()).willReturn(nameKr); - given(city.getLatitude()).willReturn(BigDecimal.valueOf(37.5665)); - given(city.getLongitude()).willReturn(BigDecimal.valueOf(126.9780)); - given(city.getPriority()).willReturn(null); - - return city; - } -} diff --git a/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java b/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java index 5c5debc..dcbb2a5 100644 --- a/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/admin/AdminNoticeApiTest.java @@ -7,14 +7,13 @@ import com.souzip.application.notice.provided.NoticeFinder; import com.souzip.application.notice.provided.NoticeRegister; import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.admin.model.AdminRole; import com.souzip.domain.notice.Notice; import com.souzip.domain.notice.NoticeRegisterRequest; import com.souzip.domain.notice.NoticeStatus; - import java.time.LocalDateTime; import java.util.List; import java.util.UUID; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; diff --git a/src/test/java/com/souzip/application/admin/AdminAuthServiceTest.java b/src/test/java/com/souzip/application/admin/AdminAuthServiceTest.java deleted file mode 100644 index 7c792fa..0000000 --- a/src/test/java/com/souzip/application/admin/AdminAuthServiceTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.dto.AdminLoginResult; -import com.souzip.application.admin.dto.AdminRefreshResult; -import com.souzip.application.admin.required.AdminRefreshTokenRepository; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.application.admin.required.TokenProvider; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminFixture; -import com.souzip.domain.admin.AdminRefreshToken; -import com.souzip.domain.admin.PasswordEncoder; -import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; -import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; -import com.souzip.domain.admin.exception.AdminLoginFailedException; -import com.souzip.domain.admin.exception.AdminNotFoundException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class AdminAuthServiceTest { - - @Mock - private AdminRepository adminRepository; - - @Mock - private AdminRefreshTokenRepository refreshTokenRepository; - - @Mock - private TokenProvider tokenProvider; - - @Spy - private final PasswordEncoder passwordEncoder = AdminFixture.createPasswordEncoder(); - - @InjectMocks - private AdminAuthService adminAuthService; - - @DisplayName("둜그인 성곡 μ‹œ μ•‘μ„ΈμŠ€ 토큰과 λ¦¬ν”„λ ˆμ‹œ 토큰을 λ°˜ν™˜ν•œλ‹€") - @Test - void login_success() { - Admin admin = AdminFixture.createAdmin(); - given(adminRepository.findByUsername("admin123")).willReturn(Optional.of(admin)); - given(adminRepository.save(any())).willReturn(admin); - given(refreshTokenRepository.findByAdminId(any())).willReturn(Optional.empty()); - given(tokenProvider.generateAccessToken(any())).willReturn("access-token"); - given(tokenProvider.generateRefreshToken(any())).willReturn("refresh-token"); - - AdminLoginResult result = adminAuthService.login("admin123", "password123"); - - assertThat(result.accessToken()).isEqualTo("access-token"); - assertThat(result.refreshToken()).isEqualTo("refresh-token"); - assertThat(result.admin().getUsername()).isEqualTo("admin123"); - } - - @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ–΄λ“œλ―ΌμœΌλ‘œ 둜그인 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void login_adminNotFound() { - given(adminRepository.findByUsername(any())).willReturn(Optional.empty()); - - assertThatThrownBy(() -> adminAuthService.login("admin123", "password123")) - .isInstanceOf(AdminNotFoundException.class); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 틀리면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void login_wrongPassword() { - Admin admin = AdminFixture.createAdmin(); - given(adminRepository.findByUsername("admin123")).willReturn(Optional.of(admin)); - - assertThatThrownBy(() -> adminAuthService.login("admin123", "wrongpassword")) - .isInstanceOf(AdminLoginFailedException.class); - } - - @DisplayName("μœ νš¨ν•œ λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μ•‘μ„ΈμŠ€ 토큰을 μž¬λ°œκΈ‰ν•œλ‹€") - @Test - void refresh_success() { - Admin admin = AdminFixture.createAdmin(); - AdminRefreshToken refreshToken = AdminFixture.createRefreshToken(admin.getId()); - given(refreshTokenRepository.findByToken("refresh-token")).willReturn(Optional.of(refreshToken)); - given(adminRepository.findById(any())).willReturn(Optional.of(admin)); - given(tokenProvider.generateAccessToken(any())).willReturn("new-access-token"); - - AdminRefreshResult result = adminAuthService.refresh("refresh-token"); - - assertThat(result.accessToken()).isEqualTo("new-access-token"); - } - - @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μž¬λ°œκΈ‰ μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void refresh_invalidToken() { - given(refreshTokenRepository.findByToken(any())).willReturn(Optional.empty()); - - assertThatThrownBy(() -> adminAuthService.refresh("invalid-token")) - .isInstanceOf(AdminInvalidRefreshTokenException.class); - } - - @DisplayName("만료된 λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ μž¬λ°œκΈ‰ μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void refresh_expiredToken() { - Admin admin = AdminFixture.createAdmin(); - AdminRefreshToken expiredToken = AdminFixture.createExpiredRefreshToken(admin.getId()); - given(refreshTokenRepository.findByToken("expired-token")).willReturn(Optional.of(expiredToken)); - - assertThatThrownBy(() -> adminAuthService.refresh("expired-token")) - .isInstanceOf(AdminExpiredRefreshTokenException.class); - - verify(refreshTokenRepository).delete(expiredToken); - } - - @DisplayName("λ‘œκ·Έμ•„μ›ƒ μ‹œ λ¦¬ν”„λ ˆμ‹œ 토큰이 μ‚­μ œλœλ‹€") - @Test - void logout_success() { - UUID adminId = UUID.randomUUID(); - AdminRefreshToken refreshToken = AdminFixture.createRefreshToken(adminId); - given(refreshTokenRepository.findByAdminId(adminId)).willReturn(Optional.of(refreshToken)); - - adminAuthService.logout(adminId); - - verify(refreshTokenRepository).delete(refreshToken); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/application/admin/AdminModifyServiceTest.java b/src/test/java/com/souzip/application/admin/AdminModifyServiceTest.java deleted file mode 100644 index f816b43..0000000 --- a/src/test/java/com/souzip/application/admin/AdminModifyServiceTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.provided.AdminFinder; -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.*; -import com.souzip.domain.admin.exception.AdminErrorCode; -import com.souzip.domain.admin.exception.AdminException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class AdminModifyServiceTest { - - @Mock - private AdminRepository adminRepository; - - @Mock - private AdminFinder adminFinder; - - @Spy - private final PasswordEncoder passwordEncoder = AdminFixture.createPasswordEncoder(); - - @InjectMocks - private AdminModifyService adminModifyService; - - @DisplayName("μ–΄λ“œλ―Όμ„ λ“±λ‘ν•œλ‹€") - @Test - void register_success() { - AdminRegisterRequest request = AdminFixture.createAdminRegisterRequest(); - given(adminRepository.existsByUsername(request.username())).willReturn(false); - given(adminRepository.save(any())).willAnswer(invocation -> invocation.getArgument(0)); - - Admin result = adminModifyService.register(request); - - assertThat(result.getUsername()).isEqualTo("admin123"); - assertThat(result.getRole()).isEqualTo(AdminRole.ADMIN); - } - - @DisplayName("μ€‘λ³΅λœ μ•„μ΄λ””λ‘œ 등둝 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void register_duplicateUsername() { - AdminRegisterRequest request = AdminFixture.createAdminRegisterRequest(); - given(adminRepository.existsByUsername(request.username())).willReturn(true); - - assertThatThrownBy(() -> adminModifyService.register(request)) - .isInstanceOf(AdminException.class) - .hasMessage(AdminErrorCode.ADMIN_USERNAME_DUPLICATED.getMessage()); - - verify(adminRepository, never()).save(any()); - } - - @DisplayName("μŠˆνΌκ΄€λ¦¬μž 등둝 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void register_superAdmin() { - AdminRegisterRequest request = AdminFixture.createAdminRegisterRequest(AdminRole.SUPER_ADMIN); - - assertThatThrownBy(() -> adminModifyService.register(request)) - .isInstanceOf(AdminException.class) - .hasMessage(AdminErrorCode.CANNOT_INVITE_SUPER_ADMIN.getMessage()); - - verify(adminRepository, never()).save(any()); - } - - @DisplayName("μ–΄λ“œλ―Όμ„ μ‚­μ œν•œλ‹€") - @Test - void delete_success() { - UUID adminId = UUID.randomUUID(); - UUID requesterId = UUID.randomUUID(); - Admin admin = AdminFixture.createAdmin(); - given(adminFinder.findById(adminId)).willReturn(admin); - - adminModifyService.delete(adminId, requesterId); - - verify(adminRepository).delete(admin); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/application/admin/AdminQueryServiceTest.java b/src/test/java/com/souzip/application/admin/AdminQueryServiceTest.java deleted file mode 100644 index 9f09a11..0000000 --- a/src/test/java/com/souzip/application/admin/AdminQueryServiceTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.souzip.application.admin; - -import com.souzip.application.admin.required.AdminRepository; -import com.souzip.domain.admin.Admin; -import com.souzip.domain.admin.AdminFixture; -import com.souzip.domain.admin.exception.AdminNotFoundException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -class AdminQueryServiceTest { - - @Mock - private AdminRepository adminRepository; - - @InjectMocks - private AdminQueryService adminQueryService; - - @DisplayName("ID둜 μ–΄λ“œλ―Όμ„ μ‘°νšŒν•œλ‹€") - @Test - void findById_success() { - UUID adminId = UUID.randomUUID(); - Admin admin = AdminFixture.createAdmin(); - given(adminRepository.findById(adminId)).willReturn(Optional.of(admin)); - - Admin result = adminQueryService.findById(adminId); - - assertThat(result.getUsername()).isEqualTo("admin123"); - } - - @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ–΄λ“œλ―Ό 쑰회 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void findById_notFound() { - UUID adminId = UUID.randomUUID(); - given(adminRepository.findById(adminId)).willReturn(Optional.empty()); - - assertThatThrownBy(() -> adminQueryService.findById(adminId)) - .isInstanceOf(AdminNotFoundException.class); - } - - @DisplayName("μ–΄λ“œλ―Ό λͺ©λ‘μ„ νŽ˜μ΄μ§€λ„€μ΄μ…˜μœΌλ‘œ μ‘°νšŒν•œλ‹€") - @Test - void findAll_success() { - PageRequest pageable = PageRequest.of(0, 10); - List admins = List.of(AdminFixture.createAdmin(), AdminFixture.createAdmin("admin456")); - Page page = new PageImpl<>(admins, pageable, 2); - given(adminRepository.findAll(pageable)).willReturn(page); - - Page result = adminQueryService.findAll(pageable); - - assertThat(result.getContent()).hasSize(2); - assertThat(result.getTotalElements()).isEqualTo(2); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/docs/RestDocsSupport.java b/src/test/java/com/souzip/docs/RestDocsSupport.java index cca2b6f..52646a1 100644 --- a/src/test/java/com/souzip/docs/RestDocsSupport.java +++ b/src/test/java/com/souzip/docs/RestDocsSupport.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.souzip.adapter.security.admin.annotation.CurrentAdminId; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import com.souzip.global.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/admin/AdminFixture.java b/src/test/java/com/souzip/domain/admin/AdminFixture.java deleted file mode 100644 index dd77a83..0000000 --- a/src/test/java/com/souzip/domain/admin/AdminFixture.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.souzip.domain.admin; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class AdminFixture { - - public static Admin createAdmin() { - return Admin.register(createAdminRegisterRequest(), createPasswordEncoder()); - } - - public static Admin createAdmin(PasswordEncoder passwordEncoder) { - return Admin.register(createAdminRegisterRequest(), passwordEncoder); - } - - public static Admin createAdmin(AdminRole role) { - return Admin.register(createAdminRegisterRequest(role), createPasswordEncoder()); - } - - public static Admin createAdmin(String username) { - return Admin.register(createAdminRegisterRequest(username), createPasswordEncoder()); - } - - public static AdminRegisterRequest createAdminRegisterRequest() { - return AdminRegisterRequest.of("admin123", "password123", AdminRole.ADMIN); - } - - public static AdminRegisterRequest createAdminRegisterRequest(AdminRole role) { - return AdminRegisterRequest.of("admin123", "password123", role); - } - - public static AdminRegisterRequest createAdminRegisterRequest(String username) { - return AdminRegisterRequest.of(username, "password123", AdminRole.ADMIN); - } - - public static AdminRefreshToken createRefreshToken(UUID adminId) { - return AdminRefreshToken.create(adminId, "refresh-token", LocalDateTime.now().plusDays(30)); - } - - public static AdminRefreshToken createExpiredRefreshToken(UUID adminId) { - return AdminRefreshToken.create(adminId, "refresh-token", LocalDateTime.now().minusDays(1)); - } - - public static PasswordEncoder createPasswordEncoder() { - return new FakePasswordEncoder(); - } - - public static class FakePasswordEncoder implements PasswordEncoder { - @Override - public String encode(String password) { - return "encoded:" + password; - } - - @Override - public boolean matches(String password, String encodedPassword) { - return encodedPassword.equals("encoded:" + password); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/admin/AdminRefreshTokenTest.java b/src/test/java/com/souzip/domain/admin/AdminRefreshTokenTest.java deleted file mode 100644 index c511efe..0000000 --- a/src/test/java/com/souzip/domain/admin/AdminRefreshTokenTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.souzip.domain.admin; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class AdminRefreshTokenTest { - - UUID adminId; - String token; - LocalDateTime expiresAt; - - @BeforeEach - void setUp() { - adminId = UUID.randomUUID(); - token = "refresh-token-value"; - expiresAt = LocalDateTime.now().plusDays(30); - } - - @Test - void create() { - AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, token, expiresAt); - - assertThat(refreshToken.getId()).isNotNull(); - assertThat(refreshToken.getAdminId()).isEqualTo(adminId); - assertThat(refreshToken.getToken()).isEqualTo(token); - assertThat(refreshToken.getExpiresAt()).isEqualTo(expiresAt); - assertThat(refreshToken.getCreatedAt()).isNotNull(); - } - - @Test - void createNullAdminIdFail() { - assertThatThrownBy(() -> AdminRefreshToken.create(null, token, expiresAt)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void createNullTokenFail() { - assertThatThrownBy(() -> AdminRefreshToken.create(adminId, null, expiresAt)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void createNullExpiresAtFail() { - assertThatThrownBy(() -> AdminRefreshToken.create(adminId, token, null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void updateToken() { - AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, token, expiresAt); - String newToken = "new-refresh-token"; - LocalDateTime newExpiresAt = LocalDateTime.now().plusDays(30); - - refreshToken.updateToken(newToken, newExpiresAt); - - assertThat(refreshToken.getToken()).isEqualTo(newToken); - assertThat(refreshToken.getExpiresAt()).isEqualTo(newExpiresAt); - } - - @Test - void isExpired() { - AdminRefreshToken expiredToken = AdminRefreshToken.create( - adminId, token, LocalDateTime.now().minusDays(1) - ); - - assertThat(expiredToken.isExpired()).isTrue(); - } - - @Test - void isNotExpired() { - AdminRefreshToken validToken = AdminRefreshToken.create( - adminId, token, LocalDateTime.now().plusDays(30) - ); - - assertThat(validToken.isExpired()).isFalse(); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/admin/AdminTest.java b/src/test/java/com/souzip/domain/admin/AdminTest.java deleted file mode 100644 index 917611a..0000000 --- a/src/test/java/com/souzip/domain/admin/AdminTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.souzip.domain.admin; - -import com.souzip.domain.admin.exception.AdminException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static com.souzip.domain.admin.AdminFixture.createAdmin; -import static com.souzip.domain.admin.AdminFixture.createPasswordEncoder; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class AdminTest { - - Admin admin; - PasswordEncoder passwordEncoder; - - @BeforeEach - void setUp() { - passwordEncoder = createPasswordEncoder(); - admin = createAdmin(passwordEncoder); - } - - @DisplayName("μ–΄λ“œλ―Όμ„ λ“±λ‘ν•œλ‹€") - @Test - void register() { - assertThat(admin.getId()).isNotNull(); - assertThat(admin.getUsername()).isEqualTo("admin123"); - assertThat(admin.getRole()).isEqualTo(AdminRole.ADMIN); - assertThat(admin.getLastLoginAt()).isNull(); - assertThat(admin.getCreatedAt()).isNotNull(); - assertThat(admin.getUpdatedAt()).isNotNull(); - } - - @DisplayName("둜그인 μ‹œ λ§ˆμ§€λ§‰ 둜그인 μ‹œκ°„μ΄ μ—…λ°μ΄νŠΈλœλ‹€") - @Test - void login() { - assertThat(admin.getLastLoginAt()).isNull(); - - admin.login(); - - assertThat(admin.getLastLoginAt()).isNotNull(); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ μΌμΉ˜ν•˜λ©΄ trueλ₯Ό λ°˜ν™˜ν•œλ‹€") - @Test - void matchesPassword() { - assertThat(admin.matchesPassword("password123", passwordEncoder)).isTrue(); - assertThat(admin.matchesPassword("wrongpassword", passwordEncoder)).isFalse(); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ μ•”ν˜Έν™”λ˜μ–΄ μ €μž₯λœλ‹€") - @Test - void passwordEncoded() { - assertThat(admin.getPassword()).isEqualTo("encoded:password123"); - } - - @DisplayName("아이디가 2자 미만이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void registerInvalidUsernameFail() { - assertThatThrownBy(() -> - Admin.register(AdminRegisterRequest.of("a", "password123", AdminRole.ADMIN), passwordEncoder) - ).isInstanceOf(AdminException.class); - } - - @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ 8자 미만이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void registerInvalidPasswordFail() { - assertThatThrownBy(() -> - Admin.register(AdminRegisterRequest.of("admin123", "pass", AdminRole.ADMIN), passwordEncoder) - ).isInstanceOf(AdminException.class); - } - - @DisplayName("역할이 null이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€") - @Test - void registerNullRoleFail() { - assertThatThrownBy(() -> - Admin.register(AdminRegisterRequest.of("admin123", "password123", null), passwordEncoder) - ).isInstanceOf(NullPointerException.class); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java new file mode 100644 index 0000000..251c0d0 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java @@ -0,0 +1,209 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.application.command.AdminLoginCommand; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; +import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; +import com.souzip.domain.admin.exception.AdminLoginFailedException; +import com.souzip.domain.admin.exception.AdminNotFoundException; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import com.souzip.domain.admin.model.AdminRefreshToken; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; +import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.security.jwt.JwtTokenProvider; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +class AdminAuthServiceTest { + + private AdminAuthService adminAuthService; + private AdminRepository adminRepository; + private AdminRefreshTokenRepository refreshTokenRepository; + private AdminPasswordEncoder passwordEncoder; + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + adminRepository = mock(AdminRepository.class); + refreshTokenRepository = mock(AdminRefreshTokenRepository.class); + passwordEncoder = mock(AdminPasswordEncoder.class); + jwtTokenProvider = mock(JwtTokenProvider.class); + adminAuthService = new AdminAuthService(adminRepository, refreshTokenRepository, passwordEncoder, jwtTokenProvider); + } + + @DisplayName("λ‘œκ·ΈμΈμ— μ„±κ³΅ν•œλ‹€.") + @Test + void login_success() { + // given + AdminLoginCommand command = new AdminLoginCommand("admin123", "password123"); + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); + given(passwordEncoder.matches(anyString(), anyString())).willReturn(true); + given(adminRepository.save(any(Admin.class))).willReturn(admin); + given(jwtTokenProvider.generateToken(anyString())).willReturn("access-token"); + given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("refresh-token"); + given(refreshTokenRepository.findByAdminId(any(UUID.class))).willReturn(Optional.empty()); + + // when + AdminLoginResult result = adminAuthService.login(command); + + // then + assertThat(result.admin().getLastLoginAt()).isNotNull(); + assertThat(result.accessToken()).isEqualTo("access-token"); + assertThat(result.refreshToken()).isEqualTo("refresh-token"); + verify(adminRepository, times(1)).save(admin); + verify(refreshTokenRepository, times(1)).save(any(AdminRefreshToken.class)); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” κ³„μ •μœΌλ‘œ 둜그인 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void login_fail_not_found() { + // given + AdminLoginCommand command = new AdminLoginCommand("admin123", "password123"); + given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminAuthService.login(command)) + .isInstanceOf(AdminNotFoundException.class); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 뢈일치 μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void login_fail_password_mismatch() { + // given + AdminLoginCommand command = new AdminLoginCommand("admin123", "wrongpassword"); + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); + given(passwordEncoder.matches(anyString(), anyString())).willReturn(false); + + // when & then + assertThatThrownBy(() -> adminAuthService.login(command)) + .isInstanceOf(AdminLoginFailedException.class); + } + + @DisplayName("λ¦¬ν”„λ ˆμ‹œ 토큰 갱신에 μ„±κ³΅ν•œλ‹€.") + @Test + void refresh_success() { + // given + UUID adminId = UUID.randomUUID(); + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + + given(refreshTokenRepository.findByToken("refresh-token")).willReturn(Optional.of(refreshToken)); + given(adminRepository.findById(adminId)).willReturn(Optional.of(admin)); + given(jwtTokenProvider.generateToken(anyString())).willReturn("new-access-token"); + + // when + RefreshResult result = adminAuthService.refresh("refresh-token"); + + // then + assertThat(result.accessToken()).isEqualTo("new-access-token"); + assertThat(result.refreshToken()).isEqualTo("refresh-token"); + } + + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ κ°±μ‹  μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void refresh_fail_invalid_token() { + // given + given(refreshTokenRepository.findByToken("invalid-token")).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminAuthService.refresh("invalid-token")) + .isInstanceOf(AdminInvalidRefreshTokenException.class); + } + + @DisplayName("만료된 λ¦¬ν”„λ ˆμ‹œ ν† ν°μœΌλ‘œ κ°±μ‹  μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void refresh_fail_expired_token() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiredAt = LocalDateTime.now().minusDays(1); + AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiredAt); + + given(refreshTokenRepository.findByToken("refresh-token")).willReturn(Optional.of(refreshToken)); + + // when & then + assertThatThrownBy(() -> adminAuthService.refresh("refresh-token")) + .isInstanceOf(AdminExpiredRefreshTokenException.class); + + verify(refreshTokenRepository, times(1)).delete(refreshToken); + } + + @DisplayName("만료 μž„λ°•ν•œ λ¦¬ν”„λ ˆμ‹œ 토큰은 κ°±μ‹ λœλ‹€.") + @Test + void refresh_renew_expiring_token() { + // given + UUID adminId = UUID.randomUUID(); + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(5); // 10일 미만 + AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "old-refresh-token", expiresAt); + + given(refreshTokenRepository.findByToken("old-refresh-token")).willReturn(Optional.of(refreshToken)); + given(adminRepository.findById(adminId)).willReturn(Optional.of(admin)); + given(jwtTokenProvider.generateToken(anyString())).willReturn("new-access-token"); + given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("new-refresh-token"); + + // when + RefreshResult result = adminAuthService.refresh("old-refresh-token"); + + // then + assertThat(result.accessToken()).isEqualTo("new-access-token"); + assertThat(result.refreshToken()).isEqualTo("new-refresh-token"); + verify(refreshTokenRepository, times(1)).save(refreshToken); + } + + @DisplayName("λ‘œκ·Έμ•„μ›ƒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void logout_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + + given(refreshTokenRepository.findByAdminId(adminId)).willReturn(Optional.of(refreshToken)); + + // when + adminAuthService.logout(adminId); + + // then + verify(refreshTokenRepository, times(1)).delete(refreshToken); + } + + @DisplayName("λ¦¬ν”„λ ˆμ‹œ 토큰이 없어도 λ‘œκ·Έμ•„μ›ƒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void logout_success_without_token() { + // given + UUID adminId = UUID.randomUUID(); + given(refreshTokenRepository.findByAdminId(adminId)).willReturn(Optional.empty()); + + // when + adminAuthService.logout(adminId); + + // then + verify(refreshTokenRepository, never()).delete(any()); + } +} diff --git a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java new file mode 100644 index 0000000..f195394 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java @@ -0,0 +1,89 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.port.CityQueryPort; +import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; +import com.souzip.domain.admin.application.query.AdminCityQueryService; +import com.souzip.domain.admin.application.query.CitySearchQuery; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminCityQueryServiceTest { + + @Mock + private CityQueryPort cityQueryPort; + + @InjectMocks + private AdminCityQueryService adminCityQueryService; + + @DisplayName("ν‚€μ›Œλ“œ 없이 λ„μ‹œ λͺ©λ‘ νŽ˜μ΄μ§• 쑰회 성곡") + @Test + void getCities_withoutKeyword_success() { + // given + LocalDateTime now = LocalDateTime.now(); + CitySearchQuery query = CitySearchQuery.of(83L, null, 1, 20); + + List content = List.of( + new CityQueryResult(1L, "μ„œμšΈ", "Seoul", 1, now), + new CityQueryResult(2L, "λΆ€μ‚°", "Busan", 2, now) + ); + + PaginationResponse expected = PaginationResponse.of( + content, 1, 20, 2, 1 + ); + + given(cityQueryPort.getCities(83L, null, 1, 20)).willReturn(expected); + + // when + PaginationResponse result = adminCityQueryService.getCities(query); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).nameKr()).isEqualTo("μ„œμšΈ"); + assertThat(result.getContent().get(0).nameEn()).isEqualTo("Seoul"); + assertThat(result.getContent().get(1).nameKr()).isEqualTo("λΆ€μ‚°"); + assertThat(result.getContent().get(1).nameEn()).isEqualTo("Busan"); + assertThat(result.getPagination().getTotalItems()).isEqualTo(2); + + verify(cityQueryPort).getCities(83L, null, 1, 20); + } + + @DisplayName("ν‚€μ›Œλ“œλ‘œ λ„μ‹œ 검색 νŽ˜μ΄μ§• 쑰회 성곡") + @Test + void getCities_withKeyword_success() { + // given + LocalDateTime now = LocalDateTime.now(); + CitySearchQuery query = CitySearchQuery.of(83L, "μ„œμšΈ", 1, 20); + + List content = List.of( + new CityQueryResult(1L, "μ„œμšΈ", "Seoul", 1, now) + ); + + PaginationResponse expected = PaginationResponse.of( + content, 1, 20, 1, 1 + ); + + given(cityQueryPort.getCities(83L, "μ„œμšΈ", 1, 20)).willReturn(expected); + + // when + PaginationResponse result = adminCityQueryService.getCities(query); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().nameKr()).isEqualTo("μ„œμšΈ"); + assertThat(result.getContent().getFirst().nameEn()).isEqualTo("Seoul"); + + verify(cityQueryPort).getCities(83L, "μ„œμšΈ", 1, 20); + } +} diff --git a/src/test/java/com/souzip/domain/admin/application/AdminCountryQueryServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminCountryQueryServiceTest.java new file mode 100644 index 0000000..94839b6 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/application/AdminCountryQueryServiceTest.java @@ -0,0 +1,86 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.port.CountryQueryPort; +import com.souzip.domain.admin.application.port.CountryQueryPort.CountryQueryResult; +import com.souzip.domain.admin.application.query.AdminCountryQueryService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminCountryQueryServiceTest { + + @Mock + private CountryQueryPort countryQueryPort; + + @InjectMocks + private AdminCountryQueryService adminCountryQueryService; + + @DisplayName("λ‚˜λΌ λͺ©λ‘ 전체 쑰회 성곡") + @Test + void getCountries_withoutKeyword_success() { + // given + List expected = List.of( + new CountryQueryResult(1L, "λŒ€ν•œλ―Όκ΅­"), + new CountryQueryResult(2L, "일본"), + new CountryQueryResult(3L, "λ―Έκ΅­") + ); + + given(countryQueryPort.getCountries(null)).willReturn(expected); + + // when + List result = adminCountryQueryService.getCountries(null); + + // then + assertThat(result).hasSize(3); + assertThat(result.get(0).id()).isEqualTo(1L); + assertThat(result.get(0).nameKr()).isEqualTo("λŒ€ν•œλ―Όκ΅­"); + assertThat(result.get(1).nameKr()).isEqualTo("일본"); + assertThat(result.get(2).nameKr()).isEqualTo("λ―Έκ΅­"); + + verify(countryQueryPort).getCountries(null); + } + + @DisplayName("λ‚˜λΌ ν‚€μ›Œλ“œ 검색 성곡") + @Test + void getCountries_withKeyword_success() { + // given + List expected = List.of( + new CountryQueryResult(1L, "λŒ€ν•œλ―Όκ΅­") + ); + + given(countryQueryPort.getCountries("ν•œκ΅­")).willReturn(expected); + + // when + List result = adminCountryQueryService.getCountries("ν•œκ΅­"); + + // then + assertThat(result).hasSize(1); + assertThat(result.getFirst().nameKr()).isEqualTo("λŒ€ν•œλ―Όκ΅­"); + + verify(countryQueryPort).getCountries("ν•œκ΅­"); + } + + @DisplayName("λ‚˜λΌ λͺ©λ‘μ΄ λΉ„μ–΄μžˆλŠ” 경우 빈 리슀트 λ°˜ν™˜") + @Test + void getCountries_empty() { + // given + given(countryQueryPort.getCountries(null)).willReturn(List.of()); + + // when + List result = adminCountryQueryService.getCountries(null); + + // then + assertThat(result).isEmpty(); + + verify(countryQueryPort).getCountries(null); + } +} diff --git a/src/test/java/com/souzip/domain/admin/application/AdminManagementServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminManagementServiceTest.java new file mode 100644 index 0000000..be99a6a --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/application/AdminManagementServiceTest.java @@ -0,0 +1,287 @@ +package com.souzip.domain.admin.application; + +import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.command.InviteAdminCommand; +import com.souzip.domain.admin.application.port.CityCommandPort; +import com.souzip.domain.admin.exception.AdminErrorCode; +import com.souzip.domain.admin.exception.AdminException; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.infrastructure.encoder.AdminPasswordEncoderImpl; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.repository.AdminRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminManagementServiceTest { + + @Mock + private AdminRepository adminRepository; + + @Mock + private AdminPasswordEncoderImpl passwordEncoder; + + @Mock + private CityCommandPort cityCommandPort; + + @InjectMocks + private AdminManagementService adminManagementService; + + @DisplayName("ADMIN μ—­ν•  κ΄€λ¦¬μž μ΄ˆλŒ€ 성곡") + @Test + void inviteAdmin_withAdminRole_success() { + // given + InviteAdminCommand command = new InviteAdminCommand( + "newadmin", + "password123", + AdminRole.ADMIN + ); + + given(passwordEncoder.encode(anyString())).willReturn("encoded_password123"); + given(adminRepository.existsByUsername(anyString())).willReturn(false); + given(adminRepository.save(any(Admin.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Admin result = adminManagementService.inviteAdmin(command); + + // then + assertThat(result.getUsername().value()).isEqualTo("newadmin"); + assertThat(result.getRole()).isEqualTo(AdminRole.ADMIN); + assertThat(result.getLastLoginAt()).isNull(); + + verify(adminRepository).existsByUsername("newadmin"); + verify(adminRepository).save(any(Admin.class)); + verify(passwordEncoder).encode("password123"); + } + + @DisplayName("VIEWER μ—­ν•  κ΄€λ¦¬μž μ΄ˆλŒ€ 성곡") + @Test + void inviteAdmin_withViewerRole_success() { + // given + InviteAdminCommand command = new InviteAdminCommand( + "viewer01", + "password123", + AdminRole.VIEWER + ); + + given(passwordEncoder.encode(anyString())).willReturn("encoded_password123"); + given(adminRepository.existsByUsername(anyString())).willReturn(false); + given(adminRepository.save(any(Admin.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Admin result = adminManagementService.inviteAdmin(command); + + // then + assertThat(result.getUsername().value()).isEqualTo("viewer01"); + assertThat(result.getRole()).isEqualTo(AdminRole.VIEWER); + + verify(adminRepository).existsByUsername("viewer01"); + verify(adminRepository).save(any(Admin.class)); + verify(passwordEncoder).encode("password123"); + } + + @DisplayName("SUPER_ADMIN μ—­ν•  μ΄ˆλŒ€ μ‹œλ„ μ‹œ μ˜ˆμ™Έ λ°œμƒ") + @Test + void inviteAdmin_withSuperAdminRole_throwsException() { + // given + InviteAdminCommand command = new InviteAdminCommand( + "superadmin", + "password123", + AdminRole.SUPER_ADMIN + ); + + // when & then + assertThatThrownBy(() -> adminManagementService.inviteAdmin(command)) + .isInstanceOf(AdminException.class) + .hasMessage(AdminErrorCode.CANNOT_INVITE_SUPER_ADMIN.getMessage()); + + verify(adminRepository, never()).existsByUsername(anyString()); + verify(adminRepository, never()).save(any(Admin.class)); + verify(passwordEncoder, never()).encode(anyString()); + } + + @DisplayName("μ€‘λ³΅λœ username으둜 μ΄ˆλŒ€ μ‹œλ„ μ‹œ μ˜ˆμ™Έ λ°œμƒ") + @Test + void inviteAdmin_withDuplicateUsername_throwsException() { + // given + InviteAdminCommand command = new InviteAdminCommand( + "existing", + "password123", + AdminRole.ADMIN + ); + + given(adminRepository.existsByUsername("existing")).willReturn(true); + + // when & then + assertThatThrownBy(() -> adminManagementService.inviteAdmin(command)) + .isInstanceOf(AdminException.class) + .hasMessage(AdminErrorCode.ADMIN_USERNAME_DUPLICATED.getMessage()); + + verify(adminRepository).existsByUsername("existing"); + verify(adminRepository, never()).save(any(Admin.class)); + verify(passwordEncoder, never()).encode(anyString()); + } + + @DisplayName("λΉ„λ°€λ²ˆν˜Έκ°€ μ•”ν˜Έν™”λ˜μ–΄ μ €μž₯됨") + @Test + void inviteAdmin_passwordIsEncoded() { + // given + InviteAdminCommand command = new InviteAdminCommand( + "newadmin", + "rawPassword123", + AdminRole.ADMIN + ); + + given(passwordEncoder.encode("rawPassword123")).willReturn("encoded_rawPassword123"); + given(adminRepository.existsByUsername(anyString())).willReturn(false); + given(adminRepository.save(any(Admin.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + Admin result = adminManagementService.inviteAdmin(command); + + // then + verify(passwordEncoder).encode("rawPassword123"); + assertThat(result.getPassword().getEncodedValue()).isEqualTo("encoded_rawPassword123"); + } + + @DisplayName("SUPER_ADMIN을 μ œμ™Έν•œ κ΄€λ¦¬μž λͺ©λ‘ 쑰회 성곡") + @Test + void getAdmins_success() { + // given + List admins = List.of( + Admin.create("admin1", "password123", AdminRole.ADMIN, new TestAdminPasswordEncoder()), + Admin.create("admin2", "password123", AdminRole.VIEWER, new TestAdminPasswordEncoder()) + ); + + given(adminRepository.findAllExcludingSuperAdmin(0, 10)).willReturn(admins); + given(adminRepository.countExcludingSuperAdmin()).willReturn(2L); + + // when + AdminPageResult result = adminManagementService.getAdmins(1, 10); + + // then + assertThat(result.admins()).hasSize(2); + assertThat(result.pageNo()).isEqualTo(1); + assertThat(result.pageSize()).isEqualTo(10); + assertThat(result.total()).isEqualTo(2); + assertThat(result.totalPages()).isEqualTo(1); + + verify(adminRepository).findAllExcludingSuperAdmin(0, 10); + verify(adminRepository).countExcludingSuperAdmin(); + } + + @DisplayName("κ΄€λ¦¬μž λͺ©λ‘ 쑰회 - 2νŽ˜μ΄μ§€") + @Test + void getAdmins_secondPage() { + // given + List admins = List.of( + Admin.create("admin11", "password123", AdminRole.ADMIN, new TestAdminPasswordEncoder()) + ); + + given(adminRepository.findAllExcludingSuperAdmin(10, 10)).willReturn(admins); + given(adminRepository.countExcludingSuperAdmin()).willReturn(11L); + + // when + AdminPageResult result = adminManagementService.getAdmins(2, 10); + + // then + assertThat(result.admins()).hasSize(1); + assertThat(result.pageNo()).isEqualTo(2); + assertThat(result.totalPages()).isEqualTo(2); + + verify(adminRepository).findAllExcludingSuperAdmin(10, 10); + } + + @DisplayName("κ΄€λ¦¬μž μ‚­μ œ 성곡") + @Test + void deleteAdmin_success() { + // given + UUID adminId = UUID.randomUUID(); + UUID requesterId = UUID.randomUUID(); + + Admin adminToDelete = Admin.create("admin1", "password123", AdminRole.ADMIN, + new TestAdminPasswordEncoder()); + + given(adminRepository.findById(adminId)).willReturn(Optional.of(adminToDelete)); + + // when + adminManagementService.deleteAdmin(adminId, requesterId); + + // then + verify(adminRepository).findById(adminId); + verify(adminRepository).delete(adminToDelete); + } + + @DisplayName("λ„μ‹œ μš°μ„ μˆœμœ„ λ³€κ²½ μ‹œ 포트 호좜") + @Test + void updateCityPriority_callsPort() { + // given + AdminUpdateCityPriorityCommand command = new AdminUpdateCityPriorityCommand(1L, 1); + + // when + adminManagementService.updateCityPriority(command); + + // then + verify(cityCommandPort).updateCityPriority(command); + } + + @DisplayName("λ„μ‹œ μš°μ„ μˆœμœ„ μ΄ˆκΈ°ν™” μ‹œ 포트 호좜") + @Test + void updateCityPriority_reset_callsPort() { + // given + AdminUpdateCityPriorityCommand command = new AdminUpdateCityPriorityCommand(1L, null); + + // when + adminManagementService.updateCityPriority(command); + + // then + verify(cityCommandPort).updateCityPriority(command); + } + + @DisplayName("λ„μ‹œ 생성 μ‹œ 포트 호좜") + @Test + void createCity_callsPort() { + // given + AdminCreateCityCommand command = new AdminCreateCityCommand( + "Seoul", "μ„œμšΈ", 37.56, 126.97, 1L + ); + + // when + adminManagementService.createCity(command); + + // then + verify(cityCommandPort).createCity(command); + } + + @DisplayName("λ„μ‹œ μ‚­μ œ μ‹œ 포트 호좜") + @Test + void deleteCity_callsPort() { + // given + AdminDeleteCityCommand command = new AdminDeleteCityCommand(1L); + + // when + adminManagementService.deleteCity(command); + + // then + verify(cityCommandPort).deleteCity(command); + } +} diff --git a/src/test/java/com/souzip/domain/admin/fixture/TestAdminPasswordEncoder.java b/src/test/java/com/souzip/domain/admin/fixture/TestAdminPasswordEncoder.java new file mode 100644 index 0000000..8bb66a1 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/fixture/TestAdminPasswordEncoder.java @@ -0,0 +1,16 @@ +package com.souzip.domain.admin.fixture; + +import com.souzip.domain.admin.model.AdminPasswordEncoder; + +public class TestAdminPasswordEncoder implements AdminPasswordEncoder { + + @Override + public String encode(String rawPassword) { + return "encoded_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("encoded_" + rawPassword); + } +} diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapperTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapperTest.java new file mode 100644 index 0000000..90db922 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminMapperTest.java @@ -0,0 +1,59 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.infrastructure.entity.AdminEntity; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminMapperTest { + + private AdminMapper mapper; + private AdminPasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + mapper = new AdminMapper(); + passwordEncoder = new TestAdminPasswordEncoder(); + } + + @DisplayName("Admin 도메인을 AdminJpaEntity둜 λ³€ν™˜μ— μ„±κ³΅ν•œλ‹€.") + @Test + void toEntity_success() { + // given + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, passwordEncoder); + + // when + AdminEntity entity = mapper.toEntity(admin); + + // then + assertThat(entity.getUsername()).isEqualTo("admin123"); + assertThat(entity.getRole()).isEqualTo(AdminRole.SUPER_ADMIN); + } + + @DisplayName("AdminJpaEntityλ₯Ό Admin λ„λ©”μΈμœΌλ‘œ λ³€ν™˜μ— μ„±κ³΅ν•œλ‹€.") + @Test + void toDomain_success() { + // given + AdminEntity entity = AdminEntity.builder() + .id(UUID.randomUUID()) + .username("admin123") + .password("encoded_password123") + .role(AdminRole.SUPER_ADMIN) + .build(); + + // when + Admin admin = mapper.toDomain(entity); + + // then + assertThat(admin.getUsername().value()).isEqualTo("admin123"); + assertThat(admin.getRole()).isEqualTo(AdminRole.SUPER_ADMIN); + } +} diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapperTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapperTest.java new file mode 100644 index 0000000..7bc731d --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenMapperTest.java @@ -0,0 +1,65 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.infrastructure.entity.AdminRefreshTokenEntity; +import com.souzip.domain.admin.model.AdminRefreshToken; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminRefreshTokenMapperTest { + + private AdminRefreshTokenMapper mapper; + + @BeforeEach + void setUp() { + mapper = new AdminRefreshTokenMapper(); + } + + @DisplayName("AdminRefreshToken 도메인을 Entity둜 λ³€ν™˜μ— μ„±κ³΅ν•œλ‹€.") + @Test + void toEntity_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken domain = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + + // when + AdminRefreshTokenEntity entity = mapper.toEntity(domain); + + // then + assertThat(entity.getId()).isEqualTo(domain.getId()); + assertThat(entity.getAdminId()).isEqualTo(adminId); + assertThat(entity.getToken()).isEqualTo("refresh-token"); + assertThat(entity.getExpiresAt()).isEqualTo(expiresAt); + } + + @DisplayName("Entityλ₯Ό AdminRefreshToken λ„λ©”μΈμœΌλ‘œ λ³€ν™˜μ— μ„±κ³΅ν•œλ‹€.") + @Test + void toDomain_success() { + // given + UUID id = UUID.randomUUID(); + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + + AdminRefreshTokenEntity entity = AdminRefreshTokenEntity.builder() + .id(id) + .adminId(adminId) + .token("refresh-token") + .expiresAt(expiresAt) + .build(); + + // when + AdminRefreshToken domain = mapper.toDomain(entity); + + // then + assertThat(domain.getId()).isEqualTo(id); + assertThat(domain.getAdminId()).isEqualTo(adminId); + assertThat(domain.getToken()).isEqualTo("refresh-token"); + assertThat(domain.getExpiresAt()).isEqualTo(expiresAt); + } +} diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java new file mode 100644 index 0000000..23208d4 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java @@ -0,0 +1,125 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.model.AdminRefreshToken; +import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; +import com.souzip.global.config.QuerydslConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@EnableJpaAuditing +@Import({AdminRefreshTokenRepositoryImpl.class, AdminRefreshTokenMapper.class, QuerydslConfig.class}) +class AdminRefreshTokenRepositoryTest { + + @Autowired + private AdminRefreshTokenRepository repository; + + @DisplayName("AdminRefreshToken μ €μž₯에 μ„±κ³΅ν•œλ‹€.") + @Test + void save_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken token = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + + // when + AdminRefreshToken saved = repository.save(token); + + // then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getAdminId()).isEqualTo(adminId); + assertThat(saved.getToken()).isEqualTo("refresh-token"); + } + + @DisplayName("token으둜 AdminRefreshToken μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void findByToken_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken token = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + repository.save(token); + + // when + Optional found = repository.findByToken("refresh-token"); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getToken()).isEqualTo("refresh-token"); + } + + @DisplayName("adminId둜 AdminRefreshToken μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void findByAdminId_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken token = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + repository.save(token); + + // when + Optional found = repository.findByAdminId(adminId); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getAdminId()).isEqualTo(adminId); + } + + @DisplayName("AdminRefreshToken μ‚­μ œμ— μ„±κ³΅ν•œλ‹€.") + @Test + void delete_success() { + // given + UUID adminId = UUID.randomUUID(); + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + AdminRefreshToken token = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); + AdminRefreshToken saved = repository.save(token); + + // when + repository.delete(saved); + + // then + Optional found = repository.findByToken("refresh-token"); + assertThat(found).isEmpty(); + } + + @DisplayName("만료된 AdminRefreshToken 일괄 μ‚­μ œμ— μ„±κ³΅ν•œλ‹€.") + @Test + void deleteAllByExpiresAtBefore_success() { + // given + UUID adminId1 = UUID.randomUUID(); + UUID adminId2 = UUID.randomUUID(); + UUID adminId3 = UUID.randomUUID(); + + LocalDateTime now = LocalDateTime.now(); + + // 만료된 토큰 2개 + AdminRefreshToken expiredToken1 = AdminRefreshToken.create(adminId1, "expired-token-1", now.minusDays(1)); + AdminRefreshToken expiredToken2 = AdminRefreshToken.create(adminId2, "expired-token-2", now.minusDays(5)); + + // μœ νš¨ν•œ 토큰 1개 + AdminRefreshToken validToken = AdminRefreshToken.create(adminId3, "valid-token", now.plusDays(30)); + + repository.save(expiredToken1); + repository.save(expiredToken2); + repository.save(validToken); + + // when + int deletedCount = repository.deleteAllByExpiresAtBefore(now); + + // then + assertThat(deletedCount).isEqualTo(2); + assertThat(repository.findByToken("expired-token-1")).isEmpty(); + assertThat(repository.findByToken("expired-token-2")).isEmpty(); + assertThat(repository.findByToken("valid-token")).isPresent(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java new file mode 100644 index 0000000..b89dace --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java @@ -0,0 +1,166 @@ +package com.souzip.domain.admin.infrastructure.persistence; + +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.config.QuerydslConfig; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({AdminRepositoryImpl.class, AdminMapper.class, QuerydslConfig.class}) +class AdminRepositoryTest { + + @Autowired + private AdminRepository adminRepository; + + private AdminPasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + passwordEncoder = new TestAdminPasswordEncoder(); + } + + @DisplayName("Admin μ €μž₯에 μ„±κ³΅ν•œλ‹€.") + @Test + void save_success() { + // given + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, passwordEncoder); + + // when + Admin saved = adminRepository.save(admin); + + // then + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getUsername().value()).isEqualTo("admin123"); + } + + @DisplayName("username으둜 Admin μ‘°νšŒμ— μ„±κ³΅ν•œλ‹€.") + @Test + void findByUsername_success() { + // given + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, passwordEncoder); + adminRepository.save(admin); + + // when + Optional found = adminRepository.findByUsername(new Username("admin123")); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getUsername().value()).isEqualTo("admin123"); + } + + @DisplayName("μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” username 쑰회 μ‹œ 빈 값을 λ°˜ν™˜ν•œλ‹€.") + @Test + void findByUsername_not_found() { + // when + Optional found = adminRepository.findByUsername(new Username("admin123")); + + // then + assertThat(found).isEmpty(); + } + + @DisplayName("username 쑴재 μ—¬λΆ€ 확인 - μ‘΄μž¬ν•¨") + @Test + void existsByUsername_exists() { + // given + Admin admin = Admin.create("testadmin", "password123", AdminRole.ADMIN, passwordEncoder); + adminRepository.save(admin); + + // when + boolean exists = adminRepository.existsByUsername("testadmin"); + + // then + assertThat(exists).isTrue(); + } + + @DisplayName("username 쑴재 μ—¬λΆ€ 확인 - μ‘΄μž¬ν•˜μ§€ μ•ŠμŒ") + @Test + void existsByUsername_notExists() { + // when + boolean exists = adminRepository.existsByUsername("nonexistent"); + + // then + assertThat(exists).isFalse(); + } + + @DisplayName("SUPER_ADMIN을 μ œμ™Έν•œ Admin λͺ©λ‘μ„ νŽ˜μ΄μ§•μœΌλ‘œ μ‘°νšŒν•œλ‹€.") + @Test + void findAllExcludingSuperAdmin_withPagination_success() { + // given + adminRepository.save(Admin.create("superadmin", "password123", AdminRole.SUPER_ADMIN, passwordEncoder)); + + for (int i = 1; i <= 15; i++) { + Admin admin = Admin.create("admin" + i, "password123", AdminRole.ADMIN, passwordEncoder); + adminRepository.save(admin); + } + + // when + List firstPage = adminRepository.findAllExcludingSuperAdmin(0, 10); + List secondPage = adminRepository.findAllExcludingSuperAdmin(10, 10); + + // then + assertThat(firstPage).hasSize(10); + assertThat(secondPage).hasSize(5); + assertThat(firstPage).noneMatch(admin -> admin.getRole() == AdminRole.SUPER_ADMIN); + assertThat(secondPage).noneMatch(admin -> admin.getRole() == AdminRole.SUPER_ADMIN); + } + + @DisplayName("SUPER_ADMIN을 μ œμ™Έν•œ Admin 개수λ₯Ό μ‘°νšŒν•œλ‹€.") + @Test + void countExcludingSuperAdmin_success() { + // given + adminRepository.save(Admin.create("superadmin1", "password123", AdminRole.SUPER_ADMIN, passwordEncoder)); + adminRepository.save(Admin.create("superadmin2", "password123", AdminRole.SUPER_ADMIN, passwordEncoder)); + + for (int i = 1; i <= 5; i++) { + Admin admin = Admin.create("admin" + i, "password123", AdminRole.ADMIN, passwordEncoder); + adminRepository.save(admin); + } + + // when + long count = adminRepository.countExcludingSuperAdmin(); + + // then + assertThat(count).isEqualTo(5); + } + + @DisplayName("Admin μ‚­μ œμ— μ„±κ³΅ν•œλ‹€.") + @Test + void delete_success() { + // given + Admin admin = Admin.create("admin123", "password123", AdminRole.ADMIN, passwordEncoder); + Admin saved = adminRepository.save(admin); + + // when + adminRepository.delete(saved); + + // then + Optional found = adminRepository.findById(saved.getId()); + assertThat(found).isEmpty(); + } + + @DisplayName("SUPER_ADMIN을 μ œμ™Έν•œ Admin λͺ©λ‘ 쑰회 μ‹œ 빈 리슀트λ₯Ό λ°˜ν™˜ν•œλ‹€.") + @Test + void findAllExcludingSuperAdmin_emptyResult() { + // given + adminRepository.save(Admin.create("superadmin", "password123", AdminRole.SUPER_ADMIN, passwordEncoder)); + + // when + List result = adminRepository.findAllExcludingSuperAdmin(0, 10); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/model/AdminRefreshTokenTest.java b/src/test/java/com/souzip/domain/admin/model/AdminRefreshTokenTest.java new file mode 100644 index 0000000..46da192 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/model/AdminRefreshTokenTest.java @@ -0,0 +1,56 @@ +package com.souzip.domain.admin.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminRefreshTokenTest { + + @DisplayName("AdminRefreshToken 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given + UUID adminId = UUID.randomUUID(); + String token = "refresh-token"; + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + + // when + AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, token, expiresAt); + + // then + assertThat(refreshToken.getId()).isNotNull(); + assertThat(refreshToken.getAdminId()).isEqualTo(adminId); + assertThat(refreshToken.getToken()).isEqualTo(token); + assertThat(refreshToken.getExpiresAt()).isEqualTo(expiresAt); + assertThat(refreshToken.getCreatedAt()).isNotNull(); + } + + @DisplayName("AdminRefreshToken 볡원에 μ„±κ³΅ν•œλ‹€.") + @Test + void restore_success() { + // given + UUID id = UUID.randomUUID(); + UUID adminId = UUID.randomUUID(); + String token = "refresh-token"; + LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + LocalDateTime createdAt = LocalDateTime.now(); + + // when + AdminRefreshToken refreshToken = AdminRefreshToken.restore( + id, adminId, token, expiresAt, createdAt + ); + + // then + assertThat(refreshToken.getId()).isEqualTo(id); + assertThat(refreshToken.getAdminId()).isEqualTo(adminId); + assertThat(refreshToken.getToken()).isEqualTo(token); + assertThat(refreshToken.getExpiresAt()).isEqualTo(expiresAt); + assertThat(refreshToken.getCreatedAt()).isEqualTo(createdAt); + } +} + + diff --git a/src/test/java/com/souzip/domain/admin/model/AdminTest.java b/src/test/java/com/souzip/domain/admin/model/AdminTest.java new file mode 100644 index 0000000..7a72349 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/model/AdminTest.java @@ -0,0 +1,31 @@ +package com.souzip.domain.admin.model; + +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminTest { + + private AdminPasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + passwordEncoder = new TestAdminPasswordEncoder(); + } + + @DisplayName("Admin 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given & when + Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, passwordEncoder); + + // then + assertThat(admin.getId()).isNotNull(); + assertThat(admin.getUsername().value()).isEqualTo("admin123"); + assertThat(admin.getRole()).isEqualTo(AdminRole.SUPER_ADMIN); + assertThat(admin.getCreatedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/model/PasswordTest.java b/src/test/java/com/souzip/domain/admin/model/PasswordTest.java new file mode 100644 index 0000000..6286d35 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/model/PasswordTest.java @@ -0,0 +1,23 @@ +package com.souzip.domain.admin.model; + +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PasswordTest { + + @DisplayName("λΉ„λ°€λ²ˆν˜Έ 인코딩에 μ„±κ³΅ν•œλ‹€.") + @Test + void encode_success() { + // given + AdminPasswordEncoder encoder = new TestAdminPasswordEncoder(); + + // when + Password password = Password.encode("password123", encoder); + + // then + assertThat(password).isNotNull(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/model/UsernameTest.java b/src/test/java/com/souzip/domain/admin/model/UsernameTest.java new file mode 100644 index 0000000..767d0d3 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/model/UsernameTest.java @@ -0,0 +1,48 @@ +package com.souzip.domain.admin.model; + +import com.souzip.domain.admin.exception.InvalidUsernameException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UsernameTest { + + @DisplayName("μœ νš¨ν•œ μ•„μ΄λ””λ‘œ Username 생성에 μ„±κ³΅ν•œλ‹€.") + @Test + void create_success() { + // given & when + Username username = new Username("admin123"); + + // then + assertThat(username.value()).isEqualTo("admin123"); + } + + @DisplayName("아이디가 null이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @Test + void create_fail_null() { + // when & then + assertThatThrownBy(() -> new Username(null)) + .isInstanceOf(InvalidUsernameException.class); + } + + @Test + @DisplayName("아이디가 곡백이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + void create_fail_blank() { + // when & then + assertThatThrownBy(() -> new Username(" ")) + .isInstanceOf(InvalidUsernameException.class); + } + + @ParameterizedTest + @DisplayName("아이디가 길이 λ²”μœ„λ₯Ό λ²—μ–΄λ‚˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€.") + @ValueSource(strings = {"a", "12345678123123901231231", "12312123456712312389012"}) + void create_fail_invalid_length(String value) { + // when & then + assertThatThrownBy(() -> new Username(value)) + .isInstanceOf(InvalidUsernameException.class); + } +} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java new file mode 100644 index 0000000..fdd1809 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java @@ -0,0 +1,242 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.admin.application.AdminAuthService; +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; +import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.presentation.request.AdminLoginRequest; +import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.UUID; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminAuthControllerTest extends RestDocsSupport { + + private final AdminAuthService adminAuthService = mock(AdminAuthService.class); + + @Override + protected Object initController() { + return new AdminAuthController(adminAuthService); + } + + @Test + @DisplayName("μ–΄λ“œλ―Ό 둜그인 성곡") + void login_success() throws Exception { + // given + AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); + + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + AdminLoginResult result = new AdminLoginResult(mockAdmin, "access-token", "refresh-token"); + + given(adminAuthService.login(any())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) + .andExpect(jsonPath("$.data.username").value("admin123")) + .andExpect(jsonPath("$.data.role").value("SUPER_ADMIN")) + .andDo(document("admin/login", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("username").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό 아이디 (4~10자)"), + fieldWithPath("password").type(JsonFieldType.STRING).description("λΉ„λ°€λ²ˆν˜Έ") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("둜그인 응닡 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.id").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό ID (UUID)"), + fieldWithPath("data.username").type(JsonFieldType.STRING).description("μ–΄λ“œλ―Ό 아이디"), + fieldWithPath("data.role").type(JsonFieldType.STRING).description("κΆŒν•œ (SUPER_ADMIN)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + } + + @Test + @DisplayName("μ–΄λ“œλ―Ό 토큰 κ°±μ‹  - Access Token만") + void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("valid-refresh-token"); + RefreshResult result = new RefreshResult("new-access-token", "valid-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("valid-refresh-token")) + .andDo(document("admin/refresh-valid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 μž¬λ°œκΈ‰ 응닡 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("μƒˆλ‘œ λ°œκΈ‰λœ JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (μœ νš¨κΈ°κ°„ 10일 초과 μ‹œ κ·ΈλŒ€λ‘œ μœ μ§€)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + } + + @Test + @DisplayName("μ–΄λ“œλ―Ό 토큰 κ°±μ‹  - 만료 μž„λ°• μ‹œ λ‘˜ λ‹€ μž¬λ°œκΈ‰") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); + RefreshResult result = new RefreshResult("new-access-token", "new-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) + .andDo(document("admin/refresh-expiring-soon", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (만료 10일 μ΄ν•˜)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 μž¬λ°œκΈ‰ 응닡 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("μƒˆλ‘œ λ°œκΈ‰λœ JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("μƒˆλ‘œ λ°œκΈ‰λœ JWT Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 μ—λŸ¬") + void refresh_withExpiredToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expired-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminExpiredRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 λ¦¬ν”„λ ˆμ‹œ ν† ν°μž…λ‹ˆλ‹€.")) + .andDo(document("admin/refresh-expired-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("만료된 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ Refresh Token - 401 μ—λŸ¬") + void refresh_withInvalidToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("invalid-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminInvalidRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("μœ νš¨ν•˜μ§€ μ•Šμ€ λ¦¬ν”„λ ˆμ‹œ ν† ν°μž…λ‹ˆλ‹€.")) + .andDo(document("admin/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("μœ νš¨ν•˜μ§€ μ•Šμ€ Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("μ–΄λ“œλ―Ό λ‘œκ·Έμ•„μ›ƒ 성곡") + void logout_success() throws Exception { + // given + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(mockAdmin, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + doNothing().when(adminAuthService).logout(any(UUID.class)); + + // when & then + mockMvc.perform(post("/api/admin/auth/logout") + .header("Authorization", "Bearer valid-access-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("λ‘œκ·Έμ•„μ›ƒλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/logout", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken}") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java new file mode 100644 index 0000000..7fd3aba --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java @@ -0,0 +1,613 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.admin.application.AdminCityQueryUseCase; +import com.souzip.domain.admin.application.AdminCountryQueryUseCase; +import com.souzip.domain.admin.application.AdminManagementService; +import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; +import com.souzip.domain.admin.application.command.AdminCreateCityCommand; +import com.souzip.domain.admin.application.command.AdminDeleteCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityCommand; +import com.souzip.domain.admin.application.command.AdminUpdateCityPriorityCommand; +import com.souzip.domain.admin.application.command.InviteAdminCommand; +import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; +import com.souzip.domain.admin.application.port.CountryQueryPort.CountryQueryResult; +import com.souzip.domain.admin.application.query.CitySearchQuery; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.presentation.request.CreateCityRequest; +import com.souzip.domain.admin.presentation.request.InviteAdminRequest; +import com.souzip.domain.admin.presentation.request.UpdateCityRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminManagementControllerTest extends RestDocsSupport { + + private final AdminManagementService adminManagementService = mock(AdminManagementService.class); + private final AdminCityQueryUseCase adminCityQueryUseCase = mock(AdminCityQueryUseCase.class); + private final AdminCountryQueryUseCase adminCountryQueryUseCase = mock(AdminCountryQueryUseCase.class); + + @Override + protected Object initController() { + return new AdminManagementController(adminManagementService, adminCityQueryUseCase, adminCountryQueryUseCase); + } + + @DisplayName("κ΄€λ¦¬μž μ΄ˆλŒ€ - ADMIN μ—­ν• ") + @Test + void inviteAdmin_withAdminRole_success() throws Exception { + setSuperAdminAuthentication(); + + InviteAdminRequest request = new InviteAdminRequest( + "newadmin", + "password123", + AdminRole.ADMIN + ); + + Admin createdAdmin = Admin.create("newadmin", "password123", AdminRole.ADMIN, + new TestAdminPasswordEncoder()); + + given(adminManagementService.inviteAdmin( + new InviteAdminCommand( + request.username(), + request.password(), + request.role() + ) + )).willReturn(createdAdmin); + + mockMvc.perform(post("/api/admin/invite") + .header("Authorization", "Bearer super-admin-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.username").value("newadmin")) + .andExpect(jsonPath("$.data.role").value("ADMIN")) + .andExpect(jsonPath("$.message").value("κ΄€λ¦¬μž μ΄ˆλŒ€κ°€ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/invite-admin", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN κΆŒν•œ ν•„μš”") + ), + requestFields( + fieldWithPath("username").type(JsonFieldType.STRING) + .description("아이디 (2-20자, 영문/숫자/μ–Έλ”μŠ€μ½”μ–΄/ν•œκΈ€)"), + fieldWithPath("password").type(JsonFieldType.STRING) + .description("λΉ„λ°€λ²ˆν˜Έ (μ΅œμ†Œ 8자)"), + fieldWithPath("role").type(JsonFieldType.STRING) + .description("μ—­ν•  (ADMIN λ˜λŠ” VIEWER)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("μƒμ„±λœ κ΄€λ¦¬μž 정보"), + fieldWithPath("data.adminId").type(JsonFieldType.STRING).description("κ΄€λ¦¬μž ID (UUID)"), + fieldWithPath("data.username").type(JsonFieldType.STRING).description("아이디"), + fieldWithPath("data.role").type(JsonFieldType.STRING).description("μ—­ν• "), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("κ΄€λ¦¬μž λͺ©λ‘ 쑰회") + @Test + void getAdmins_success() throws Exception { + setSuperAdminAuthentication(); + + List admins = List.of( + Admin.create("admin1", "password123", AdminRole.ADMIN, new TestAdminPasswordEncoder()), + Admin.create("admin2", "password123", AdminRole.VIEWER, new TestAdminPasswordEncoder()) + ); + + AdminPageResult pageResult = new AdminPageResult(admins, 1, 10, 2, 1); + + given(adminManagementService.getAdmins(anyInt(), anyInt())).willReturn(pageResult); + + mockMvc.perform(get("/api/admin/list") + .header("Authorization", "Bearer super-admin-token") + .param("pageNo", "1") + .param("pageSize", "10")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.data.pagination.currentPage").value(1)) + .andExpect(jsonPath("$.data.pagination.totalPages").value(1)) + .andExpect(jsonPath("$.data.pagination.totalItems").value(2)) + .andDo(document("admin/get-admins", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN κΆŒν•œ ν•„μš”") + ), + queryParameters( + parameterWithName("pageNo").description("νŽ˜μ΄μ§€ 번호 (κΈ°λ³Έκ°’: 1)").optional(), + parameterWithName("pageSize").description("νŽ˜μ΄μ§€ 크기 (κΈ°λ³Έκ°’: 10, μ΅œλŒ€: 30)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("κ΄€λ¦¬μž λͺ©λ‘"), + fieldWithPath("data.content[].id").type(JsonFieldType.STRING).description("κ΄€λ¦¬μž ID"), + fieldWithPath("data.content[].username").type(JsonFieldType.STRING).description("아이디"), + fieldWithPath("data.content[].role").type(JsonFieldType.STRING).description("μ—­ν• "), + fieldWithPath("data.content[].lastLoginAt").type(JsonFieldType.STRING).description("λ§ˆμ§€λ§‰ 둜그인 μ‹œκ°").optional(), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성 μ‹œκ°"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("νŽ˜μ΄μ§• 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("ν˜„μž¬ νŽ˜μ΄μ§€"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 νŽ˜μ΄μ§€ 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 ν•­λͺ© 수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("νŽ˜μ΄μ§€ 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 νŽ˜μ΄μ§€ μ—¬λΆ€"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ μ—¬λΆ€"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("κ΄€λ¦¬μž μ‚­μ œ 성곡") + @Test + void deleteAdmin_success() throws Exception { + Admin superAdmin = Admin.create("superadmin", "password", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + setSuperAdminAuthenticationWithAdmin(superAdmin); + + UUID adminIdToDelete = UUID.randomUUID(); + + doNothing().when(adminManagementService).deleteAdmin(any(UUID.class), any(UUID.class)); + + mockMvc.perform(delete("/api/admin/{adminId}", adminIdToDelete) + .header("Authorization", "Bearer super-admin-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("κ΄€λ¦¬μžκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/delete-admin", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN κΆŒν•œ ν•„μš”") + ), + pathParameters( + parameterWithName("adminId").description("μ‚­μ œν•  κ΄€λ¦¬μž ID (UUID)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ‚˜λΌ λͺ©λ‘ 쑰회 성곡") + @Test + void getCountries_success() throws Exception { + setAdminAuthentication(); + + List countries = List.of( + new CountryQueryResult(1L, "λŒ€ν•œλ―Όκ΅­"), + new CountryQueryResult(2L, "일본") + ); + + given(adminCountryQueryUseCase.getCountries(null)).willReturn(countries); + + mockMvc.perform(get("/api/admin/countries") + .header("Authorization", "Bearer admin-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].nameKr").value("λŒ€ν•œλ―Όκ΅­")) + .andExpect(jsonPath("$.data[1].id").value(2)) + .andExpect(jsonPath("$.data[1].nameKr").value("일본")) + .andDo(document("admin/get-countries", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN λ˜λŠ” VIEWER κΆŒν•œ ν•„μš”") + ), + queryParameters( + parameterWithName("keyword").description("κ΅­κ°€λͺ… 검색 ν‚€μ›Œλ“œ (ν•œκΈ€λͺ…)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("λ‚˜λΌ λͺ©λ‘"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("λ‚˜λΌ ID"), + fieldWithPath("data[].nameKr").type(JsonFieldType.STRING).description("λ‚˜λΌ ν•œκΈ€ 이름"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ‚˜λΌ ν‚€μ›Œλ“œ 검색 성곡") + @Test + void getCountries_withKeyword_success() throws Exception { + setAdminAuthentication(); + + List countries = List.of( + new CountryQueryResult(1L, "λŒ€ν•œλ―Όκ΅­") + ); + + given(adminCountryQueryUseCase.getCountries("ν•œκ΅­")).willReturn(countries); + + mockMvc.perform(get("/api/admin/countries") + .header("Authorization", "Bearer admin-token") + .param("keyword", "ν•œκ΅­")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].nameKr").value("λŒ€ν•œλ―Όκ΅­")) + .andExpect(jsonPath("$.data.length()").value(1)); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ λͺ©λ‘ 쑰회 성곡") + @Test + void getCities_success() throws Exception { + setAdminAuthentication(); + LocalDateTime now = LocalDateTime.now(); + + List content = List.of( + new CityQueryResult(1L, "μ„œμšΈ", "Seoul", 1, now), + new CityQueryResult(2L, "λΆ€μ‚°", "Busan", 2, now) + ); + + PaginationResponse pageResponse = PaginationResponse.of( + content, 1, 20, 2, 1 + ); + + given(adminCityQueryUseCase.getCities(any(CitySearchQuery.class))).willReturn(pageResponse); + + mockMvc.perform(get("/api/admin/cities") + .header("Authorization", "Bearer admin-token") + .param("countryId", "83") + .param("pageNo", "1") + .param("pageSize", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].nameKr").value("μ„œμšΈ")) + .andExpect(jsonPath("$.data.content[0].nameEn").value("Seoul")) + .andExpect(jsonPath("$.data.content[0].priority").value(1)) + .andExpect(jsonPath("$.data.content[1].id").value(2)) + .andExpect(jsonPath("$.data.content[1].nameKr").value("λΆ€μ‚°")) + .andExpect(jsonPath("$.data.content[1].nameEn").value("Busan")) + .andExpect(jsonPath("$.data.pagination.currentPage").value(1)) + .andExpect(jsonPath("$.data.pagination.totalItems").value(2)) + .andDo(document("admin/get-cities", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN λ˜λŠ” VIEWER κΆŒν•œ ν•„μš”") + ), + queryParameters( + parameterWithName("countryId").description("λ‚˜λΌ ID (κΈ°λ³Έκ°’: 83)").optional(), + parameterWithName("keyword").description("λ„μ‹œλͺ… 검색어 (ν•œκΈ€λͺ…, 영문λͺ…)").optional(), + parameterWithName("pageNo").description("νŽ˜μ΄μ§€ 번호 (κΈ°λ³Έκ°’: 1)").optional(), + parameterWithName("pageSize").description("νŽ˜μ΄μ§€ 크기 (κΈ°λ³Έκ°’: 10, μ΅œλŒ€: 30)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응닡 데이터"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("λ„μ‹œ λͺ©λ‘"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("λ„μ‹œ ID"), + fieldWithPath("data.content[].nameKr").type(JsonFieldType.STRING).description("λ„μ‹œ ν•œκΈ€ 이름"), + fieldWithPath("data.content[].nameEn").type(JsonFieldType.STRING).description("λ„μ‹œ 영문 이름"), + fieldWithPath("data.content[].priority").type(JsonFieldType.NUMBER).description("μš°μ„ μˆœμœ„").optional(), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("μˆ˜μ • μ‹œκ°"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("νŽ˜μ΄μ§• 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("ν˜„μž¬ νŽ˜μ΄μ§€"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 νŽ˜μ΄μ§€ 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 ν•­λͺ© 수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("νŽ˜μ΄μ§€ 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 νŽ˜μ΄μ§€ μ—¬λΆ€"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("λ§ˆμ§€λ§‰ νŽ˜μ΄μ§€ μ—¬λΆ€"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€").optional() + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ ν‚€μ›Œλ“œ 검색 성곡") + @Test + void getCities_withKeyword_success() throws Exception { + setAdminAuthentication(); + LocalDateTime now = LocalDateTime.now(); + + List content = List.of( + new CityQueryResult(1L, "μ„œμšΈ", "Seoul", 1, now) + ); + + PaginationResponse pageResponse = PaginationResponse.of( + content, 1, 20, 1, 1 + ); + + given(adminCityQueryUseCase.getCities(any(CitySearchQuery.class))).willReturn(pageResponse); + + mockMvc.perform(get("/api/admin/cities") + .header("Authorization", "Bearer admin-token") + .param("countryId", "83") + .param("keyword", "μ„œμšΈ") + .param("pageNo", "1") + .param("pageSize", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].nameKr").value("μ„œμšΈ")) + .andExpect(jsonPath("$.data.content[0].nameEn").value("Seoul")) + .andExpect(jsonPath("$.data.pagination.totalItems").value(1)); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ μΆ”κ°€ 성곡") + @Test + void createCity_success() throws Exception { + setAdminAuthentication(); + + CreateCityRequest request = new CreateCityRequest( + "Seoul", "μ„œμšΈ", 37.56, 126.97, 1L + ); + + doNothing().when(adminManagementService).createCity( + new AdminCreateCityCommand( + request.nameEn(), + request.nameKr(), + request.latitude(), + request.longitude(), + request.countryId() + ) + ); + + mockMvc.perform(post("/api/admin/cities") + .header("Authorization", "Bearer admin-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("λ„μ‹œκ°€ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/create-city", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œ ν•„μš”") + ), + requestFields( + fieldWithPath("nameEn").type(JsonFieldType.STRING).description("λ„μ‹œ 영문λͺ…"), + fieldWithPath("nameKr").type(JsonFieldType.STRING).description("λ„μ‹œ ν•œκΈ€λͺ…"), + fieldWithPath("latitude").type(JsonFieldType.NUMBER).description("μœ„λ„"), + fieldWithPath("longitude").type(JsonFieldType.NUMBER).description("경도"), + fieldWithPath("countryId").type(JsonFieldType.NUMBER).description("λ‚˜λΌ ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ 이름 μˆ˜μ • 성곡") + @Test + void updateCityName_success() throws Exception { + setAdminAuthentication(); + Long cityId = 1L; + + UpdateCityRequest request = new UpdateCityRequest( + "Seoul", + "μ„œμšΈνŠΉλ³„μ‹œ" + ); + + doNothing().when(adminManagementService).updateCity( + new AdminUpdateCityCommand( + cityId, + request.nameEn(), + request.nameKr() + ) + ); + + mockMvc.perform(patch("/api/admin/cities/{cityId}/name", cityId) + .header("Authorization", "Bearer admin-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("λ„μ‹œ 이름이 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/update-city-name", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œ ν•„μš”") + ), + pathParameters( + parameterWithName("cityId").description("μˆ˜μ •ν•  λ„μ‹œ ID") + ), + requestFields( + fieldWithPath("nameEn").type(JsonFieldType.STRING).description("λ„μ‹œ 영문λͺ…"), + fieldWithPath("nameKr").type(JsonFieldType.STRING).description("λ„μ‹œ ν•œκΈ€λͺ…") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ μ‚­μ œ 성곡") + @Test + void deleteCity_success() throws Exception { + setAdminAuthentication(); + Long cityId = 1L; + + doNothing().when(adminManagementService) + .deleteCity(new AdminDeleteCityCommand(cityId)); + + mockMvc.perform(delete("/api/admin/cities/{cityId}", cityId) + .header("Authorization", "Bearer admin-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("λ„μ‹œκ°€ μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/delete-city", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œ ν•„μš”") + ), + pathParameters( + parameterWithName("cityId").description("μ‚­μ œν•  λ„μ‹œ ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ μš°μ„ μˆœμœ„ μ„€μ • 성곡") + @Test + void updateCityPriority_success() throws Exception { + setAdminAuthentication(); + Long cityId = 1L; + + doNothing().when(adminManagementService) + .updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, 1)); + + mockMvc.perform(patch("/api/admin/cities/{cityId}/priority", cityId) + .header("Authorization", "Bearer admin-token") + .param("priority", "1")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("μš°μ„ μˆœμœ„κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/update-city-priority", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œ ν•„μš”") + ), + pathParameters( + parameterWithName("cityId").description("λ„μ‹œ ID") + ), + queryParameters( + parameterWithName("priority").description("μš°μ„ μˆœμœ„ (1 이상, λ―Έμž…λ ₯ μ‹œ μ΄ˆκΈ°ν™”)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + @DisplayName("λ„μ‹œ μš°μ„ μˆœμœ„ μ΄ˆκΈ°ν™” 성곡") + @Test + void updateCityPriority_reset_success() throws Exception { + setAdminAuthentication(); + Long cityId = 1L; + + doNothing().when(adminManagementService) + .updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, null)); + + mockMvc.perform(patch("/api/admin/cities/{cityId}/priority", cityId) + .header("Authorization", "Bearer admin-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("μš°μ„ μˆœμœ„κ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")) + .andDo(document("admin/reset-city-priority", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken} - SUPER_ADMIN λ˜λŠ” ADMIN κΆŒν•œ ν•„μš”") + ), + pathParameters( + parameterWithName("cityId").description("λ„μ‹œ ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응닡 데이터 (μ—†μŒ)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응닡 λ©”μ‹œμ§€") + ) + )); + + SecurityContextHolder.clearContext(); + } + + private void setSuperAdminAuthentication() { + Admin superAdmin = Admin.create("superadmin", "password", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + superAdmin, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void setAdminAuthentication() { + Admin admin = Admin.create("admin", "password", AdminRole.ADMIN, + new TestAdminPasswordEncoder()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + admin, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void setSuperAdminAuthenticationWithAdmin(Admin admin) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + admin, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +}