diff --git a/.gitignore b/.gitignore index 4680a52b..0295d342 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 e7ac8db5..795b8288 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 008edb4e..70b1dec0 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 76855f30..00000000 --- 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 a82db455..00000000 --- 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 73b4f16a..00000000 --- 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 1e48fdc2..00000000 --- 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 0a070f45..00000000 --- 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 1204d159..00000000 --- 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 41b29f2d..00000000 --- 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 7123a149..00000000 --- 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 a6e0c31d..00000000 --- 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 68df71b4..00000000 --- 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 cd008b8e..6b7c940a 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 ff900697..00000000 --- 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 96043223..00000000 --- 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 15f28663..00000000 --- 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 e9ac14ba..00000000 --- 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 827a7838..00000000 --- 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 daebaccb..00000000 --- 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 808ecf23..00000000 --- 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 1bdbdd3f..00000000 --- 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 f2d1c8b2..00000000 --- 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 062e0026..00000000 --- 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 f07e0b7b..00000000 --- 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 16723124..00000000 --- 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 aa751d85..00000000 --- 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 a2706fcc..00000000 --- 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 409d9fa5..00000000 --- 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 69b21321..00000000 --- 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 63227a63..00000000 --- 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 5b876460..00000000 --- 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 d8e345cd..00000000 --- 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 0988f9b5..00000000 --- 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 641ccca4..00000000 --- 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 58392d38..00000000 --- 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 6c3ddc71..57cc7d48 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 e0390525..00000000 --- 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 cef4b673..00000000 --- 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 60de1cf3..00000000 --- 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 d5b0a77a..00000000 --- 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 00000000..73c9282c --- /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 00000000..0000b793 --- /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 00000000..af227741 --- /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 00000000..46536dd8 --- /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 00000000..da8c21bd --- /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 00000000..4ea95839 --- /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 00000000..b7caa8f9 --- /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 00000000..fe2e8edd --- /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 00000000..45c2fd7f --- /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 00000000..36d696dd --- /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 00000000..af824f9f --- /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 00000000..2e1ec4b4 --- /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 00000000..49ac7784 --- /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 00000000..f1d6147b --- /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 00000000..e59ca11e --- /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 00000000..0d4fa6e2 --- /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 00000000..b0db5061 --- /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 d608fc60..d71940ce 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 af6193be..1645bfad 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 00000000..69b81c99 --- /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 00000000..108741fb --- /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 00000000..5b4e19ac --- /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 00000000..37e77ca8 --- /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 00000000..6cbe01d8 --- /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 00000000..c85c0dcd --- /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 00000000..a062f68f --- /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 00000000..2d2231be --- /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 00000000..0103998e --- /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 00000000..8782cffe --- /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 00000000..4ae9a08e --- /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 00000000..853bc38c --- /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 00000000..4bd49b64 --- /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 00000000..06027f23 --- /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 16945061..f7b6887c 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 00000000..17a9abe2 --- /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 93a3987b..b976d747 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 00000000..74f46012 --- /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 00000000..5572d37c --- /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 36dc06dc..f99ddbd2 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 6bb0d494..7a8437de 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 00000000..9a58e762 --- /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 00000000..f83691df --- /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 00000000..985dcb01 --- /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 45093773..8b4eeb17 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 00000000..b3aa9ab1 --- /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 00000000..1eb2fcf5 --- /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 00000000..8e3ca7d0 --- /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 00000000..3a5b1359 --- /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 00000000..d234fc86 --- /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 00000000..fa9cfcd2 --- /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 00000000..87960d77 --- /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 00000000..bb3c89cc --- /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 00000000..a59a413d --- /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 00000000..ef89f1e5 --- /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 00000000..f1e1f53a --- /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 00000000..37db88ea --- /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 00000000..4e228c48 --- /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 ef2283ae..d84bec2f 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 23e250ad..08eb790e 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 0beede36..a848b411 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 5fa3460d..6d116753 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 bc2e9a94..00000000 --- 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 be3d4cf2..00000000 --- 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 8199d4df..6a5dbc68 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 d41338cc..74c45b31 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 2f907bb1..2f06c698 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 0c5c1545..a8955637 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 dd91060e..ead65aed 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 ac4d3f89..00000000 --- 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 b0dba9bd..00000000 --- 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 b8b81951..00000000 --- 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 5c5debc8..dcbb2a51 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 7c792fad..00000000 --- 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 f816b43a..00000000 --- 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 9f09a117..00000000 --- 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 cca2b6f8..52646a1b 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 dd77a838..00000000 --- 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 c511efed..00000000 --- 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 917611aa..00000000 --- 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 00000000..251c0d06 --- /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 00000000..f1953947 --- /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 00000000..94839b6c --- /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 00000000..be99a6a1 --- /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 00000000..8bb66a1a --- /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 00000000..90db9229 --- /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 00000000..7bc731de --- /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 00000000..23208d4e --- /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 00000000..b89dacef --- /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 00000000..46da1922 --- /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 00000000..7a72349a --- /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 00000000..6286d35a --- /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 00000000..767d0d3e --- /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 00000000..fdd1809b --- /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 00000000..7fd3aba3 --- /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); + } +}