diff --git a/deploy/prod/docker-compose.db.yaml b/deploy/prod/docker-compose.db.yaml new file mode 100644 index 00000000..e44a9ba2 --- /dev/null +++ b/deploy/prod/docker-compose.db.yaml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + postgres: + image: postgis/postgis:16-3.4 + container_name: souzip-prod-db + restart: always + networks: + - souzip-network + ports: + - "127.0.0.1:5432:5432" + env_file: + - .env.prod + environment: + POSTGRES_DB: souzip_prod + POSTGRES_USER: ${PROD_POSTGRES_USER} + POSTGRES_PASSWORD: ${PROD_POSTGRES_PASSWORD} + TZ: Asia/Seoul + volumes: + - postgres-data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${PROD_POSTGRES_USER} -d souzip_prod"] + interval: 10s + timeout: 5s + retries: 10 + +networks: + souzip-network: + external: true + +volumes: + postgres-data: + driver: local \ No newline at end of file diff --git a/deploy/prod/scripts/deploy.sh b/deploy/prod/scripts/deploy.sh index fc852d83..ebfe38ff 100755 --- a/deploy/prod/scripts/deploy.sh +++ b/deploy/prod/scripts/deploy.sh @@ -15,6 +15,7 @@ BLUE_COMPOSE="docker-compose.blue.yaml" GREEN_COMPOSE="docker-compose.green.yaml" MONITORING_COMPOSE="docker-compose.monitoring.yaml" LOGGING_COMPOSE="docker-compose.logging.yaml" +DB_COMPOSE="docker-compose.db.yaml" BLUE_PROJECT="souzip-blue" GREEN_PROJECT="souzip-green" @@ -40,7 +41,7 @@ fi cd "$WORK_DIR" || exit 1 -echo -e "${YELLOW}[1/8] 최신 이미지 다운로드${NC}" +echo -e "${YELLOW}[1/9] 최신 이미지 다운로드${NC}" docker pull ${REGISTRY}:latest @@ -60,7 +61,7 @@ cd "$DEPLOY_DIR" || exit 1 exit 1 } -echo -e "${YELLOW}[2/8] 현재 active 포트 확인${NC}" +echo -e "${YELLOW}[2/9] 현재 active 포트 확인${NC}" CURRENT_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true) @@ -89,7 +90,19 @@ fi echo -e "${GREEN}[INFO] 현재:$CURRENT_PORT → 배포:$TARGET($TARGET_PORT)${NC}" -echo -e "${YELLOW}[3/8] 모니터링 스택 확인${NC}" +echo -e "${YELLOW}[3/9] DB 컨테이너 확인${NC}" +if ! docker ps --format '{{.Names}}' | grep -q '^souzip-prod-db$'; then + docker compose -f "$DB_COMPOSE" up -d + if [ $? -ne 0 ]; then + echo -e "${RED}[ERROR] DB 컨테이너 시작 실패${NC}" + exit 1 + fi + echo -e "${GREEN}[SUCCESS] DB 컨테이너 시작 완료${NC}" +else + echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[4/9] 모니터링 스택 확인${NC}" PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-prod$' && echo "true" || echo "false") GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-prod$' && echo "true" || echo "false") NODE_EXPORTER_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-node-exporter$' && echo "true" || echo "false") @@ -110,7 +123,7 @@ else echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}" fi -echo -e "${YELLOW}[3.5/8] 로그 수집 스택 확인${NC}" +echo -e "${YELLOW}[4.5/9] 로그 수집 스택 확인${NC}" LOKI_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-loki-prod$' && echo "true" || echo "false") PROMTAIL_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-promtail-prod$' && echo "true" || echo "false") @@ -129,12 +142,12 @@ else echo -e "${GREEN}[SUCCESS] 로그 수집 스택 이미 실행 중${NC}" fi -echo -e "${YELLOW}[4/8] $TARGET 컨테이너 실행${NC}" +echo -e "${YELLOW}[5/9] $TARGET 컨테이너 실행${NC}" docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" pull docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" up -d -echo -e "${YELLOW}[5/8] 헬스체크 시작${NC}" +echo -e "${YELLOW}[6/9] 헬스체크 시작${NC}" RETRY_COUNT=0 HEALTH_OK=false @@ -164,7 +177,7 @@ if [ "$HEALTH_OK" = false ]; then exit 1 fi -echo -e "${YELLOW}[6/8] nginx upstream 전환${NC}" +echo -e "${YELLOW}[7/9] nginx upstream 전환${NC}" sudo tee "$NGINX_UPSTREAM_FILE" > /dev/null < /dev/null \ No newline at end of file diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index 2095e924..bb5a6ec2 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -15,6 +15,7 @@ include::../shared/overview.adoc[] include::auth.adoc[] include::user.adoc[] include::souvenir.adoc[] +include::wishlist.adoc[] include::category.adoc[] include::country.adoc[] include::discovery.adoc[] diff --git a/src/docs/asciidoc/app/souvenir.adoc b/src/docs/asciidoc/app/souvenir.adoc index bc17c4f1..0e981db6 100644 --- a/src/docs/asciidoc/app/souvenir.adoc +++ b/src/docs/asciidoc/app/souvenir.adoc @@ -2,13 +2,14 @@ == Souvenir API [[souvenir-get]] -=== 기념품 조회 +=== 기념품 조회 (new) 기념품 상세 정보를 조회하는 API입니다. * 인증 불필요 (Public API) * 삭제된(soft delete) 기념품은 조회할 수 없습니다. -* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned) 함께 반환 +* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned), 찜 여부(isWishlisted) 함께 반환 +* 비로그인 사용자의 경우 `isWishlisted` 는 `null` 로 반환 operation::souvenirs/get-souvenir[snippets='http-request,path-parameters,http-response,response-fields'] @@ -19,7 +20,7 @@ NOTE: v2 응답 구조 - `price` 필드에 원본 가격(original)과 등록 시 WARNING: 응답의 `localPrice`, `currencySymbol`, `krwPrice` 필드는 v1 호환성을 위해 임시로 제공되며, 향후 제거될 예정입니다. `price` 객체(`price.original.amount`, `price.original.symbol`, `price.converted.amount`) 사용을 권장합니다. [[souvenir-create]] -=== 기념품 생성 (New) +=== 기념품 생성 사용자가 새로운 기념품을 등록하는 v2 API입니다. @@ -59,7 +60,7 @@ operation::souvenirs/create-souvenir[snippets='http-request,request-parts,reques NOTE: 기념품 가격은 현지 통화 또는 원화 기준으로 입력할 수 있으며, 서버에서 환율이 자동 적용됩니다. [[souvenir-update]] -=== 기념품 수정 (New) +=== 기념품 수정 v2 API를 사용한 기념품 수정입니다. @@ -106,7 +107,7 @@ operation::souvenirs/delete-souvenir[snippets='http-request,path-parameters,http NOTE: 삭제 성공 시 `data`는 반환되지 않으며, 메시지만 전달됩니다. [[souvenir-nearby]] -=== 근처 기념품 조회 +=== 근처 기념품 조회 (new) 위도/경도를 통해 특정 반경 내 근처 기념품을 조회하는 API입니다. @@ -114,5 +115,6 @@ NOTE: 삭제 성공 시 `data`는 반환되지 않으며, 메시지만 전달됩 * 인증 불필요 (Public API) * 삭제된(soft delete) 기념품은 조회할 수 없습니다. * 조회 반경 단위는 미터(m) +* 로그인한 사용자가 조회하면 찜 여부(isWishlisted) 함께 반환, 비로그인 시 `null` operation::souvenirs/get-nearby-souvenirs[snippets='http-request,query-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/app/user.adoc b/src/docs/asciidoc/app/user.adoc index 9a077318..86d700f9 100644 --- a/src/docs/asciidoc/app/user.adoc +++ b/src/docs/asciidoc/app/user.adoc @@ -94,4 +94,24 @@ operation::user/my-souvenirs-page-2[snippets='http-request,query-parameters,http ==== 등록한 기념품이 없는 경우 operation::user/my-souvenirs-empty[snippets='http-request,query-parameters,http-response,response-fields'] -NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다. 찜 탭 기능은 추후 구현 예정입니다. +NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다. + +[[user-my-wishlists]] +==== 내가 찜한 기념품 목록 조회 (new) + +찜 탭에 표시할 기념품 목록을 조회합니다. + +* 인증 필요 +* 찜한 순서(최신순)로 정렬됩니다. + +operation::user/my-wishlists[snippets='http-request,query-parameters,http-response,response-fields'] + +==== 페이징 예시 + +===== 2페이지 조회 +operation::user/my-wishlists-page-2[snippets='http-request,query-parameters,http-response,response-fields'] + +==== 찜한 기념품이 없는 경우 +operation::user/my-wishlists-empty[snippets='http-request,query-parameters,http-response,response-fields'] + +NOTE: 기념품 목록은 찜한 시간 기준 내림차순으로 정렬됩니다. diff --git a/src/docs/asciidoc/app/wishlist.adoc b/src/docs/asciidoc/app/wishlist.adoc new file mode 100644 index 00000000..d21edd74 --- /dev/null +++ b/src/docs/asciidoc/app/wishlist.adoc @@ -0,0 +1,24 @@ +[[wishlist-api]] +== Wishlist API + +[[wishlist-add]] +=== 찜 등록 (new) + +기념품을 찜 목록에 추가하는 API입니다. + +* 엔드포인트: `POST /api/wishlists/{souvenirId}` +* 인증 필요 +* 이미 찜한 기념품에 다시 찜 요청을 보내면 에러가 반환됩니다. + +operation::wishlists/add[snippets='http-request,path-parameters,http-response,response-fields'] + +[[wishlist-remove]] +=== 찜 취소 (new) + +찜 목록에서 기념품을 제거하는 API입니다. + +* 엔드포인트: `DELETE /api/wishlists/{souvenirId}` +* 인증 필요 +* 찜하지 않은 기념품을 취소하려 하면 에러가 반환됩니다. + +operation::wishlists/remove[snippets='http-request,path-parameters,http-response,response-fields'] \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java index 44735ac0..b21c9847 100644 --- a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java +++ b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java @@ -5,7 +5,7 @@ import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse.Result; import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.required.AddressProvider; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java b/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java index a7b5d913..d5a3b24b 100644 --- a/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java +++ b/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java @@ -4,8 +4,7 @@ import com.souzip.adapter.integration.googleplaces.dto.GooglePlacesSearchResponse.Result; import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.required.PlaceSearchProvider; -import com.souzip.domain.shared.Coordinate; -import java.util.Optional; +import com.souzip.shared.domain.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -14,6 +13,7 @@ import java.math.BigDecimal; import java.util.List; +import java.util.Optional; @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java index 821a9b5d..dee542f4 100644 --- a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java +++ b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java @@ -4,8 +4,8 @@ import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; import com.souzip.application.file.required.FileStorage; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java index 9ced0c31..6c3f0f2d 100644 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java @@ -5,7 +5,7 @@ import com.souzip.application.email.EmailBroadcastService; import com.souzip.application.email.dto.EmailBroadcastResult; import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; 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 6b7c940a..f843539f 100644 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java @@ -8,13 +8,16 @@ 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 com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; + import java.util.Optional; + import lombok.RequiredArgsConstructor; import java.util.List; import java.util.UUID; + import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java index b9eb60a3..7464dcd2 100644 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java @@ -4,23 +4,21 @@ import com.souzip.application.notification.FcmNotificationService; import com.souzip.application.notification.PushBroadcastHistoryCommandService; import com.souzip.application.notification.PushBroadcastHistoryQueryService; +import com.souzip.application.notification.dto.MarketingConsentStats; import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; import com.souzip.application.notification.dto.PushBroadcastResult; 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.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.domain.user.repository.UserAgreementRepository; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; import jakarta.validation.Valid; -import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -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 org.springframework.web.bind.annotation.*; + +import java.util.UUID; @RequestMapping("/api/admin/push") @RequiredArgsConstructor @@ -30,15 +28,16 @@ public class AdminPushApi { private final FcmNotificationService fcmNotificationService; private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService; private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService; + private final UserAgreementRepository userAgreementRepository; - // 관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다. + // 마케팅 수신 동의한 사용자에게만 푸시를 발송합니다. @AdminAccess @PostMapping("/broadcast") public SuccessResponse broadcast( @CurrentAdminId UUID adminId, @Valid @RequestBody PushBroadcastRequest request ) { - PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens( + PushBroadcastResult result = fcmNotificationService.broadcastToMarketingConsentedTokens( request.title(), request.body() ); @@ -47,6 +46,15 @@ public SuccessResponse broadcast( return SuccessResponse.of(result, message); } + // 마케팅 수신 동의 현황을 조회합니다. + @ViewerAccess + @GetMapping("/marketing-consent/stats") + public SuccessResponse marketingConsentStats() { + long total = userAgreementRepository.count(); + long consented = userAgreementRepository.countByMarketingConsentTrue(); + return SuccessResponse.of(new MarketingConsentStats(total, consented, total - consented)); + } + // 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. @ViewerAccess @GetMapping("/broadcast/history") diff --git a/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java b/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java index 413e8589..d1c1227f 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java +++ b/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java @@ -3,11 +3,14 @@ import com.souzip.adapter.webapi.location.dto.AddressResponse; import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; -import com.souzip.domain.shared.Coordinate; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.domain.Coordinate; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; diff --git a/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java b/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java index 6e38ab20..b895cc21 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java +++ b/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java @@ -6,15 +6,18 @@ import com.souzip.application.location.dto.SearchResult; import com.souzip.application.location.provided.LocationSearch; import com.souzip.application.location.provided.ReverseGeocoding; -import com.souzip.domain.shared.Coordinate; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.RequireAuth; -import java.util.List; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.domain.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; +import java.util.List; @Slf4j @RequestMapping("/api/location") diff --git a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java index 13a1d706..542e5681 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java +++ b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java @@ -6,7 +6,8 @@ import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.SearchResult; import com.souzip.domain.city.entity.City; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; + import java.util.List; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java index 57254c86..c7c6068c 100644 --- a/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java +++ b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java @@ -2,7 +2,7 @@ import com.souzip.application.notice.dto.NoticeResponse; import com.souzip.application.notice.provided.NoticeFinder; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/souzip/application/email/EmailBroadcastService.java b/src/main/java/com/souzip/application/email/EmailBroadcastService.java index b5caadd3..0a66817a 100644 --- a/src/main/java/com/souzip/application/email/EmailBroadcastService.java +++ b/src/main/java/com/souzip/application/email/EmailBroadcastService.java @@ -2,12 +2,14 @@ import com.souzip.application.email.dto.EmailBroadcastResult; import com.souzip.application.email.provided.UserEmailFinder; -import com.souzip.global.config.MailSmtpProperties; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.config.MailSmtpProperties; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; + import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; diff --git a/src/main/java/com/souzip/application/location/ReverseGeocodingService.java b/src/main/java/com/souzip/application/location/ReverseGeocodingService.java index 1393c9a5..a878f4e1 100644 --- a/src/main/java/com/souzip/application/location/ReverseGeocodingService.java +++ b/src/main/java/com/souzip/application/location/ReverseGeocodingService.java @@ -3,7 +3,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; import com.souzip.application.location.required.AddressProvider; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/souzip/application/location/dto/SearchPlace.java b/src/main/java/com/souzip/application/location/dto/SearchPlace.java index 23f0c16b..37d5ba47 100644 --- a/src/main/java/com/souzip/application/location/dto/SearchPlace.java +++ b/src/main/java/com/souzip/application/location/dto/SearchPlace.java @@ -1,6 +1,6 @@ package com.souzip.application.location.dto; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; public record SearchPlace( String name, diff --git a/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java b/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java index 2b012523..c5071de9 100644 --- a/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java +++ b/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java @@ -1,7 +1,7 @@ package com.souzip.application.location.provided; import com.souzip.application.location.dto.AddressResult; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; public interface ReverseGeocoding { diff --git a/src/main/java/com/souzip/application/location/required/AddressProvider.java b/src/main/java/com/souzip/application/location/required/AddressProvider.java index fb2666b4..9fa20fb9 100644 --- a/src/main/java/com/souzip/application/location/required/AddressProvider.java +++ b/src/main/java/com/souzip/application/location/required/AddressProvider.java @@ -1,7 +1,7 @@ package com.souzip.application.location.required; import com.souzip.application.location.dto.AddressResult; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; public interface AddressProvider { diff --git a/src/main/java/com/souzip/application/notification/FcmNotificationService.java b/src/main/java/com/souzip/application/notification/FcmNotificationService.java index 2846da64..14207f0d 100644 --- a/src/main/java/com/souzip/application/notification/FcmNotificationService.java +++ b/src/main/java/com/souzip/application/notification/FcmNotificationService.java @@ -7,11 +7,13 @@ import com.souzip.application.notification.dto.PushBroadcastResult; import com.souzip.application.notification.provided.FcmTokenFinder; import com.souzip.domain.notification.FcmToken; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.util.HashMap; import java.util.List; import java.util.Map; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; @@ -85,9 +87,19 @@ public void sendToUser(Long userId, String title, String body) { } } - // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송 루프는 트랜잭션 밖에서 수행합니다. + // 마케팅 수신 동의한 사용자의 활성 토큰에만 푸시를 발송합니다. + public PushBroadcastResult broadcastToMarketingConsentedTokens(String title, String body) { + List tokens = fcmTokenFinder.getAllActiveTokensWithMarketingConsent(); + return doBroadcast(tokens, title, body); + } + + // 활성 토큰 전체에 푸시를 발송합니다. public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) { List tokens = fcmTokenFinder.getAllActiveTokens(); + return doBroadcast(tokens, title, body); + } + + private PushBroadcastResult doBroadcast(List tokens, String title, String body) { if (tokens.isEmpty()) { return new PushBroadcastResult(0, 0, 0, true); } diff --git a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java index c9bd7003..8bdf2fcd 100644 --- a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java +++ b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java @@ -38,4 +38,9 @@ public List getActiveTokensByUserId(Long userId) { public List getAllActiveTokens() { return fcmTokenRepository.findAllByActiveTrue(); } + + @Override + public List getAllActiveTokensWithMarketingConsent() { + return fcmTokenRepository.findAllActiveWithMarketingConsent(); + } } diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java index 7f801f10..48df8f43 100644 --- a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java +++ b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java @@ -3,9 +3,11 @@ import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; import com.souzip.application.notification.required.PushBroadcastHistoryRepository; import com.souzip.domain.notification.PushBroadcastHistory; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.util.List; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java b/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java new file mode 100644 index 00000000..49d644de --- /dev/null +++ b/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java @@ -0,0 +1,8 @@ +package com.souzip.application.notification.dto; + +public record MarketingConsentStats( + long totalUsers, + long consentedUsers, + long declinedUsers +) { +} diff --git a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java index 171d314a..99895949 100644 --- a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java +++ b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java @@ -12,4 +12,6 @@ public interface FcmTokenFinder { List getActiveTokensByUserId(Long userId); List getAllActiveTokens(); + + List getAllActiveTokensWithMarketingConsent(); } diff --git a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java index 7f3da8af..177aa1e9 100644 --- a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java +++ b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java @@ -3,6 +3,7 @@ import com.souzip.domain.notification.FcmToken; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; public interface FcmTokenRepository extends Repository { @@ -19,6 +20,10 @@ public interface FcmTokenRepository extends Repository { List findAllByActiveTrue(); + @Query("SELECT ft FROM FcmToken ft WHERE ft.active = true AND ft.userId IN " + + "(SELECT ua.user.id FROM UserAgreement ua WHERE ua.marketingConsent = true)") + List findAllActiveWithMarketingConsent(); + void delete(FcmToken fcmToken); void deleteByDeviceId(String deviceId); diff --git a/src/main/java/com/souzip/global/config/WebConfig.java b/src/main/java/com/souzip/auth/adapter/config/WebConfig.java similarity index 87% rename from src/main/java/com/souzip/global/config/WebConfig.java rename to src/main/java/com/souzip/auth/adapter/config/WebConfig.java index 74c45b31..951a2751 100644 --- a/src/main/java/com/souzip/global/config/WebConfig.java +++ b/src/main/java/com/souzip/auth/adapter/config/WebConfig.java @@ -1,13 +1,14 @@ -package com.souzip.global.config; +package com.souzip.auth.adapter.config; +import com.souzip.auth.adapter.security.resolver.CurrentUserIdArgumentResolver; 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/domain/auth/scheduler/RefreshTokenCleanupScheduler.java b/src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java similarity index 78% rename from src/main/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupScheduler.java rename to src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java index 320eb016..1b2da7f0 100644 --- a/src/main/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupScheduler.java +++ b/src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java @@ -1,13 +1,14 @@ -package com.souzip.domain.auth.scheduler; +package com.souzip.auth.adapter.integration.scheduler; -import com.souzip.domain.auth.repository.RefreshTokenRepository; -import java.time.LocalDateTime; +import com.souzip.auth.application.required.RefreshTokenRepository; 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 @@ -19,7 +20,9 @@ public class RefreshTokenCleanupScheduler { @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") public void cleanUpExpiredRefreshTokens() { LocalDateTime now = LocalDateTime.now(); - int deletedCount = refreshTokenRepository.deleteAllByExpiresAtBefore(now); + + int deletedCount = refreshTokenRepository.deleteAllExpiredBefore(now); + log.info("[Token Scheduler] 만료된 Refresh Token {}개 삭제 완료 (실행 시각: {})", deletedCount, now); } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/security/annotation/CurrentUserId.java b/src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java similarity index 83% rename from src/main/java/com/souzip/global/security/annotation/CurrentUserId.java rename to src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java index 24393502..b91da375 100644 --- a/src/main/java/com/souzip/global/security/annotation/CurrentUserId.java +++ b/src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java @@ -1,4 +1,4 @@ -package com.souzip.global.security.annotation; +package com.souzip.auth.adapter.security.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/souzip/global/security/annotation/RequireAuth.java b/src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java similarity index 87% rename from src/main/java/com/souzip/global/security/annotation/RequireAuth.java rename to src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java index c1941934..fd7bb22e 100644 --- a/src/main/java/com/souzip/global/security/annotation/RequireAuth.java +++ b/src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java @@ -1,4 +1,4 @@ -package com.souzip.global.security.annotation; +package com.souzip.auth.adapter.security.annotation; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/com/souzip/global/security/config/SecurityConfig.java b/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java similarity index 94% rename from src/main/java/com/souzip/global/security/config/SecurityConfig.java rename to src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java index 614c5757..c2bdb9f0 100644 --- a/src/main/java/com/souzip/global/security/config/SecurityConfig.java +++ b/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java @@ -1,11 +1,11 @@ -package com.souzip.global.security.config; +package com.souzip.auth.adapter.security.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.global.common.dto.ErrorResponse; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.config.CorsProperties; +import com.souzip.auth.adapter.security.jwt.JwtAuthenticationFilter; import com.souzip.domain.admin.infrastructure.security.jwt.AdminJwtAuthenticationFilter; -import com.souzip.global.security.jwt.JwtAuthenticationFilter; +import com.souzip.shared.common.dto.ErrorResponse; +import com.souzip.shared.config.CorsProperties; +import com.souzip.shared.exception.ErrorCode; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; diff --git a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..df451c79 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.souzip.auth.adapter.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String token = extractToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/security/jwt/JwtProperties.java b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java similarity index 88% rename from src/main/java/com/souzip/global/security/jwt/JwtProperties.java rename to src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java index f7369eeb..c9d4ea76 100644 --- a/src/main/java/com/souzip/global/security/jwt/JwtProperties.java +++ b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.security.jwt; +package com.souzip.auth.adapter.security.jwt; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -8,8 +8,9 @@ @RequiredArgsConstructor @ConfigurationProperties(prefix = "jwt") public class JwtProperties { - private final String secret; + private final Long expiration; + private final Long refreshExpiration; } diff --git a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..62e58b2c --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java @@ -0,0 +1,86 @@ +package com.souzip.auth.adapter.security.jwt; + +import com.souzip.auth.application.required.TokenProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtTokenProvider implements TokenProvider { + + private final JwtProperties jwtProperties; + + @Override + public String generateAccessToken(Long userId) { + return createToken(String.valueOf(userId), jwtProperties.getExpiration()); + } + + @Override + public String generateRefreshToken() { + return createToken("refresh", jwtProperties.getRefreshExpiration()); + } + + @Override + public LocalDateTime getRefreshTokenExpiresAt() { + return LocalDateTime.now().plus(jwtProperties.getRefreshExpiration(), ChronoUnit.MILLIS); + } + + public Long getUserIdFromToken(String token) { + return Long.parseLong(parseClaims(token).getSubject()); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + + return true; + } catch (Exception e) { + log.debug("Invalid JWT token: {}", e.getMessage()); + + return false; + } + } + + public String generateToken(String subject) { + return createToken(subject, jwtProperties.getExpiration()); + } + + public String generateRefreshToken(String subject) { + return createToken(subject, jwtProperties.getRefreshExpiration()); + } + + private String createToken(String subject, Long expirationMs) { + Date now = new Date(); + + return Jwts.builder() + .subject(subject) + .issuedAt(now) + .expiration(new Date(now.getTime() + expirationMs)) + .signWith(getSigningKey()) + .compact(); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java b/src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java similarity index 71% rename from src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java rename to src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java index 9ea64811..b7c065d1 100644 --- a/src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java +++ b/src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java @@ -1,10 +1,8 @@ -package com.souzip.global.security.resolver; +package com.souzip.auth.adapter.security.resolver; -import com.souzip.domain.user.entity.User; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.security.annotation.CurrentUserId; -import lombok.RequiredArgsConstructor; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -14,27 +12,26 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -@RequiredArgsConstructor @Component public class CurrentUserIdArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); + && parameter.getParameterType().equals(Long.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) throws Exception { + WebDataBinderFactory binderFactory) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !(authentication.getPrincipal() instanceof User)) { + if (authentication == null || !(authentication.getPrincipal() instanceof Long)) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } - User user = (User) authentication.getPrincipal(); - return user.getId(); + + return (Long) authentication.getPrincipal(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java new file mode 100644 index 00000000..cf9b1fd2 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java @@ -0,0 +1,46 @@ +package com.souzip.auth.adapter.social; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Slf4j +@RequiredArgsConstructor +@Component +public class AppleSocialClient implements SocialClient { + + private final ObjectMapper objectMapper; + + @Override + public Provider getProvider() { + return Provider.APPLE; + } + + @Override + public SocialUserInfo getUserInfo(String identityToken) { + try { + String[] parts = identityToken.split("\\."); + if (parts.length < 2) throw AuthException.invalidJwtStructure(); + + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + AppleTokenPayload token = objectMapper.readValue(payload, AppleTokenPayload.class); + + return new SocialUserInfo(token.sub(), token.email()); + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("애플 사용자 정보 조회 중 오류 발생", e); + throw AuthException.appleApiError(); + } + } + + private record AppleTokenPayload(String sub, String email) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java new file mode 100644 index 00000000..14bcf4c0 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java @@ -0,0 +1,55 @@ +package com.souzip.auth.adapter.social; + +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@RequiredArgsConstructor +@EnableConfigurationProperties(SocialProperties.class) +@Component +public class GoogleSocialClient implements SocialClient { + + private final RestTemplate restTemplate; + private final SocialProperties socialProperties; + + @Override + public Provider getProvider() { + return Provider.GOOGLE; + } + + @Override + public SocialUserInfo getUserInfo(String accessToken) { + try { + GoogleUserResponse response = restTemplate.exchange( + socialProperties.google().userInfoUrl(), + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(accessToken)), + GoogleUserResponse.class + ).getBody(); + + return new SocialUserInfo(response.id(), response.email()); + } catch (Exception e) { + log.error("구글 사용자 정보 조회 중 오류 발생", e); + throw AuthException.googleApiError(); + } + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private record GoogleUserResponse(String id, String email) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java new file mode 100644 index 00000000..ad4e3c79 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java @@ -0,0 +1,62 @@ +package com.souzip.auth.adapter.social; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@RequiredArgsConstructor +@EnableConfigurationProperties(SocialProperties.class) +@Component +public class KakaoSocialClient implements SocialClient { + + private final RestTemplate restTemplate; + private final SocialProperties socialProperties; + + @Override + public Provider getProvider() { + return Provider.KAKAO; + } + + @Override + public SocialUserInfo getUserInfo(String accessToken) { + try { + KakaoUserResponse response = restTemplate.exchange( + socialProperties.kakao().userInfoUrl(), + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(accessToken)), + KakaoUserResponse.class + ).getBody(); + + return new SocialUserInfo( + String.valueOf(response.id()), + response.kakaoAccount().email() + ); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 중 오류 발생", e); + throw AuthException.kakaoApiError(); + } + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private record KakaoUserResponse(Long id, @JsonProperty("kakao_account") KakaoAccount kakaoAccount) { + } + + private record KakaoAccount(String email) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java b/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java new file mode 100644 index 00000000..ecbeef50 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java @@ -0,0 +1,15 @@ +package com.souzip.auth.adapter.social; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth") +public record SocialProperties( + Kakao kakao, + Google google +) { + public record Kakao(String userInfoUrl) { + } + + public record Google(String userInfoUrl) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/AuthApi.java b/src/main/java/com/souzip/auth/adapter/web/AuthApi.java new file mode 100644 index 00000000..67b5dc71 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/AuthApi.java @@ -0,0 +1,48 @@ +package com.souzip.auth.adapter.web; + +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.auth.adapter.web.dto.LoginRequest; +import com.souzip.auth.adapter.web.dto.LoginResponse; +import com.souzip.auth.adapter.web.dto.RefreshRequest; +import com.souzip.auth.adapter.web.dto.RefreshResponse; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.provided.Auth; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@RestController +public class AuthApi { + + private final Auth auth; + + @PostMapping("/login/{provider}") + public SuccessResponse login( + @PathVariable String provider, + @RequestBody LoginRequest request + ) { + LoginInfo info = auth.login(Provider.from(provider), request.accessToken()); + + return SuccessResponse.of(LoginResponse.from(info)); + } + + @PostMapping("/refresh") + public SuccessResponse refresh(@RequestBody RefreshRequest request) { + RefreshInfo info = auth.refresh(request.refreshToken()); + + return SuccessResponse.of(RefreshResponse.from(info)); + } + + @RequireAuth + @PostMapping("/logout") + public SuccessResponse logout(@CurrentUserId Long currentUserId) { + auth.logout(currentUserId); + + return SuccessResponse.of(null, "로그아웃 되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java new file mode 100644 index 00000000..c1fd3e55 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java @@ -0,0 +1,6 @@ +package com.souzip.auth.adapter.web.dto; + +public record LoginRequest( + String accessToken +) { +} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java new file mode 100644 index 00000000..8e4bcd8a --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java @@ -0,0 +1,22 @@ +package com.souzip.auth.adapter.web.dto; + +import com.souzip.auth.application.dto.LoginInfo; + +public record LoginResponse( + String accessToken, + String refreshToken, + boolean needsOnboarding, + UserDto user +) { + public record UserDto(String userId, String nickname) { + } + + public static LoginResponse from(LoginInfo info) { + return new LoginResponse( + info.accessToken(), + info.refreshToken(), + info.needsOnboarding(), + new UserDto(info.userId(), info.nickname()) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java new file mode 100644 index 00000000..e0204e54 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java @@ -0,0 +1,6 @@ +package com.souzip.auth.adapter.web.dto; + +public record RefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java new file mode 100644 index 00000000..2ed925a1 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java @@ -0,0 +1,15 @@ +package com.souzip.auth.adapter.web.dto; + +import com.souzip.auth.application.dto.RefreshInfo; + +public record RefreshResponse( + String accessToken, + String refreshToken +) { + public static RefreshResponse from(RefreshInfo info) { + return new RefreshResponse( + info.accessToken(), + info.refreshToken() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/AuthService.java b/src/main/java/com/souzip/auth/application/AuthService.java new file mode 100644 index 00000000..73b9c2d9 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/AuthService.java @@ -0,0 +1,92 @@ +package com.souzip.auth.application; + +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.provided.Auth; +import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.auth.application.required.TokenProvider; +import com.souzip.auth.application.required.UserPort; +import com.souzip.auth.domain.RefreshToken; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AuthService implements Auth { + + private final SocialClientFactory socialClientFactory; + private final UserPort userPort; + private final RefreshTokenRepository refreshTokenRepository; + private final TokenProvider tokenProvider; + + @Transactional + @Override + public LoginInfo login(Provider provider, String accessToken) { + var socialUserInfo = socialClientFactory.getClient(provider) + .getUserInfo(accessToken); + + UserInfo userInfo = userPort.findOrCreateUser( + provider, + socialUserInfo.providerId(), + socialUserInfo.email() + ); + + refreshTokenRepository.deleteByUserId(userInfo.id()); + + String newRefreshToken = tokenProvider.generateRefreshToken(); + refreshTokenRepository.save( + RefreshToken.create(userInfo.id(), newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()) + ); + + log.info("로그인 성공 - userId: {}, provider: {}", userInfo.id(), provider); + + return new LoginInfo( + tokenProvider.generateAccessToken(userInfo.id()), + newRefreshToken, + userInfo.needsOnboarding(), + userInfo.userId(), + userInfo.nickname() + ); + } + + @Transactional + @Override + public RefreshInfo refresh(String refreshToken) { + RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(AuthException::invalidRefreshToken); + + if (storedToken.isExpired()) { + refreshTokenRepository.delete(storedToken); + + throw AuthException.expiredRefreshToken(); + } + + String newRefreshToken = tokenProvider.generateRefreshToken(); + + storedToken.rotate(newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()); + + refreshTokenRepository.save(storedToken); + + log.debug("토큰 재발급 - userId: {}", storedToken.getUserId()); + + return new RefreshInfo( + tokenProvider.generateAccessToken(storedToken.getUserId()), + newRefreshToken + ); + } + + @Transactional + @Override + public void logout(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + + log.info("로그아웃 - userId: {}", userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/SocialClientFactory.java b/src/main/java/com/souzip/auth/application/SocialClientFactory.java new file mode 100644 index 00000000..146b801b --- /dev/null +++ b/src/main/java/com/souzip/auth/application/SocialClientFactory.java @@ -0,0 +1,27 @@ +package com.souzip.auth.application; + +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Component +public class SocialClientFactory { + + private final Map clients; + + public SocialClientFactory(List socialClients) { + this.clients = socialClients.stream() + .collect(Collectors.toMap(SocialClient::getProvider, c -> c)); + } + + public SocialClient getClient(Provider provider) { + return Optional.ofNullable(clients.get(provider)) + .orElseThrow(AuthException::unsupportedProvider); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/LoginInfo.java b/src/main/java/com/souzip/auth/application/dto/LoginInfo.java new file mode 100644 index 00000000..cfb95297 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/LoginInfo.java @@ -0,0 +1,10 @@ +package com.souzip.auth.application.dto; + +public record LoginInfo( + String accessToken, + String refreshToken, + boolean needsOnboarding, + String userId, + String nickname +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java b/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java new file mode 100644 index 00000000..549433bb --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java @@ -0,0 +1,7 @@ +package com.souzip.auth.application.dto; + +public record RefreshInfo( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java b/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java new file mode 100644 index 00000000..9ab39b14 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java @@ -0,0 +1,4 @@ +package com.souzip.auth.application.dto; + +public record SocialUserInfo(String providerId, String email) { +} diff --git a/src/main/java/com/souzip/auth/application/dto/UserInfo.java b/src/main/java/com/souzip/auth/application/dto/UserInfo.java new file mode 100644 index 00000000..5fc2421d --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/UserInfo.java @@ -0,0 +1,9 @@ +package com.souzip.auth.application.dto; + +public record UserInfo( + Long id, + String userId, + String nickname, + boolean needsOnboarding +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java b/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java new file mode 100644 index 00000000..e32f6cd1 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java @@ -0,0 +1,27 @@ +package com.souzip.auth.application.exception; + +import com.souzip.shared.exception.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), + + INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), + INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), + TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), + TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), + UNSUPPORTED_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인입니다."), + + KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), + GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), + APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."); + + private final HttpStatus status; + + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthException.java b/src/main/java/com/souzip/auth/application/exception/AuthException.java new file mode 100644 index 00000000..8cb237a7 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/exception/AuthException.java @@ -0,0 +1,46 @@ +package com.souzip.auth.application.exception; + +import com.souzip.shared.exception.BusinessException; + +public class AuthException extends BusinessException { + + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } + + public static AuthException invalidRefreshToken() { + return new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + public static AuthException expiredRefreshToken() { + return new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN); + } + + public static AuthException unsupportedProvider() { + return new AuthException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + + public static AuthException invalidJwtStructure() { + return new AuthException(AuthErrorCode.INVALID_JWT_STRUCTURE); + } + + public static AuthException tokenDecodeFailed() { + return new AuthException(AuthErrorCode.TOKEN_DECODE_FAILED); + } + + public static AuthException tokenParseFailed() { + return new AuthException(AuthErrorCode.TOKEN_PARSE_FAILED); + } + + public static AuthException kakaoApiError() { + return new AuthException(AuthErrorCode.KAKAO_API_ERROR); + } + + public static AuthException googleApiError() { + return new AuthException(AuthErrorCode.GOOGLE_API_ERROR); + } + + public static AuthException appleApiError() { + return new AuthException(AuthErrorCode.APPLE_API_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/provided/Auth.java b/src/main/java/com/souzip/auth/application/provided/Auth.java new file mode 100644 index 00000000..3659c1b5 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/provided/Auth.java @@ -0,0 +1,13 @@ +package com.souzip.auth.application.provided; + +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.shared.domain.Provider; + +public interface Auth { + LoginInfo login(Provider provider, String accessToken); + + RefreshInfo refresh(String refreshToken); + + void logout(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java b/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java new file mode 100644 index 00000000..c2bcf69f --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java @@ -0,0 +1,24 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.domain.RefreshToken; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface RefreshTokenRepository extends Repository { + RefreshToken save(RefreshToken token); + + Optional findByToken(String token); + + void deleteByUserId(Long userId); + + void delete(RefreshToken token); + + @Modifying + @Query("DELETE FROM RefreshToken t WHERE t.expiresAt < :now") + int deleteAllExpiredBefore(@Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/SocialClient.java b/src/main/java/com/souzip/auth/application/required/SocialClient.java new file mode 100644 index 00000000..1fb80087 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/SocialClient.java @@ -0,0 +1,10 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.shared.domain.Provider; + +public interface SocialClient { + SocialUserInfo getUserInfo(String accessToken); + + Provider getProvider(); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/TokenProvider.java b/src/main/java/com/souzip/auth/application/required/TokenProvider.java new file mode 100644 index 00000000..6757ee11 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/TokenProvider.java @@ -0,0 +1,11 @@ +package com.souzip.auth.application.required; + +import java.time.LocalDateTime; + +public interface TokenProvider { + String generateAccessToken(Long userId); + + String generateRefreshToken(); + + LocalDateTime getRefreshTokenExpiresAt(); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/UserPort.java b/src/main/java/com/souzip/auth/application/required/UserPort.java new file mode 100644 index 00000000..144e14d6 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/UserPort.java @@ -0,0 +1,8 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.shared.domain.Provider; + +public interface UserPort { + UserInfo findOrCreateUser(Provider provider, String providerId, String email); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/domain/RefreshToken.java b/src/main/java/com/souzip/auth/domain/RefreshToken.java new file mode 100644 index 00000000..3cb51e67 --- /dev/null +++ b/src/main/java/com/souzip/auth/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.souzip.auth.domain; + +import com.souzip.shared.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class RefreshToken extends BaseEntity { + + private Long userId; + + private String token; + + private LocalDateTime expiresAt; + + public static RefreshToken create(Long userId, String token, LocalDateTime expiresAt) { + return new RefreshToken(userId, token, expiresAt); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public void rotate(String newToken, LocalDateTime newExpiresAt) { + this.token = newToken; + this.expiresAt = newExpiresAt; + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java index 73c9282c..7447ceb9 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java @@ -1,5 +1,6 @@ package com.souzip.domain.admin.application; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.application.command.AdminLoginCommand; import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; @@ -11,7 +12,6 @@ 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; @@ -37,7 +37,7 @@ public class AdminAuthService { @Transactional public AdminLoginResult login(AdminLoginCommand command) { Admin admin = adminRepository.findByUsername(new Username(command.username())) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); validatePassword(admin, command.password()); @@ -58,7 +58,7 @@ public RefreshResult refresh(String refreshTokenValue) { validateRefreshToken(refreshToken); Admin admin = adminRepository.findById(refreshToken.getAdminId()) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); String newAccessToken = jwtTokenProvider.generateToken(admin.getId().toString()); @@ -72,7 +72,7 @@ public RefreshResult refresh(String refreshTokenValue) { @Transactional public void logout(UUID adminId) { refreshTokenRepository.findByAdminId(adminId) - .ifPresent(refreshTokenRepository::delete); + .ifPresent(refreshTokenRepository::delete); } private void validatePassword(Admin admin, String password) { @@ -85,10 +85,10 @@ 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) - ); + .ifPresentOrElse( + token -> updateExistingToken(token, tokenValue, expiresAt), + () -> createNewToken(admin.getId(), tokenValue, expiresAt) + ); } private void updateExistingToken(AdminRefreshToken token, String tokenValue, LocalDateTime expiresAt) { @@ -103,7 +103,7 @@ private void createNewToken(UUID adminId, String tokenValue, LocalDateTime expir private AdminRefreshToken findRefreshToken(String tokenValue) { return refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(AdminInvalidRefreshTokenException::new); + .orElseThrow(AdminInvalidRefreshTokenException::new); } private void validateRefreshToken(AdminRefreshToken refreshToken) { @@ -133,13 +133,15 @@ private RefreshResult renewRefreshToken(AdminRefreshToken refreshToken, Admin ad } public record AdminLoginResult( - Admin admin, - String accessToken, - String refreshToken - ) {} + Admin admin, + String accessToken, + String refreshToken + ) { + } public record RefreshResult( - String accessToken, - String refreshToken - ) {} + 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 index 0000b793..df2894aa 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java @@ -2,7 +2,7 @@ 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; +import com.souzip.shared.common.dto.pagination.PaginationResponse; public interface AdminCityQueryUseCase { 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 index 49ac7784..b5a385b2 100644 --- a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java +++ b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java @@ -1,6 +1,7 @@ package com.souzip.domain.admin.application.port; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.time.LocalDateTime; public interface CityQueryPort { @@ -13,5 +14,6 @@ record CityQueryResult( String nameEn, Integer priority, LocalDateTime updatedAt - ) {} + ) { + } } 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 index e59ca11e..baf66efa 100644 --- a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java +++ b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java @@ -3,7 +3,7 @@ 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 com.souzip.shared.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +18,7 @@ public class AdminCityQueryService implements AdminCityQueryUseCase { @Override public PaginationResponse getCities(CitySearchQuery query) { return cityQueryPort.getCities( - query.countryId(), query.keyword(), query.pageNo(), query.pageSize() + query.countryId(), query.keyword(), query.pageNo(), query.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 d71940ce..808d0030 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BaseErrorCode; +import com.souzip.shared.exception.BaseErrorCode; import org.springframework.http.HttpStatus; public enum AdminErrorCode implements BaseErrorCode { 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 1645bfad..632db616 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminException extends BusinessException { public AdminException(AdminErrorCode errorCode) { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java index 50414923..f65ed114 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminExpiredRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java index caca6044..2c6de256 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminInvalidRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java index 1b753143..ef35020d 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminLoginFailedException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java index 6a6a54cd..da896ed5 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminNotFoundException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java index b24b2167..57218b9a 100644 --- a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java +++ b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class InvalidUsernameException extends BusinessException { 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 index 4bd49b64..94006b43 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java @@ -2,8 +2,10 @@ 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 com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.util.List; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -17,26 +19,26 @@ public class CityQueryAdapter implements CityQueryPort { @Override public PaginationResponse getCities( - Long countryId, - String keyword, - int pageNo, - int pageSize + Long countryId, + String keyword, + int pageNo, + int pageSize ) { Page page = cityAdminPort.getCities( - countryId, - keyword, - PageRequest.of(pageNo - 1, pageSize) + 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(); + .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/security/jwt/AdminJwtAuthenticationFilter.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java index f99ddbd2..bf59e5c8 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java @@ -1,8 +1,8 @@ package com.souzip.domain.admin.infrastructure.security.jwt; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; 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; import jakarta.servlet.http.HttpServletRequest; @@ -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 @@ -101,8 +101,8 @@ private boolean isTokenInvalid(String token) { } private Admin getAdminFromToken(String token) { - String adminId = jwtTokenProvider.getUserIdFromToken(token); - return adminRepository.findById(UUID.fromString(adminId)).orElse(null); + Long adminId = jwtTokenProvider.getUserIdFromToken(token); + return adminRepository.findById(UUID.fromString(String.valueOf(adminId))).orElse(null); } private boolean isAdminAbsent(Admin admin) { @@ -111,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/domain/admin/presentation/AdminAuthController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java index 8e3ca7d0..e79925b8 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java @@ -1,23 +1,24 @@ package com.souzip.domain.admin.presentation; +import com.souzip.auth.adapter.security.annotation.RequireAuth; 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.infrastructure.security.annotation.CurrentAdminId; 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 com.souzip.shared.common.dto.SuccessResponse; 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; +import java.util.UUID; + @RequiredArgsConstructor @RequestMapping("/api/admin/auth") @RestController diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java index 3a5b1359..7bdd64cf 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java @@ -21,12 +21,14 @@ 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 com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.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; @@ -51,12 +53,12 @@ public class AdminManagementController { @SuperAdminOnly @PostMapping("/invite") public SuccessResponse inviteAdmin( - @Valid @RequestBody InviteAdminRequest request + @Valid @RequestBody InviteAdminRequest request ) { Admin admin = adminManagementUseCase.inviteAdmin(new InviteAdminCommand( - request.username(), - request.password(), - request.role() + request.username(), + request.password(), + request.role() )); return SuccessResponse.of(InviteAdminResponse.from(admin), "관리자 초대가 완료되었습니다."); } @@ -64,21 +66,21 @@ public SuccessResponse inviteAdmin( @SuperAdminOnly @GetMapping("/list") public SuccessResponse> getAdmins( - @ModelAttribute PaginationRequest paginationRequest + @ModelAttribute PaginationRequest paginationRequest ) { return SuccessResponse.of(AdminResponse.ofPageResult( - adminManagementUseCase.getAdmins( - paginationRequest.getPageNo(), - paginationRequest.getPageSize() - ) + adminManagementUseCase.getAdmins( + paginationRequest.getPageNo(), + paginationRequest.getPageSize() + ) )); } @SuperAdminOnly @DeleteMapping("/{adminId}") public SuccessResponse deleteAdmin( - @PathVariable UUID adminId, - @CurrentAdminId UUID requesterId + @PathVariable UUID adminId, + @CurrentAdminId UUID requesterId ) { adminManagementUseCase.deleteAdmin(adminId, requesterId); return SuccessResponse.of(null, "관리자가 삭제되었습니다."); @@ -95,15 +97,15 @@ public SuccessResponse> getCountries( @ViewerAccess @GetMapping("/cities") public SuccessResponse> getCities( - @RequestParam Long countryId, - @RequestParam(required = false) String keyword, - @ModelAttribute PaginationRequest paginationRequest + @RequestParam Long countryId, + @RequestParam(required = false) String keyword, + @ModelAttribute PaginationRequest paginationRequest ) { CitySearchQuery query = CitySearchQuery.of( - countryId, - keyword, - paginationRequest.getPageNo(), - paginationRequest.getPageSize() + countryId, + keyword, + paginationRequest.getPageNo(), + paginationRequest.getPageSize() ); return SuccessResponse.of(adminCityQueryUseCase.getCities(query)); } @@ -111,14 +113,14 @@ public SuccessResponse> getCities( @AdminAccess @PostMapping("/cities") public SuccessResponse createCity( - @Valid @RequestBody CreateCityRequest request + @Valid @RequestBody CreateCityRequest request ) { adminManagementUseCase.createCity(new AdminCreateCityCommand( - request.nameEn(), - request.nameKr(), - request.latitude(), - request.longitude(), - request.countryId() + request.nameEn(), + request.nameKr(), + request.latitude(), + request.longitude(), + request.countryId() )); return SuccessResponse.of(null, "도시가 추가되었습니다."); } @@ -126,7 +128,7 @@ public SuccessResponse createCity( @AdminAccess @DeleteMapping("/cities/{cityId}") public SuccessResponse deleteCity( - @PathVariable Long cityId + @PathVariable Long cityId ) { adminManagementUseCase.deleteCity(new AdminDeleteCityCommand(cityId)); return SuccessResponse.of(null, "도시가 삭제되었습니다."); @@ -135,8 +137,8 @@ public SuccessResponse deleteCity( @AdminAccess @PatchMapping("/cities/{cityId}/priority") public SuccessResponse updateCityPriority( - @PathVariable Long cityId, - @RequestParam(required = false) Integer priority + @PathVariable Long cityId, + @RequestParam(required = false) Integer priority ) { adminManagementUseCase.updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, priority)); return SuccessResponse.of(null, "우선순위가 업데이트되었습니다."); 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 index 37db88ea..33cd56e3 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java @@ -3,39 +3,40 @@ 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 com.souzip.shared.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 + 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() + 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(); + .map(AdminResponse::from) + .toList(); return PaginationResponse.of( - content, - result.pageNo(), - result.pageSize(), - result.total(), - result.totalPages() + content, + result.pageNo(), + result.pageSize(), + result.total(), + result.totalPages() ); } } diff --git a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java index 76f4bf3d..84aa6bab 100644 --- a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java +++ b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java @@ -1,7 +1,7 @@ package com.souzip.domain.audit.entity; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Provider; import jakarta.persistence.*; import lombok.*; @@ -11,13 +11,13 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table( - indexes = { - @Index(name = "idx_audit_user_id", columnList = "user_id"), - @Index(name = "idx_audit_action", columnList = "action, created_at"), - @Index(name = "idx_audit_category", columnList = "category, created_at"), - @Index(name = "idx_audit_target", columnList = "target_type, target_id"), - @Index(name = "idx_audit_created_at", columnList = "created_at DESC") - } + indexes = { + @Index(name = "idx_audit_user_id", columnList = "user_id"), + @Index(name = "idx_audit_action", columnList = "action, created_at"), + @Index(name = "idx_audit_category", columnList = "category, created_at"), + @Index(name = "idx_audit_target", columnList = "target_type, target_id"), + @Index(name = "idx_audit_created_at", columnList = "created_at DESC") + } ) public class AuditLog extends BaseEntity { @@ -58,45 +58,45 @@ public class AuditLog extends BaseEntity { private String metadata; public static AuditLog success( - String userId, - AuditAction action, - String ipAddress, - String userAgent, - String appVersion, - String metadata + String userId, + AuditAction action, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .userId(userId) - .action(action) - .category(action.getCategory()) - .success(true) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .userId(userId) + .action(action) + .category(action.getCategory()) + .success(true) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public static AuditLog failure( - AuditAction action, - String failureReason, - String ipAddress, - String userAgent, - String appVersion, - String metadata + AuditAction action, + String failureReason, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .action(action) - .category(action.getCategory()) - .success(false) - .failureReason(failureReason) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .action(action) + .category(action.getCategory()) + .success(false) + .failureReason(failureReason) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public void setOAuthProvider(Provider provider) { diff --git a/src/main/java/com/souzip/domain/audit/service/AuditService.java b/src/main/java/com/souzip/domain/audit/service/AuditService.java index f0ad70b7..5d4a835a 100644 --- a/src/main/java/com/souzip/domain/audit/service/AuditService.java +++ b/src/main/java/com/souzip/domain/audit/service/AuditService.java @@ -3,7 +3,7 @@ import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.entity.AuditLog; import com.souzip.domain.audit.repository.AuditLogRepository; -import com.souzip.global.audit.dto.AuditContext; +import com.souzip.shared.audit.dto.AuditContext; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; @@ -46,7 +46,7 @@ public void logFailure(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { try { AuditLog auditLog = createFailureLog(action, failureReason, - ipAddress, userAgent, appVersion); + ipAddress, userAgent, appVersion); saveLog(auditLog); incrementFailureMetric(action); logFailureWarning(action, failureReason); @@ -58,12 +58,12 @@ public void logFailure(AuditAction action, String failureReason, private AuditLog createSuccessLog(AuditContext context) { AuditLog auditLog = AuditLog.success( - context.getUserId(), - context.getAction(), - context.getIpAddress(), - context.getUserAgent(), - context.getAppVersion(), - context.getMetadata() + context.getUserId(), + context.getAction(), + context.getIpAddress(), + context.getUserAgent(), + context.getAppVersion(), + context.getMetadata() ); enrichLogWithOptionalFields(auditLog, context); @@ -84,12 +84,12 @@ private void enrichLogWithOptionalFields(AuditLog auditLog, AuditContext context private AuditLog createFailureLog(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { return AuditLog.failure( - action, - failureReason, - ipAddress, - userAgent, - appVersion, - null + action, + failureReason, + ipAddress, + userAgent, + appVersion, + null ); } @@ -111,24 +111,24 @@ private void incrementMetric(AuditAction action, boolean success) { private Counter buildCounter(AuditAction action, boolean success) { return meterRegistry.counter( - METRIC_NAME, - TAG_ACTION, action.name(), - TAG_CATEGORY, action.getCategory().name(), - TAG_SUCCESS, String.valueOf(success) + METRIC_NAME, + TAG_ACTION, action.name(), + TAG_CATEGORY, action.getCategory().name(), + TAG_SUCCESS, String.valueOf(success) ); } private void logSuccessInfo(AuditContext context) { log.info("[Audit success] - userId: {}, action: {}, category: {}", - context.getUserId(), - context.getAction(), - context.getAction().getCategory()); + context.getUserId(), + context.getAction(), + context.getAction().getCategory()); } private void logFailureWarning(AuditAction action, String failureReason) { log.warn("[Audit failure] - action: {}, reason: {}", - action, - failureReason); + action, + failureReason); } private void logSaveError(Exception e) { diff --git a/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java b/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java deleted file mode 100644 index d831bd87..00000000 --- a/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.domain.auth.dto.AppleUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Base64; - -@Slf4j -@RequiredArgsConstructor -@Component -public class AppleApiClient implements OAuthClient { - - private final ObjectMapper objectMapper; - - @Override - public OAuthUserInfo getUserInfo(String identityToken) { - try { - return parseIdentityToken(identityToken); - } catch (Exception e) { - throw new BusinessException(ErrorCode.APPLE_API_ERROR); - } - } - - private AppleUserInfo parseIdentityToken(String identityToken) { - String[] parts = splitToken(identityToken); - String payload = decodePayload(parts[1]); - return parseJson(payload); - } - - private String[] splitToken(String identityToken) { - String[] parts = identityToken.split("\\."); - if (parts.length < 2) { - throw new BusinessException(ErrorCode.INVALID_JWT_STRUCTURE); - } - return parts; - } - - private String decodePayload(String encodedPayload) { - try { - return new String(Base64.getUrlDecoder().decode(encodedPayload)); - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorCode.TOKEN_DECODE_FAILED); - } - } - - private AppleUserInfo parseJson(String payload) { - try { - return objectMapper.readValue(payload, AppleUserInfo.class); - } catch (Exception e) { - throw new BusinessException(ErrorCode.TOKEN_PARSE_FAILED); - } - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java b/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java deleted file mode 100644 index 7b803b43..00000000 --- a/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.GoogleUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.oauth.OAuthProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@Component -public class GoogleApiClient implements OAuthClient { - - private final RestTemplate restTemplate; - private final OAuthProperties oAuthProperties; - - @Override - public OAuthUserInfo getUserInfo(String accessToken) { - try { - return callGoogleUserInfoApi(accessToken); - } catch (Exception e) { - log.error("구글 사용자 정보 조회 중 오류 발생", e); - throw new BusinessException(ErrorCode.GOOGLE_API_ERROR); - } - } - - private GoogleUserInfo callGoogleUserInfoApi(String accessToken) { - String url = oAuthProperties.getGoogle().getUserInfoUrl(); - HttpHeaders headers = createAuthHeaders(accessToken); - HttpEntity request = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - GoogleUserInfo.class - ); - - return response.getBody(); - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java b/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java deleted file mode 100644 index e9a57c2f..00000000 --- a/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.KakaoUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.oauth.OAuthProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@Component -public class KakaoApiClient implements OAuthClient { - - private final RestTemplate restTemplate; - private final OAuthProperties oAuthProperties; - - @Override - public OAuthUserInfo getUserInfo(String accessToken) { - try { - return callKakaoUserInfoApi(accessToken); - } catch (Exception e) { - log.error("카카오 사용자 정보 조회 중 오류 발생", e); - throw new BusinessException(ErrorCode.KAKAO_API_ERROR); - } - } - - private KakaoUserInfo callKakaoUserInfoApi(String accessToken) { - String url = oAuthProperties.getKakao().getUserInfoUrl(); - HttpHeaders headers = createAuthHeaders(accessToken); - HttpEntity request = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - KakaoUserInfo.class - ); - - return response.getBody(); - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClient.java b/src/main/java/com/souzip/domain/auth/client/OAuthClient.java deleted file mode 100644 index 0322a6e6..00000000 --- a/src/main/java/com/souzip/domain/auth/client/OAuthClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.OAuthUserInfo; - -public interface OAuthClient { - OAuthUserInfo getUserInfo(String accessToken); -} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java b/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java deleted file mode 100644 index 32acde69..00000000 --- a/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.user.entity.Provider; -import org.springframework.stereotype.Component; - -import java.util.EnumMap; -import java.util.Map; - -@Component -public class OAuthClientFactory { - - private final Map clients; - - public OAuthClientFactory( - KakaoApiClient kakaoApiClient, - GoogleApiClient googleApiClient, - AppleApiClient appleApiClient - ) { - this.clients = new EnumMap<>(Provider.class); - clients.put(Provider.KAKAO, kakaoApiClient); - clients.put(Provider.GOOGLE, googleApiClient); - clients.put(Provider.APPLE, appleApiClient); - } - public OAuthClient getClient(Provider provider) { - return clients.get(provider); - } -} diff --git a/src/main/java/com/souzip/domain/auth/controller/AuthController.java b/src/main/java/com/souzip/domain/auth/controller/AuthController.java deleted file mode 100644 index 3010fa9f..00000000 --- a/src/main/java/com/souzip/domain/auth/controller/AuthController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.domain.auth.controller; - -import com.souzip.domain.auth.dto.LoginRequest; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.RefreshRequest; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.service.AuthService; -import com.souzip.domain.user.entity.Provider; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; -import lombok.RequiredArgsConstructor; -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.RestController; - -@RequiredArgsConstructor -@RequestMapping("/api/auth") -@RestController -public class AuthController { - - private final AuthService authService; - - @PostMapping("/login/{provider}") - public SuccessResponse login(@PathVariable String provider, @RequestBody LoginRequest request) { - LoginResponse response = authService.login(Provider.from(provider), request.accessToken()); - return SuccessResponse.of(response); - } - - @PostMapping("/refresh") - public SuccessResponse refresh(@RequestBody RefreshRequest request) { - RefreshResponse response = authService.refresh(request.refreshToken()); - return SuccessResponse.of(response); - } - - @PostMapping("/logout") - @RequireAuth - public SuccessResponse logout(@CurrentUserId Long currentUserId) { - authService.logout(currentUserId); - return SuccessResponse.of(null, "로그아웃 되었습니다."); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java deleted file mode 100644 index 4315a7e3..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class AppleUserInfo implements OAuthUserInfo { - - private String sub; - private String email; - - @JsonProperty("email_verified") - private Boolean emailVerified; - - @JsonProperty("transfer_sub") - private String transferSub; - - @Override - public String getProviderId() { - return sub; - } - - @Override - public String getName() { - if (isValidEmail(email)) { - return email.split("@")[0]; - } - return "apple.user"; - } - - @Override - public String getEmail() { - return email; - } - - public String getTransferSub() { - return transferSub; - } - - private boolean isValidEmail(String email) { - return email != null && email.contains("@"); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java deleted file mode 100644 index 979277ca..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class GoogleUserInfo implements OAuthUserInfo { - - @JsonProperty("id") - private String id; - - @JsonProperty("email") - private String email; - - @JsonProperty("name") - private String name; - - @Override - public String getProviderId() { - return id; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getEmail() { - return email; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java deleted file mode 100644 index ecde2a47..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class KakaoUserInfo implements OAuthUserInfo { - - private Long id; - - @JsonProperty("kakao_account") - private KakaoAccount kakaoAccount; - - @Getter - @NoArgsConstructor - public static class KakaoAccount { - - private Profile profile; - private String email; - - @Getter - @NoArgsConstructor - public static class Profile { - private String nickname; - } - } - - @Override - public String getProviderId() { - return String.valueOf(id); - } - - @Override - public String getName() { - return kakaoAccount.profile.nickname; - } - - @Override - public String getEmail() { - return kakaoAccount.email; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java b/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java deleted file mode 100644 index 2dff0d95..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record LoginRequest( - String accessToken -) {} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java b/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java deleted file mode 100644 index 294821b0..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.auth.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class LoginResponse { - private String accessToken; - private String refreshToken; - private LoginUserInfo user; - private boolean needsOnboarding; - - public static LoginResponse of( - String accessToken, - String refreshToken, - LoginUserInfo user, - boolean needsOnboarding - ) { - return LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .user(user) - .needsOnboarding(needsOnboarding) - .build(); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java deleted file mode 100644 index 8067d298..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.souzip.domain.user.entity.User; - -public record LoginUserInfo( - String userId, - String nickname -) { - public static LoginUserInfo from(User user) { - return new LoginUserInfo( - user.getUserId(), - user.getNickname() - ); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java deleted file mode 100644 index ee0818d2..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.domain.auth.dto; - -public interface OAuthUserInfo { - String getProviderId(); - String getName(); - String getEmail(); - default String getProfileImageUrl() { - return null; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java b/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java deleted file mode 100644 index b410f1cb..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record RefreshRequest( - String refreshToken -) { -} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java b/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java deleted file mode 100644 index 8588c3e7..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record RefreshResponse( - String accessToken, - String refreshToken -) { - public static RefreshResponse of(String accessToken, String refreshToken) { - return new RefreshResponse(accessToken, refreshToken); - } -} diff --git a/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java b/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java deleted file mode 100644 index 7808a7b8..00000000 --- a/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.souzip.domain.auth.entity; - -import com.souzip.domain.user.entity.User; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Getter -@Builder(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Table(name = "refresh_token", - indexes = { - @Index(name = "idx_refresh_token_user_id", columnList = "user_id"), - @Index(name = "idx_refresh_token_token", columnList = "token") - } -) -@Entity -public class RefreshToken extends BaseEntity { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) - private User user; - - @Column(nullable = false, unique = true) - private String token; - - @Column(nullable = false) - private LocalDateTime expiresAt; - - public static RefreshToken of(User user, String token, LocalDateTime expiresAt) { - return RefreshToken.builder() - .user(user) - .token(token) - .expiresAt(expiresAt) - .build(); - } - - public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); - } - - public void updateToken(String newToken, LocalDateTime newExpiresAt) { - this.token = newToken; - this.expiresAt = newExpiresAt; - } -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index afd50707..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.domain.auth.repository; - -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.user.entity.User; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RefreshTokenRepository extends JpaRepository, RefreshTokenRepositoryCustom { - - Optional findByUser(User user); - - Optional findByToken(String token); -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java deleted file mode 100644 index d3d0eb70..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.domain.auth.repository; - -import java.time.LocalDateTime; - -public interface RefreshTokenRepositoryCustom { - - int deleteAllByExpiresAtBefore(LocalDateTime now); -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java deleted file mode 100644 index 27153247..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.souzip.domain.auth.repository; - -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.time.LocalDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import static com.souzip.domain.auth.entity.QRefreshToken.refreshToken; - -@RequiredArgsConstructor -@Repository -public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom { - - private final JPAQueryFactory queryFactory; - - @Override - public int deleteAllByExpiresAtBefore(LocalDateTime now) { - long deletedCount = queryFactory - .delete(refreshToken) - .where(refreshToken.expiresAt.lt(now)) - .execute(); - - return (int) deletedCount; - } -} diff --git a/src/main/java/com/souzip/domain/auth/service/AuthService.java b/src/main/java/com/souzip/domain/auth/service/AuthService.java deleted file mode 100644 index 639b09ce..00000000 --- a/src/main/java/com/souzip/domain/auth/service/AuthService.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.souzip.domain.auth.service; - -import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.auth.dto.AppleUserInfo; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.domain.auth.client.OAuthClient; -import com.souzip.domain.auth.client.OAuthClientFactory; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.security.jwt.JwtTokenProvider; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Slf4j -@RequiredArgsConstructor -@Service -public class AuthService { - - private static final int REFRESH_TOKEN_VALIDITY_DAYS = 30; - private static final int REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS = 10; - - private final OAuthClientFactory oauthClientFactory; - private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final JwtTokenProvider jwtTokenProvider; - - @Audit(action = AuditAction.LOGIN) - @Transactional - public LoginResponse login(Provider provider, String oauthAccessToken) { - OAuthClient oauthClient = oauthClientFactory.getClient(provider); - OAuthUserInfo oauthUserInfo = oauthClient.getUserInfo(oauthAccessToken); - - User user = findOrCreateUser(provider, oauthUserInfo); - - user.updateLastLoginAt(); - - String accessToken = jwtTokenProvider.generateToken(user.getUserId()); - String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); - - saveRefreshToken(user, refreshToken); - - LoginUserInfo userInfo = LoginUserInfo.from(user); - boolean needsOnboarding = user.needsOnboarding(); - - return LoginResponse.of(accessToken, refreshToken, userInfo, needsOnboarding); - } - - @Transactional - public RefreshResponse refresh(String refreshTokenValue) { - RefreshToken refreshToken = findRefreshToken(refreshTokenValue); - validateRefreshToken(refreshToken); - - User user = refreshToken.getUser(); - String newAccessToken = jwtTokenProvider.generateToken(user.getUserId()); - - if (isRefreshTokenExpiringSoon(refreshToken)) { - return renewRefreshToken(refreshToken, user, newAccessToken); - } - - return RefreshResponse.of(newAccessToken, refreshToken.getToken()); - } - - @Audit(action = AuditAction.LOGOUT) - @Transactional - public void logout(Long currentUserId) { - User user = userRepository.findById(currentUserId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - refreshTokenRepository.findByUser(user) - .ifPresent(refreshTokenRepository::delete); - } - - private User findOrCreateUser(Provider provider, OAuthUserInfo oauthUserInfo) { - if (isAppleProvider(provider)) { - return findOrCreateAppleUser(oauthUserInfo); - } - - return findOrCreateGeneralUser(provider, oauthUserInfo); - } - - private User findOrCreateGeneralUser(Provider provider, OAuthUserInfo oauthUserInfo) { - Optional existingUser = userRepository - .findByProviderAndProviderId(provider, oauthUserInfo.getProviderId()); - - return existingUser.map(user -> restoreIfDeleted(user, oauthUserInfo)) - .orElseGet(() -> createUser(provider, oauthUserInfo)); - } - - private User findOrCreateAppleUser(OAuthUserInfo oauthUserInfo) { - String newProviderId = oauthUserInfo.getProviderId(); - String transferSub = extractTransferSub(oauthUserInfo); - - Optional existingUser = findByProviderId(newProviderId); - if (existingUser.isPresent()) { - return handleExistingUser(existingUser.get(), oauthUserInfo, newProviderId); - } - - if (hasTransferSub(transferSub)) { - Optional migratedUser = attemptMigration(transferSub, newProviderId, oauthUserInfo); - - return migratedUser.orElseGet(() -> createNewAppleUser(newProviderId, oauthUserInfo)); - } - - return createNewAppleUser(newProviderId, oauthUserInfo); - } - - private Optional findByProviderId(String providerId) { - return userRepository.findByProviderAndProviderId(Provider.APPLE, providerId); - } - - private String extractTransferSub(OAuthUserInfo oauthUserInfo) { - if (oauthUserInfo instanceof AppleUserInfo appleUserInfo) { - return appleUserInfo.getTransferSub(); - } - return null; - } - - private boolean hasTransferSub(String transferSub) { - return transferSub != null; - } - - private User handleExistingUser(User user, OAuthUserInfo oauthUserInfo, String providerId) { - log.info("Apple 사용자 로그인 - providerId로 찾음: {}", providerId); - return restoreIfDeleted(user, oauthUserInfo); - } - - private Optional attemptMigration(String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { - Optional existingUser = userRepository.findByTransferIdentifier(transferSub); - - return existingUser.map(user -> migrateUser(user, transferSub, newProviderId, oauthUserInfo)); - } - - private User migrateUser(User user, String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { - log.info("Apple 앱 이전 감지 - 기존 providerId: {}, transfer_sub: {}, 새 providerId: {}", - user.getProviderId(), transferSub, newProviderId); - - user.updateProviderId(newProviderId); - userRepository.save(user); - - User restoredUser = restoreIfDeleted(user, oauthUserInfo); - - log.info("Apple 사용자 마이그레이션 완료: userId={}", user.getId()); - return restoredUser; - } - - private User createNewAppleUser(String providerId, OAuthUserInfo oauthUserInfo) { - log.info("새로운 Apple 사용자 생성: providerId={}", providerId); - return createUser(Provider.APPLE, oauthUserInfo); - } - - private User restoreIfDeleted(User user, OAuthUserInfo oauthUserInfo) { - if (user.isDeleted()) { - String name = oauthUserInfo.getName(); - user.restore(name); - } - return user; - } - - private User createUser(Provider provider, OAuthUserInfo oauthUserInfo) { - User user = User.of(provider, oauthUserInfo); - return userRepository.save(user); - } - - private boolean isAppleProvider(Provider provider) { - return provider == Provider.APPLE; - } - - private RefreshResponse renewRefreshToken(RefreshToken refreshToken, User user, String newAccessToken) { - String newRefreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); - updateRefreshToken(refreshToken, newRefreshToken); - - log.info("Refresh Token 갱신: userId={}, remainingDays={}", - user.getUserId(), getRemainingDays(refreshToken)); - - return RefreshResponse.of(newAccessToken, newRefreshToken); - } - - private void saveRefreshToken(User user, String tokenValue) { - LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - - refreshTokenRepository.findByUser(user) - .ifPresentOrElse( - token -> updateExistingToken(token, tokenValue, expiresAt), - () -> createNewToken(user, tokenValue, expiresAt) - ); - } - - private void updateExistingToken(RefreshToken token, String tokenValue, LocalDateTime expiresAt) { - token.updateToken(tokenValue, expiresAt); - } - - private void createNewToken(User user, String tokenValue, LocalDateTime expiresAt) { - RefreshToken newToken = RefreshToken.of(user, tokenValue, expiresAt); - refreshTokenRepository.save(newToken); - } - - private RefreshToken findRefreshToken(String tokenValue) { - return refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); - } - - private void validateRefreshToken(RefreshToken refreshToken) { - if (refreshToken.isExpired()) { - deleteExpiredToken(refreshToken); - throw new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN); - } - } - - private void deleteExpiredToken(RefreshToken refreshToken) { - refreshTokenRepository.delete(refreshToken); - log.info("만료된 Refresh Token 삭제: token={}", refreshToken.getId()); - } - - private boolean isRefreshTokenExpiringSoon(RefreshToken refreshToken) { - LocalDateTime threshold = LocalDateTime.now().plusDays(REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS); - return refreshToken.getExpiresAt().isBefore(threshold); - } - - private void updateRefreshToken(RefreshToken refreshToken, String newTokenValue) { - LocalDateTime newExpiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - refreshToken.updateToken(newTokenValue, newExpiresAt); - } - - private long getRemainingDays(RefreshToken refreshToken) { - return java.time.Duration.between(LocalDateTime.now(), refreshToken.getExpiresAt()).toDays(); - } -} diff --git a/src/main/java/com/souzip/domain/category/controller/CategoryController.java b/src/main/java/com/souzip/domain/category/controller/CategoryController.java index 7218f073..65408844 100644 --- a/src/main/java/com/souzip/domain/category/controller/CategoryController.java +++ b/src/main/java/com/souzip/domain/category/controller/CategoryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.category.dto.CategoriesResponse; import com.souzip.domain.category.service.CategoryService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; 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 08eb790e..45b184b7 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 @@ -9,9 +9,11 @@ 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 com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.math.BigDecimal; + import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; 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 6d116753..83c936c0 100644 --- a/src/main/java/com/souzip/domain/city/entity/City.java +++ b/src/main/java/com/souzip/domain/city/entity/City.java @@ -1,9 +1,9 @@ package com.souzip.domain.city.entity; import com.souzip.domain.country.entity.Country; -import com.souzip.domain.shared.BaseEntity; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; @@ -15,9 +15,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table(name = "city", indexes = { - @Index(name = "idx_city_country", columnList = "country_id"), - @Index(name = "idx_city_name", columnList = "name_en, name_kr"), - @Index(name = "idx_city_priority", columnList = "country_id, priority") + @Index(name = "idx_city_country", columnList = "country_id"), + @Index(name = "idx_city_name", columnList = "name_en, name_kr"), + @Index(name = "idx_city_priority", columnList = "country_id, priority") }) public class City extends BaseEntity { @@ -41,20 +41,20 @@ public class City extends BaseEntity { private Country country; public static City create( - String nameEn, - String nameKr, - BigDecimal latitude, - BigDecimal longitude, - Country country + String nameEn, + String nameKr, + BigDecimal latitude, + BigDecimal longitude, + Country country ) { return City.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .latitude(latitude) - .longitude(longitude) - .country(country) - .priority(null) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .latitude(latitude) + .longitude(longitude) + .country(country) + .priority(null) + .build(); } public void updatePriority(Integer priority) { diff --git a/src/main/java/com/souzip/domain/country/controller/CountryController.java b/src/main/java/com/souzip/domain/country/controller/CountryController.java index b93a154c..6d804ddc 100644 --- a/src/main/java/com/souzip/domain/country/controller/CountryController.java +++ b/src/main/java/com/souzip/domain/country/controller/CountryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.country.dto.CountryListResponse; import com.souzip.domain.country.service.CountryService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/souzip/domain/country/entity/Country.java b/src/main/java/com/souzip/domain/country/entity/Country.java index 4412709e..d6fb33a0 100644 --- a/src/main/java/com/souzip/domain/country/entity/Country.java +++ b/src/main/java/com/souzip/domain/country/entity/Country.java @@ -1,21 +1,11 @@ package com.souzip.domain.country.entity; import com.souzip.domain.currency.entity.Currency; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.ConstraintMode; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.ForeignKey; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.souzip.shared.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + import java.math.BigDecimal; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -54,26 +44,26 @@ public class Country extends BaseEntity { private Currency currency; public static Country of( - String nameEn, - String nameKr, - String code, - String capital, - Region region, - String imageUrl, - BigDecimal latitude, - BigDecimal longitude, - Currency currency + String nameEn, + String nameKr, + String code, + String capital, + Region region, + String imageUrl, + BigDecimal latitude, + BigDecimal longitude, + Currency currency ) { return Country.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .code(code) - .capital(capital) - .region(region) - .imageUrl(imageUrl) - .latitude(latitude) - .longitude(longitude) - .currency(currency) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .code(code) + .capital(capital) + .region(region) + .imageUrl(imageUrl) + .latitude(latitude) + .longitude(longitude) + .currency(currency) + .build(); } } diff --git a/src/main/java/com/souzip/domain/country/service/CountryService.java b/src/main/java/com/souzip/domain/country/service/CountryService.java index 67efc415..75ccd41f 100644 --- a/src/main/java/com/souzip/domain/country/service/CountryService.java +++ b/src/main/java/com/souzip/domain/country/service/CountryService.java @@ -9,8 +9,8 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java index f025016d..51cafe0e 100644 --- a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java +++ b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java @@ -1,9 +1,9 @@ package com.souzip.domain.currency.controller; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.currency.service.CurrencyService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java index 060668ed..202740c3 100644 --- a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java +++ b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java @@ -3,8 +3,8 @@ import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.dto.CurrencyResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java index 892a91fc..7434a624 100644 --- a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java +++ b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java @@ -14,8 +14,8 @@ import com.souzip.domain.souvenir.dto.PriceData; import com.souzip.domain.souvenir.dto.PriceResponse; import com.souzip.domain.souvenir.vo.PriceInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -42,9 +42,9 @@ public class ExchangeRateService { private final CurrencyRepository currencyRepository; public ExchangeCalculatedPrice calculatePrice( - String countryCode, - Integer localPrice, - Integer krwPrice + String countryCode, + Integer localPrice, + Integer krwPrice ) { CountryResponseDto country = countryService.getCountryByCode(countryCode); ExchangeRateResponseDto rate = getRate(country.currency().code()); @@ -61,9 +61,9 @@ public ExchangeCalculatedPrice calculatePrice( } public PriceData calculatePriceData( - Integer price, - String currency, - String countryCode + Integer price, + String currency, + String countryCode ) { if (hasInvalidPriceInput(price, currency)) { return createEmptyPriceData(); @@ -75,16 +75,16 @@ public PriceData calculatePriceData( String localCurrency = getLocalCurrency(countryCode); PriceInfo convertedPrice = calculateConvertedPrice( - originalPrice, - localCurrency, - exchangeAmount + originalPrice, + localCurrency, + exchangeAmount ); return new PriceData( - originalPrice, - exchangeAmount, - currencySymbol, - convertedPrice + originalPrice, + exchangeAmount, + currencySymbol, + convertedPrice ); } @@ -97,10 +97,10 @@ public PriceResponse createPriceResponse(PriceInfo originalPrice, PriceInfo conv String convertedSymbol = getCurrencySymbol(convertedPrice.getCurrency()); return PriceResponse.of( - originalPrice.getAmount(), - originalSymbol, - convertedPrice.getAmount(), - convertedSymbol + originalPrice.getAmount(), + originalSymbol, + convertedPrice.getAmount(), + convertedSymbol ); } @@ -114,37 +114,37 @@ private boolean hasKrwPrice(Integer krwPrice) { private ExchangeCalculatedPrice createEmptyPrice(CountryResponseDto country) { return new ExchangeCalculatedPrice( - ZERO_PRICE, - ZERO_PRICE, - country.currency().symbol() + ZERO_PRICE, + ZERO_PRICE, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromLocalPrice( - Integer localPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer localPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int krw = multiply(localPrice, rate.rate()); return new ExchangeCalculatedPrice( - localPrice, - krw, - country.currency().symbol() + localPrice, + krw, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromKrwPrice( - Integer krwPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer krwPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int local = divide(krwPrice, rate.rate()); return new ExchangeCalculatedPrice( - local, - krwPrice, - country.currency().symbol() + local, + krwPrice, + country.currency().symbol() ); } @@ -181,9 +181,9 @@ private PriceData createEmptyPriceData() { } private PriceInfo calculateConvertedPrice( - PriceInfo originalPrice, - String localCurrency, - Integer exchangeAmount + PriceInfo originalPrice, + String localCurrency, + Integer exchangeAmount ) { if (isKrwInput(originalPrice)) { return convertKrwToLocal(originalPrice.getAmount(), localCurrency); @@ -207,8 +207,8 @@ private boolean hasNoOriginalPrice(PriceInfo originalPrice) { private String getLocalCurrency(String countryCode) { return countryRepository.findByCodeWithCurrency(countryCode) - .map(country -> country.getCurrency().getCode()) - .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); + .map(country -> country.getCurrency().getCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); } private String getCurrencySymbol(String currencyCode) { @@ -217,8 +217,8 @@ private String getCurrencySymbol(String currencyCode) { } return currencyRepository.findByCode(currencyCode) - .map(Currency::getSymbol) - .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); + .map(Currency::getSymbol) + .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); } private boolean isNullCurrency(String currencyCode) { @@ -245,21 +245,21 @@ private Integer calculateForeignAmount(Integer krwAmount, String toCurrency) { private int multiply(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .multiply(rate) - .setScale(0, RoundingMode.HALF_UP) - .intValue(); + .multiply(rate) + .setScale(0, RoundingMode.HALF_UP) + .intValue(); } private int divide(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .divide(rate, 0, RoundingMode.HALF_UP) - .intValue(); + .divide(rate, 0, RoundingMode.HALF_UP) + .intValue(); } public ExchangeRateResponseDto getRate(String currencyCode) { ExchangeRate rate = exchangeRateRepository - .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) - .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); + .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) + .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); return ExchangeRateResponseDto.from(rate); } @@ -307,8 +307,8 @@ private void saveOrUpdate(ExchangeRate newRate) { private Optional findExistingRate(ExchangeRate newRate) { return exchangeRateRepository.findByCurrencyCodeAndBaseCode( - newRate.getCurrencyCode(), - newRate.getBaseCode() + newRate.getCurrencyCode(), + newRate.getBaseCode() ); } diff --git a/src/main/java/com/souzip/domain/file/File.java b/src/main/java/com/souzip/domain/file/File.java index f515ad1d..ff312cb7 100644 --- a/src/main/java/com/souzip/domain/file/File.java +++ b/src/main/java/com/souzip/domain/file/File.java @@ -1,6 +1,6 @@ package com.souzip.domain.file; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/location/Location.java b/src/main/java/com/souzip/domain/location/Location.java index 5ac59312..44ba5686 100644 --- a/src/main/java/com/souzip/domain/location/Location.java +++ b/src/main/java/com/souzip/domain/location/Location.java @@ -1,7 +1,7 @@ package com.souzip.domain.location; -import com.souzip.domain.shared.BaseEntity; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Coordinate; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java b/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java deleted file mode 100644 index b0e70fac..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.souzip.domain.migration.apple.controller; - -import com.souzip.domain.migration.apple.dto.AppleMigrationPrepareResponse; -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService; -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; -import com.souzip.global.common.dto.SuccessResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/migration/apple") -@RequiredArgsConstructor -public class AppleMigrationController { - - private final AppleMigrationPrepService migrationPrepService; - - @PostMapping("/prepare") - public SuccessResponse prepare() { - log.info("Apple 마이그레이션 준비 API 호출"); - - MigrationResult result = migrationPrepService.prepareAllAppleUsers(); - AppleMigrationPrepareResponse data = AppleMigrationPrepareResponse.from(result); - - return SuccessResponse.of(data, "마이그레이션 준비 완료"); - } -} diff --git a/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java b/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java deleted file mode 100644 index 7a42ab3c..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.souzip.domain.migration.apple.dto; - -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class AppleMigrationPrepareResponse { - private final int total; - private final int successCount; - private final int failCount; - private final int skipCount; - - public static AppleMigrationPrepareResponse from(MigrationResult result) { - return AppleMigrationPrepareResponse.builder() - .total(result.total()) - .successCount(result.success()) - .failCount(result.fail()) - .skipCount(result.skip()) - .build(); - } -} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java deleted file mode 100644 index b0663713..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.souzip.domain.migration.apple.service; - -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AppleMigrationPrepService { - - private final UserRepository userRepository; - private final AppleTransferService appleTransferService; - - private static final int RATE_LIMIT_DELAY_MILLIS = 100; - - @Transactional - public MigrationResult prepareAllAppleUsers() { - log.info("=== Apple 사용자 마이그레이션 준비 시작 ==="); - - List appleUsers = findAllAppleUsers(); - - if (hasNoAppleUsers(appleUsers)) { - throw new BusinessException(ErrorCode.NO_APPLE_USERS_FOUND); - } - - MigrationCounters counters = processAllUsers(appleUsers); - - return logAndReturnResult(appleUsers.size(), counters); - } - - private List findAllAppleUsers() { - List appleUsers = userRepository.findByProvider(Provider.APPLE); - log.info("총 {} 명의 Apple 사용자 발견", appleUsers.size()); - return appleUsers; - } - - private boolean hasNoAppleUsers(List appleUsers) { - return appleUsers.isEmpty(); - } - - private MigrationCounters processAllUsers(List appleUsers) { - MigrationCounters counters = new MigrationCounters(); - appleUsers.forEach(user -> processUser(user, counters)); - return counters; - } - - private void processUser(User user, MigrationCounters counters) { - try { - if (alreadyHasTransferIdentifier(user)) { - handleAlreadyMigrated(user, counters); - return; - } - - attemptMigration(user, counters); - waitForRateLimit(); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - handleMigrationFailure(user, counters, e); - } - } - - private boolean alreadyHasTransferIdentifier(User user) { - return user.getTransferIdentifier() != null; - } - - private void handleAlreadyMigrated(User user, MigrationCounters counters) { - log.info("이미 저장됨 - userId: {}, transferId: {}", - user.getId(), user.getTransferIdentifier()); - counters.incrementSkip(); - } - - private void attemptMigration(User user, MigrationCounters counters) { - String transferIdentifier = appleTransferService.getTransferIdentifier(user.getProviderId()); - - if (hasTransferIdentifier(transferIdentifier)) { - saveMigration(user, transferIdentifier, counters); - } else { - handleNoTransferIdentifier(user, counters); - } - } - - private boolean hasTransferIdentifier(String transferIdentifier) { - return transferIdentifier != null; - } - - private void saveMigration(User user, String transferIdentifier, MigrationCounters counters) { - user.updateTransferIdentifier(transferIdentifier); - userRepository.save(user); - counters.incrementSuccess(); - - log.info("저장 성공 - userId: {}, providerId: {}, transferId: {}", - user.getId(), user.getProviderId(), transferIdentifier); - } - - private void handleNoTransferIdentifier(User user, MigrationCounters counters) { - counters.incrementFail(); - log.warn("transfer_identifier 없음 - userId: {}, providerId: {}", - user.getId(), user.getProviderId()); - } - - private void waitForRateLimit() { - try { - Thread.sleep(RATE_LIMIT_DELAY_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Rate limit 대기 중 인터럽트 발생", e); - } - } - - private void handleMigrationFailure(User user, MigrationCounters counters, Exception e) { - counters.incrementFail(); - log.error("실패 - userId: {}, providerId: {}", user.getId(), user.getProviderId(), e); - } - - private MigrationResult logAndReturnResult(int total, MigrationCounters counters) { - MigrationResult result = new MigrationResult( - total, - counters.getSuccess(), - counters.getFail(), - counters.getSkip() - ); - - log.info("=== 마이그레이션 준비 완료 ==="); - log.info("총: {}, 성공: {}, 실패: {}, 스킵: {}", - result.total(), result.success(), result.fail(), result.skip()); - - return result; - } - - private static class MigrationCounters { - private final AtomicInteger successCount = new AtomicInteger(0); - private final AtomicInteger failCount = new AtomicInteger(0); - private final AtomicInteger skipCount = new AtomicInteger(0); - - public void incrementSuccess() { - successCount.incrementAndGet(); - } - - public void incrementFail() { - failCount.incrementAndGet(); - } - - public void incrementSkip() { - skipCount.incrementAndGet(); - } - - public int getSuccess() { - return successCount.get(); - } - - public int getFail() { - return failCount.get(); - } - - public int getSkip() { - return skipCount.get(); - } - } - - public record MigrationResult( - int total, - int success, - int fail, - int skip - ) {} -} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java deleted file mode 100644 index 3ca8f75f..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.souzip.domain.migration.apple.service; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.souzip.global.config.AppleMigrationConfig; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import io.jsonwebtoken.Jwts; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Base64; -import java.util.Date; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AppleTransferService { - - private final AppleMigrationConfig appleConfig; - private final RestTemplate restTemplate = new RestTemplate(); - - private static final String APPLE_AUTH_URL = "https://appleid.apple.com/auth/token"; - private static final String APPLE_MIGRATION_URL = "https://appleid.apple.com/auth/usermigrationinfo"; - private static final long JWT_EXPIRATION_MILLIS = 3600000L; - - public String getTransferIdentifier(String userId) { - try { - TransferResponse response = callAppleMigrationApi(userId); - return extractTransferSub(response, userId); - } catch (Exception e) { - log.error("Apple transfer identifier 조회 실패 - userId: {}", userId, e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private TransferResponse callAppleMigrationApi(String userId) { - String clientSecret = generateClientSecret(); - String accessToken = getAccessToken(clientSecret); - - HttpHeaders headers = createHeaders(); - headers.setBearerAuth(accessToken); - - MultiValueMap params = createParams(userId, clientSecret); - HttpEntity> request = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.postForEntity( - APPLE_MIGRATION_URL, - request, - TransferResponse.class - ); - - return response.getBody(); - } - - private String getAccessToken(String clientSecret) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("grant_type", "client_credentials"); - params.add("scope", "user.migration"); - params.add("client_id", appleConfig.getClientId()); - params.add("client_secret", clientSecret); - - ResponseEntity response = restTemplate.postForEntity( - APPLE_AUTH_URL, - new HttpEntity<>(params, headers), - AccessTokenResponse.class - ); - - log.info("Apple access token 발급 성공"); - return response.getBody().getAccessToken(); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - return headers; - } - - private MultiValueMap createParams(String userId, String clientSecret) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("sub", userId); - params.add("target", appleConfig.getTeamId()); - params.add("client_id", appleConfig.getClientId()); - params.add("client_secret", clientSecret); - return params; - } - - private String extractTransferSub(TransferResponse response, String userId) { - if (hasValidTransferSub(response)) { - return logAndReturnTransferSub(response, userId); - } - - logNoTransferSub(userId); - return null; - } - - private boolean hasValidTransferSub(TransferResponse response) { - return response != null && response.getTransferSub() != null; - } - - private String logAndReturnTransferSub(TransferResponse response, String userId) { - String transferSub = response.getTransferSub(); - log.info("transfer_identifier 조회 성공 - userId: {}, transferId: {}", userId, transferSub); - return transferSub; - } - - private void logNoTransferSub(String userId) { - log.warn("transfer_identifier 없음 - userId: {}", userId); - } - - private String generateClientSecret() { - try { - PrivateKey privateKey = getPrivateKey(); - String jwt = buildJwt(privateKey); - logJwtGeneration(jwt); - return jwt; - } catch (Exception e) { - log.error("client_secret 생성 실패", e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private String buildJwt(PrivateKey privateKey) { - long nowMillis = System.currentTimeMillis(); - Date now = new Date(nowMillis); - Date expiryDate = new Date(nowMillis + JWT_EXPIRATION_MILLIS); - - return Jwts.builder() - .header() - .add("kid", appleConfig.getOldKeyId()) - .add("alg", "ES256") - .and() - .issuer(appleConfig.getOldTeamId()) - .issuedAt(now) - .expiration(expiryDate) - .audience() - .add("https://appleid.apple.com") - .and() - .subject(appleConfig.getClientId()) - .signWith(privateKey, Jwts.SIG.ES256) - .compact(); - } - - private void logJwtGeneration(String jwt) { - log.info("Generated JWT: {}", jwt); - log.info("Old Team ID: {}", appleConfig.getOldTeamId()); - log.info("Old Key ID: {}", appleConfig.getOldKeyId()); - log.info("Client ID: {}", appleConfig.getClientId()); - } - - private PrivateKey getPrivateKey() { - try { - String cleanedKey = cleanPrivateKey(appleConfig.getOldPrivateKey()); // 이전 팀 Private Key - byte[] keyBytes = Base64.getDecoder().decode(cleanedKey); - return generatePrivateKey(keyBytes); - } catch (Exception e) { - log.error("Private Key 파싱 실패", e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private String cleanPrivateKey(String privateKey) { - return privateKey - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\\n", "") - .replaceAll("\\s+", ""); - } - - private PrivateKey generatePrivateKey(byte[] keyBytes) throws Exception { - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - return keyFactory.generatePrivate(keySpec); - } - - @Getter - public static class AccessTokenResponse { - @JsonProperty("access_token") - private String accessToken; - } - - @Getter - public static class TransferResponse { - @JsonProperty("transfer_sub") - private String transferSub; - } -} diff --git a/src/main/java/com/souzip/domain/notice/Notice.java b/src/main/java/com/souzip/domain/notice/Notice.java index e0d2836e..76656d0f 100644 --- a/src/main/java/com/souzip/domain/notice/Notice.java +++ b/src/main/java/com/souzip/domain/notice/Notice.java @@ -1,11 +1,12 @@ package com.souzip.domain.notice; -import com.souzip.domain.shared.BaseEntity; -import java.util.UUID; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + import static java.util.Objects.requireNonNull; @Getter diff --git a/src/main/java/com/souzip/domain/notification/FcmToken.java b/src/main/java/com/souzip/domain/notification/FcmToken.java index fa70f5dd..3268edf5 100644 --- a/src/main/java/com/souzip/domain/notification/FcmToken.java +++ b/src/main/java/com/souzip/domain/notification/FcmToken.java @@ -1,6 +1,6 @@ package com.souzip.domain.notification; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java index cacd112e..c8d7e98d 100644 --- a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java +++ b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java @@ -1,11 +1,12 @@ package com.souzip.domain.notification; -import com.souzip.domain.shared.BaseEntity; -import java.util.UUID; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + import static java.util.Objects.requireNonNull; @Getter diff --git a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java index 773501d1..14c51ae8 100644 --- a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java +++ b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java @@ -1,20 +1,15 @@ package com.souzip.domain.notification.controller; import com.souzip.application.notification.FcmTokenCommandService; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.notification.FcmToken; import com.souzip.domain.notification.FcmTokenRegisterRequest; import com.souzip.domain.notification.dto.FcmTokenResponse; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -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; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RequestMapping("/api/users/me/fcm-tokens") diff --git a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java index 1efa3284..daf1d462 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java @@ -1,10 +1,10 @@ package com.souzip.domain.recommend.ai.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java index 6b553c6b..e200c858 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java +++ b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java @@ -10,10 +10,12 @@ public record RecommendedSouvenir( String name, String category, String countryCode, - String thumbnailUrl + String thumbnailUrl, + long wishlistCount, + boolean isWishlisted ) { - public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl) { - return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl); + public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl, long wishlistCount, boolean isWishlisted) { + return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl, wishlistCount, isWishlisted); } } } diff --git a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java index 85a1df0d..c933ae64 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java @@ -11,8 +11,13 @@ import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.repository.AiRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.global.clova.ClovaStudioClient; -import com.souzip.global.clova.PromptLoader; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.clova.ClovaStudioClient; +import com.souzip.shared.clova.PromptLoader; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; @@ -33,6 +38,8 @@ public class AiRecommendationService { private final ObjectMapper objectMapper; private final FileQueryService fileQueryService; private final FileStorage fileStorage; + private final WishlistRepository wishlistRepository; + private final UserRepository userRepository; public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { log.info("getCategoryRecommendationsForUser 시작, userId={}", userId); @@ -64,8 +71,10 @@ public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); + String userUuid = findUserUuid(userId); + return new AiRecommendationResponse( - mapToRecommendedSouvenirs(recommendedNamesByCategory) + mapToRecommendedSouvenirs(recommendedNamesByCategory, userUuid) ); } @@ -107,20 +116,29 @@ public AiRecommendationResponse getRecentSouvenirRecommendations(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); - List finalSouvenirs = - recommendedNamesByCategory.values().stream() - .flatMap(List::stream) - .map(aiRecommendationRepository::findByName) - .filter(Optional::isPresent) - .map(Optional::get) - .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( - s.getId(), - s.getName(), - s.getCategory().name(), - s.getCountryCode(), - getThumbnailUrl(s.getId()) - )) - .collect(Collectors.toList()); + List souvenirs = recommendedNamesByCategory.values().stream() + .flatMap(List::stream) + .map(aiRecommendationRepository::findByName) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + + String userUuid = findUserUuid(userId); + List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); + + List finalSouvenirs = souvenirs.stream() + .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( + s.getId(), + s.getName(), + s.getCategory().name(), + s.getCountryCode(), + getThumbnailUrl(s.getId()), + wishlistCountMap.getOrDefault(s.getId(), 0L), + wishlistedIds.contains(s.getId()) + )) + .collect(Collectors.toList()); log.info("최종 추천 souvenirs 개수: {}", finalSouvenirs.size()); return new AiRecommendationResponse(finalSouvenirs); @@ -169,7 +187,8 @@ private Map> parseClovaResponse(String clovaResponse) { try { return objectMapper.readValue( clovaResponse, - new TypeReference>>>() {} + new TypeReference>>>() { + } ).entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, @@ -183,7 +202,8 @@ private Map> parseClovaResponse(String clovaResponse) { } private List mapToRecommendedSouvenirs( - Map> recommendedNamesByCategory + Map> recommendedNamesByCategory, + String userUuid ) { List souvenirs = recommendedNamesByCategory.values().stream() .flatMap(List::stream) @@ -197,6 +217,8 @@ private List mapToRecommendedSouve .toList(); Map thumbnailMap = getThumbnails(souvenirIds); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); return souvenirs.stream() .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( @@ -206,11 +228,19 @@ private List mapToRecommendedSouve s.getCountryCode(), Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + wishlistedIds.contains(s.getId()) )) .collect(Collectors.toList()); } + private String findUserUuid(Long userId) { + return userRepository.findById(userId) + .map(User::getUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + private String getThumbnailUrl(Long souvenirId) { File file = fileQueryService.findFirst(EntityType.SOUVENIR, souvenirId); return fileStorage.generateUrl(file.getStorageKey()); diff --git a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java index ba261e52..ac52dadc 100644 --- a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java @@ -4,12 +4,9 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationDto; import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.service.GeneralRecommendationService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,13 +18,19 @@ public class GeneralRecommendationController { private final GeneralRecommendationService generalRecommendationService; @GetMapping("/discovery/general/country/{countryCode}") - public SuccessResponse> getCountryTop10(@PathVariable String countryCode) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode)); + public SuccessResponse> getCountryTop10( + @PathVariable String countryCode, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode, authorizationHeader)); } @GetMapping("/discovery/general/category/{categoryName}") - public SuccessResponse> getCategoryTop10(@PathVariable String categoryName) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName)); + public SuccessResponse> getCategoryTop10( + @PathVariable String categoryName, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName, authorizationHeader)); } @GetMapping("/discovery/general/stats") @@ -36,15 +39,14 @@ public SuccessResponse> getTopCountriesAllTi } @GetMapping("/countries/souvenirs") - public SuccessResponse> getTopCountriesWithTop10Souvenirs() { - return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs()); + public SuccessResponse> getTopCountriesWithTop10Souvenirs( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs(authorizationHeader)); } @GetMapping("/discovery/general/countries/top10") public SuccessResponse> getTopCountriesAllTimeTop10() { - return SuccessResponse.of( - generalRecommendationService.getTop10CountriesBySouvenirCount() - ); + return SuccessResponse.of(generalRecommendationService.getTop10CountriesBySouvenirCount()); } - } diff --git a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java index 1ad7b732..e6eb9490 100644 --- a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java +++ b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java @@ -8,15 +8,19 @@ public record GeneralRecommendationDto( String name, Category category, String countryCode, - String thumbnailUrl + String thumbnailUrl, + long wishlistCount, + Boolean isWishlisted ) { - public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl) { + public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl, long wishlistCount, Boolean isWishlisted) { return new GeneralRecommendationDto( souvenir.getId(), souvenir.getName(), souvenir.getCategory(), souvenir.getCountryCode(), - thumbnailUrl + thumbnailUrl, + wishlistCount, + isWishlisted ); } } diff --git a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java index ec8a91eb..cb3ef67b 100644 --- a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java +++ b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java @@ -7,8 +7,8 @@ import com.souzip.domain.souvenir.entity.QSouvenir; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.QUser; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -25,15 +25,15 @@ public List findTop10ByCategoryRecent(String categoryName) { QUser u = QUser.user; Category category = Category.from(categoryName) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.category.eq(category) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.category.eq(category) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override @@ -42,12 +42,12 @@ public List findTop10ByCountry(String countryCode) { QUser u = QUser.user; return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.countryCode.eq(countryCode) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.countryCode.eq(countryCode) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override diff --git a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java index e74ca2e8..4267815e 100644 --- a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java @@ -3,6 +3,7 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.recommend.general.dto.CountryRecommendationDto; @@ -10,50 +11,40 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.repository.GeneralRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.wishlist.repository.WishlistRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class GeneralRecommendationService { + private static final String BEARER_PREFIX = "Bearer "; + private static final int BEARER_PREFIX_LENGTH = 7; private final GeneralRecommendationRepositoryCustom generalRecommendationRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; + private final WishlistRepository wishlistRepository; + private final JwtTokenProvider jwtTokenProvider; - public List getTop10ByCategory(String categoryName) { - List souvenirs = generalRecommendationRepository - .findTop10ByCategoryRecent(categoryName); - - List souvenirIds = souvenirs.stream() - .map(Souvenir::getId) - .toList(); - - Map thumbnailMap = getThumbnails(souvenirIds); - - return souvenirs.stream() - .map(s -> new GeneralRecommendationDto( - s.getId(), - s.getName(), - s.getCategory(), - s.getCountryCode(), - Optional.ofNullable(thumbnailMap.get(s.getId())) - .map(FileResponse::url) - .orElse(null) - )) - .toList(); + public List getTop10ByCategory(String categoryName, @Nullable String authorizationHeader) { + List souvenirs = generalRecommendationRepository.findTop10ByCategoryRecent(categoryName); + return toDto(souvenirs, authorizationHeader); } public List getTop10CountriesBySouvenirCount() { return generalRecommendationRepository.findTop10CountriesBySouvenirCount(); } - public List getTopCountriesWithTop10Souvenirs() { + public List getTopCountriesWithTop10Souvenirs(@Nullable String authorizationHeader) { List topCountries = generalRecommendationRepository.findTop10CountriesBySouvenirCount(); @@ -74,16 +65,14 @@ public List getTopCountriesWithTop10Souvenirs() { .toList(); Map thumbnailMap = allSouvenirIds.isEmpty() - ? Map.of() - : getThumbnails(allSouvenirIds); + ? Map.of() + : getThumbnails(allSouvenirIds); - Map statsMap = topCountries.stream() - .collect(Collectors.toMap( - GeneralRecommendationStatsDto::countryCode, - s -> s, - (a, b) -> a, - LinkedHashMap::new - )); + String userId = extractUserId(authorizationHeader); + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(allSouvenirIds); List result = new ArrayList<>(); for (GeneralRecommendationStatsDto stats : topCountries) { @@ -95,7 +84,9 @@ public List getTopCountriesWithTop10Souvenirs() { s, Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + userId != null ? wishlistedIds.contains(s.getId()) : null )) .toList(); @@ -110,31 +101,81 @@ public List getTopCountriesWithTop10Souvenirs() { return result; } - public List getTop10ByCountry(String countryCode) { - List souvenirs = generalRecommendationRepository - .findTop10ByCountry(countryCode); + public List getTop10ByCountry(String countryCode, @Nullable String authorizationHeader) { + List souvenirs = generalRecommendationRepository.findTop10ByCountry(countryCode); + return toDto(souvenirs, authorizationHeader); + } - List souvenirIds = souvenirs.stream() - .map(Souvenir::getId) - .toList(); + public List getTop3CountriesBySouvenirCount() { + return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); + } - Map thumbnailMap = getThumbnails(souvenirIds); + private List toDto(List souvenirs, @Nullable String authorizationHeader) { + List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); + Map thumbnailMap = souvenirIds.isEmpty() ? Map.of() : getThumbnails(souvenirIds); + + String userId = extractUserId(authorizationHeader); + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); return souvenirs.stream() - .map(s -> new GeneralRecommendationDto( - s.getId(), - s.getName(), - s.getCategory(), - s.getCountryCode(), + .map(s -> GeneralRecommendationDto.of( + s, Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + userId != null ? wishlistedIds.contains(s.getId()) : null )) .toList(); } - public List getTop3CountriesBySouvenirCount() { - return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); + @Nullable + private String extractUserId(@Nullable String authorizationHeader) { + if (hasNoAuthorizationHeader(authorizationHeader)) { + return null; + } + + if (isNotBearerToken(authorizationHeader)) { + return null; + } + + String token = extractToken(authorizationHeader); + + if (isEmptyToken(token)) { + return null; + } + + return parseUserIdFromToken(token); + } + + private boolean hasNoAuthorizationHeader(String authorizationHeader) { + return authorizationHeader == null; + } + + private boolean isNotBearerToken(String authorizationHeader) { + return !authorizationHeader.startsWith(BEARER_PREFIX); + } + + private String extractToken(String authorizationHeader) { + return authorizationHeader.substring(BEARER_PREFIX_LENGTH).trim(); + } + + private boolean isEmptyToken(String token) { + return token.isEmpty(); + } + + @Nullable + private String parseUserIdFromToken(String token) { + try { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + return userId != null ? String.valueOf(userId) : null; + } catch (Exception e) { + log.debug("Failed to parse token", e); + return null; + } } private Map getThumbnails(List souvenirIds) { diff --git a/src/main/java/com/souzip/domain/search/controller/SearchController.java b/src/main/java/com/souzip/domain/search/controller/SearchController.java index a3728e39..bbe63a27 100644 --- a/src/main/java/com/souzip/domain/search/controller/SearchController.java +++ b/src/main/java/com/souzip/domain/search/controller/SearchController.java @@ -2,9 +2,9 @@ import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java index 856ee8ad..ced1bc4a 100644 --- a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java +++ b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java @@ -1,6 +1,6 @@ package com.souzip.domain.search.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; public class SearchListResponse extends PaginationResponse { diff --git a/src/main/java/com/souzip/domain/search/service/SearchService.java b/src/main/java/com/souzip/domain/search/service/SearchService.java index 0ad3a601..a30b0f0e 100644 --- a/src/main/java/com/souzip/domain/search/service/SearchService.java +++ b/src/main/java/com/souzip/domain/search/service/SearchService.java @@ -3,11 +3,13 @@ import com.souzip.domain.city.entity.City; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.repository.LocationSearchRepository; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java index ade8ffc1..a213ca24 100644 --- a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java +++ b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java @@ -1,15 +1,10 @@ package com.souzip.domain.souvenir.controller; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.service.SouvenirService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -25,20 +20,22 @@ public class SouvenirController { @GetMapping("/api/souvenirs/nearby") public SuccessResponse getNearbySouvenirs( - @RequestParam double latitude, - @RequestParam double longitude, - @RequestParam(required = false, defaultValue = "5000") double radiusMeter + @RequestParam double latitude, + @RequestParam double longitude, + @RequestParam(required = false, defaultValue = "5000") double radiusMeter, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader ) { - return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter)); + return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter, authorizationHeader)); } @GetMapping("/api/souvenirs/{id}") public SuccessResponse getSouvenir( - @PathVariable Long id, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader + @PathVariable Long id, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader ) { SouvenirDetailResponse response = souvenirService.getSouvenir(id, authorizationHeader); return SuccessResponse.of(response); + } @RequireAuth @@ -51,9 +48,9 @@ public SuccessResponse deleteSouvenir(@PathVariable Long id, @CurrentUserI @RequireAuth @PostMapping("/api/souvenirs") public SuccessResponse createSouvenir( - @Valid @RequestPart("souvenir") SouvenirCreateRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirCreateRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenir(request, userId, files); return SuccessResponse.of(response); @@ -62,9 +59,9 @@ public SuccessResponse createSouvenir( @RequireAuth @PutMapping("/api/souvenirs/{id}") public SuccessResponse updateSouvenir( - @Valid @PathVariable Long id, - @RequestPart("souvenir") SouvenirUpdateRequest request, - @CurrentUserId Long userId + @Valid @PathVariable Long id, + @RequestPart("souvenir") SouvenirUpdateRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenir(id, request, userId); return SuccessResponse.of(response); @@ -73,9 +70,9 @@ public SuccessResponse updateSouvenir( @RequireAuth @PostMapping("/api/v2/souvenirs") public SuccessResponse createSouvenirV2( - @Valid @RequestPart("souvenir") SouvenirRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenirV2(request, userId, files); return SuccessResponse.of(response); @@ -84,9 +81,9 @@ public SuccessResponse createSouvenirV2( @RequireAuth @PutMapping("/api/v2/souvenirs/{id}") public SuccessResponse updateSouvenirV2( - @PathVariable Long id, - @Valid @RequestPart("souvenir") SouvenirRequest request, - @CurrentUserId Long userId + @PathVariable Long id, + @Valid @RequestPart("souvenir") SouvenirRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenirV2(id, request, userId); return SuccessResponse.of(response); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java index ba115e49..d0d372d1 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java @@ -1,13 +1,13 @@ package com.souzip.domain.souvenir.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; +import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; import org.springframework.data.domain.Page; import java.util.List; public record MySouvenirListResponse( - List content, - PageInfo pagination + List content, + PageInfo pagination ) { public static MySouvenirListResponse from(Page page) { PageInfo pagination = PageInfo.of(page); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java index f737331e..abd40340 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java @@ -5,19 +5,23 @@ import java.time.LocalDateTime; public record MySouvenirResponse( - Long id, - String thumbnailUrl, - String countryCode, - LocalDateTime createdAt, - LocalDateTime updatedAt + Long id, + String thumbnailUrl, + String countryCode, + LocalDateTime createdAt, + LocalDateTime updatedAt, + long wishlistCount, + boolean isWishlisted ) { - public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl) { + public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl, boolean isWishlisted, long wishlistCount) { return new MySouvenirResponse( - souvenir.getId(), - thumbnailUrl, - souvenir.getCountryCode(), - souvenir.getCreatedAt(), - souvenir.getUpdatedAt() + souvenir.getId(), + thumbnailUrl, + souvenir.getCountryCode(), + souvenir.getCreatedAt(), + souvenir.getUpdatedAt(), + wishlistCount, + isWishlisted ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java index 46185b1d..d209e21b 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java @@ -9,80 +9,87 @@ import java.util.List; public record SouvenirDetailResponse( - Long id, - String name, - - Integer localPrice, - String currencySymbol, - Integer krwPrice, - - PriceResponse price, - - String description, - String address, - String locationDetail, - BigDecimal latitude, - BigDecimal longitude, - Category category, - Purpose purpose, - String countryCode, - String userNickname, - String userProfileImageUrl, - Boolean isOwned, - List files + Long id, + String name, + Integer localPrice, + String currencySymbol, + Integer krwPrice, + PriceResponse price, + String description, + String address, + String locationDetail, + BigDecimal latitude, + BigDecimal longitude, + Category category, + Purpose purpose, + String countryCode, + String userNickname, + String userProfileImageUrl, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount, + List files ) { public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned + Souvenir souvenir, + List files, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - null, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + null, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + isWishlisted, + wishlistCount, + files ); } public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned, - PriceResponse priceResponse + Souvenir souvenir, + List files, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount, + PriceResponse priceResponse ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - priceResponse, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + priceResponse, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + isWishlisted, + wishlistCount, + files ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java index 190c0674..0a58ee20 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java @@ -4,20 +4,23 @@ import com.souzip.domain.souvenir.entity.Purpose; import java.math.BigDecimal; +import java.util.Set; import java.util.function.Function; public record SouvenirNearbyResponse( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address, + long wishlistCount, + Boolean isWishlisted ) { private static final int INDEX_ID = 0; private static final int INDEX_NAME = 1; @@ -30,38 +33,44 @@ public record SouvenirNearbyResponse( private static final int INDEX_LATITUDE = 8; private static final int INDEX_LONGITUDE = 9; private static final int INDEX_ADDRESS = 10; + private static final int INDEX_WISHLIST_COUNT = 11; public static SouvenirNearbyResponse from( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address, + long wishlistCount, + Boolean isWishlisted ) { return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - thumbnail, - latitude, - longitude, - address + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + thumbnail, + latitude, + longitude, + address, + wishlistCount, + isWishlisted ); } public static SouvenirNearbyResponse fromObjectArray( - Object[] row, - Function urlGenerator + Object[] row, + Function urlGenerator, + Set wishlistedIds ) { Long id = extractId(row); String name = extractName(row); @@ -76,19 +85,23 @@ public static SouvenirNearbyResponse fromObjectArray( String address = extractAddress(row); String imageUrl = generateImageUrl(thumbnail, urlGenerator); + long wishlistCount = ((Number) row[INDEX_WISHLIST_COUNT]).longValue(); + Boolean isWishlisted = wishlistedIds.isEmpty() ? null : wishlistedIds.contains(id); return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - imageUrl, - latitude, - longitude, - address + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + imageUrl, + latitude, + longitude, + address, + wishlistCount, + isWishlisted ); } diff --git a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java index a6cfe50f..60771fd9 100644 --- a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java +++ b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java @@ -6,20 +6,11 @@ import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.vo.PriceInfo; import com.souzip.domain.user.entity.User; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.souzip.shared.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + import java.math.BigDecimal; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -89,65 +80,65 @@ public class Souvenir extends BaseEntity { @Deprecated public static Souvenir of( - SouvenirCreateRequest request, - User user, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirCreateRequest request, + User user, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { return Souvenir.builder() - .name(request.name()) - .localPrice(calculatedLocalPrice) - .currencySymbol(request.currencySymbol()) - .krwPrice(calculatedKrwPrice) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .localPrice(calculatedLocalPrice) + .currencySymbol(request.currencySymbol()) + .krwPrice(calculatedKrwPrice) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } public static Souvenir ofV2( - SouvenirRequest request, - User user, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + User user, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { return Souvenir.builder() - .name(request.name()) - .originalAmount(extractAmount(originalPrice)) - .originalCurrency(extractCurrency(originalPrice)) - .exchangeAmount(exchangeAmount) - .convertedAmount(extractAmount(convertedPrice)) - .convertedCurrency(extractCurrency(convertedPrice)) - .localPrice(extractAmount(originalPrice)) - .currencySymbol(currencySymbol) - .krwPrice(exchangeAmount) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .originalAmount(extractAmount(originalPrice)) + .originalCurrency(extractCurrency(originalPrice)) + .exchangeAmount(exchangeAmount) + .convertedAmount(extractAmount(convertedPrice)) + .convertedCurrency(extractCurrency(convertedPrice)) + .localPrice(extractAmount(originalPrice)) + .currencySymbol(currencySymbol) + .krwPrice(exchangeAmount) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } @Deprecated public void update( - SouvenirUpdateRequest request, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirUpdateRequest request, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { this.name = request.name(); this.localPrice = calculatedLocalPrice; @@ -164,11 +155,11 @@ public void update( } public void updateV2( - SouvenirRequest request, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { this.name = request.name(); this.originalAmount = extractAmount(originalPrice); diff --git a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java index e86519c5..00a68033 100644 --- a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java +++ b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java @@ -27,18 +27,23 @@ public interface SouvenirRepository extends JpaRepository, Souve f.storage_key, s.latitude, s.longitude, - s.address + s.address, + COUNT(w.id) AS wishlist_count FROM souvenir s LEFT JOIN file f ON f.entity_type = 'Souvenir' AND f.entity_id = s.id AND f.display_order = 1 + LEFT JOIN wishlist w ON w.souvenir_id = s.id WHERE s.deleted = false AND ST_DWithin( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography, :radiusMeter ) + GROUP BY s.id, s.name, s.category, s.purpose, s.local_price, + s.krw_price, s.currency_symbol, f.storage_key, + s.latitude, s.longitude, s.address ORDER BY ST_Distance( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography diff --git a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java index 337fe4c7..111a11a8 100644 --- a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java +++ b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java @@ -5,28 +5,21 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.exchangerate.dto.ExchangeCalculatedPrice; import com.souzip.domain.exchangerate.service.ExchangeRateService; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.souvenir.dto.PriceData; -import com.souzip.domain.souvenir.dto.PriceResponse; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.souvenir.repository.SouvenirRepository; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.security.jwt.JwtTokenProvider; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,6 +29,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; @Slf4j @Transactional(readOnly = true) @@ -48,6 +42,7 @@ public class SouvenirService { private final SouvenirRepository souvenirRepository; private final UserRepository userRepository; + private final WishlistRepository wishlistRepository; private final FileModifyService fileModifyService; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -58,18 +53,22 @@ public class SouvenirService { public SouvenirNearbyListResponse getNearbySouvenirs( double latitude, double longitude, - double radiusMeter + double radiusMeter, + @Nullable String authorizationHeader ) { - List results = souvenirRepository.findNearbySouvenirs( - latitude, - longitude, - radiusMeter - ); + String userId = extractUserId(authorizationHeader); + + List results = souvenirRepository.findNearbySouvenirs(latitude, longitude, radiusMeter); + + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); List list = results.stream() .map(row -> SouvenirNearbyResponse.fromObjectArray( row, - ncpStorage::generateUrl + ncpStorage::generateUrl, + wishlistedIds )) .toList(); @@ -81,13 +80,13 @@ public SouvenirDetailResponse getSouvenir( @Nullable String authorizationHeader ) { String userId = extractUserId(authorizationHeader); - Souvenir souvenir = findSouvenirById(souvenirId); List files = getFiles(souvenirId); boolean isOwned = souvenir.isOwnedBy(userId); + boolean isWishlisted = userId != null && wishlistRepository.existsByUserUserIdAndSouvenirId(userId, souvenirId); + long wishlistCount = wishlistRepository.countBySouvenirId(souvenirId); PriceResponse priceResponse = createPriceResponse(souvenir); - - return SouvenirDetailResponse.of(souvenir, files, isOwned, priceResponse); + return SouvenirDetailResponse.of(souvenir, files, isOwned, isWishlisted, wishlistCount, priceResponse); } @Audit(action = AuditAction.SOUVENIR_DELETED) @@ -258,7 +257,8 @@ private boolean isEmptyToken(String token) { @Nullable private String parseUserIdFromToken(String token) { try { - return jwtTokenProvider.getUserIdFromToken(token); + Long userId = jwtTokenProvider.getUserIdFromToken(token); + return userId != null ? String.valueOf(userId) : null; } catch (Exception e) { log.debug("Failed to parse token", e); return null; diff --git a/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java b/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java new file mode 100644 index 00000000..346e548c --- /dev/null +++ b/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java @@ -0,0 +1,32 @@ +package com.souzip.domain.user.adapter; + +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.required.UserPort; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserPortImpl implements UserPort { + + private final UserRepository userRepository; + + @Override + public UserInfo findOrCreateUser(Provider provider, String providerId, String email) { + User user = userRepository + .findByProviderAndProviderId(provider, providerId) + .orElseGet(() -> userRepository.save( + User.of(provider, providerId, email) + )); + + return new UserInfo( + user.getId(), + user.getUserId(), + user.getNickname(), + user.needsOnboarding() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/user/controller/UserController.java b/src/main/java/com/souzip/domain/user/controller/UserController.java index e981e75c..d3b26061 100644 --- a/src/main/java/com/souzip/domain/user/controller/UserController.java +++ b/src/main/java/com/souzip/domain/user/controller/UserController.java @@ -1,24 +1,14 @@ package com.souzip.domain.user.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.souvenir.dto.MySouvenirListResponse; -import com.souzip.domain.user.dto.NicknameCheckRequest; -import com.souzip.domain.user.dto.NicknameCheckResponse; -import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.user.dto.OnboardingResponse; -import com.souzip.domain.user.dto.UserProfileResponse; +import com.souzip.domain.user.dto.*; import com.souzip.domain.user.service.UserService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RequestMapping("/api/users") @@ -30,7 +20,7 @@ public class UserController { @PostMapping("/check-nickname") @RequireAuth public SuccessResponse checkNickname( - @Valid @RequestBody NicknameCheckRequest request + @Valid @RequestBody NicknameCheckRequest request ) { NicknameCheckResponse response = userService.checkNickname(request.nickname()); return SuccessResponse.of(response); @@ -39,8 +29,8 @@ public SuccessResponse checkNickname( @PostMapping("/onboarding") @RequireAuth public SuccessResponse completeOnboarding( - @CurrentUserId Long currentUserId, - @Valid @RequestBody OnboardingRequest request + @CurrentUserId Long currentUserId, + @Valid @RequestBody OnboardingRequest request ) { OnboardingResponse response = userService.completeOnboarding(currentUserId, request); return SuccessResponse.of(response); @@ -49,7 +39,7 @@ public SuccessResponse completeOnboarding( @GetMapping("/me") @RequireAuth public SuccessResponse getMyProfile( - @CurrentUserId Long currentUserId + @CurrentUserId Long currentUserId ) { UserProfileResponse profile = userService.getUserProfile(currentUserId); return SuccessResponse.of(profile); @@ -58,9 +48,9 @@ public SuccessResponse getMyProfile( @GetMapping("/me/souvenirs") @RequireAuth public SuccessResponse getMySouvenirs( - @CurrentUserId Long currentUserId, - @RequestParam int page, - @RequestParam int size + @CurrentUserId Long currentUserId, + @RequestParam int page, + @RequestParam int size ) { MySouvenirListResponse souvenirs = userService.getMySouvenirs(currentUserId, page, size); return SuccessResponse.of(souvenirs); diff --git a/src/main/java/com/souzip/domain/user/entity/Provider.java b/src/main/java/com/souzip/domain/user/entity/Provider.java deleted file mode 100644 index d4399633..00000000 --- a/src/main/java/com/souzip/domain/user/entity/Provider.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.souzip.domain.user.entity; - -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import java.util.Arrays; - -public enum Provider { - KAKAO, - APPLE, - GOOGLE; - - public static Provider from(String value) { - if (value == null || value.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); - } - - return Arrays.stream(values()) - .filter(provider -> provider.name().equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new BusinessException( - ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); - } -} diff --git a/src/main/java/com/souzip/domain/user/entity/User.java b/src/main/java/com/souzip/domain/user/entity/User.java index b69f64df..49bbf9b3 100644 --- a/src/main/java/com/souzip/domain/user/entity/User.java +++ b/src/main/java/com/souzip/domain/user/entity/User.java @@ -1,15 +1,15 @@ package com.souzip.domain.user.entity; -import com.souzip.domain.auth.dto.OAuthUserInfo; import com.souzip.domain.category.entity.Category; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Provider; import jakarta.persistence.*; -import java.util.HashSet; -import java.util.Set; import lombok.*; import org.hibernate.annotations.SQLDelete; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; @Getter @@ -17,15 +17,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Table(name = "\"user\"", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"provider", "provider_id"}) - }, - indexes = { - @Index(name = "idx_user_id", columnList = "user_id"), - @Index(name = "idx_provider", columnList = "provider, provider_id"), - @Index(name = "idx_deleted", columnList = "deleted"), - @Index(name = "idx_email", columnList = "email") - } + uniqueConstraints = { + @UniqueConstraint(columnNames = {"provider", "provider_id"}) + }, + indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_provider", columnList = "provider, provider_id"), + @Index(name = "idx_deleted", columnList = "deleted"), + @Index(name = "idx_email", columnList = "email") + } ) @SQLDelete(sql = "UPDATE \"user\" SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @Entity @@ -61,8 +61,8 @@ public class User extends BaseEntity { @ElementCollection(fetch = FetchType.LAZY) @CollectionTable( - name = "user_category", - joinColumns = @JoinColumn(name = "user_id") + name = "user_category", + joinColumns = @JoinColumn(name = "user_id") ) @Column(name = "category") @Enumerated(EnumType.STRING) @@ -81,37 +81,18 @@ protected void onCreate() { ensureUserId(); } - public static User of( - Provider provider, - String providerId, - String name, - String nickname, - String email, - String profileImageUrl - ) { + public static User of(Provider provider, String providerId, String email) { return User.builder() - .userId(UUID.randomUUID().toString()) - .provider(provider) - .providerId(providerId) - .name(name) - .nickname(nickname) - .email(email) - .profileImageUrl(profileImageUrl) - .onboardingCompleted(false) - .categories(new HashSet<>()) - .deleted(false) - .build(); - } - - public static User of(Provider provider, OAuthUserInfo oauthUserInfo) { - return User.of( - provider, - oauthUserInfo.getProviderId(), - oauthUserInfo.getName(), - "", - oauthUserInfo.getEmail(), - oauthUserInfo.getProfileImageUrl() - ); + .userId(UUID.randomUUID().toString()) + .provider(provider) + .providerId(providerId) + .name("") + .nickname("") + .email(email) + .onboardingCompleted(false) + .categories(new HashSet<>()) + .deleted(false) + .build(); } private void ensureUserId() { @@ -161,4 +142,4 @@ public void updateProviderId(String providerId) { public void updateLastLoginAt() { this.lastLoginAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java index a38f787a..2467b398 100644 --- a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java +++ b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.entity; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -32,31 +32,31 @@ public class UserAgreement extends BaseEntity { private Boolean marketingConsent; public static UserAgreement of( - User user, - Boolean ageVerified, - Boolean serviceTerms, - Boolean privacyRequired, - Boolean locationService, - Boolean marketingConsent + User user, + Boolean ageVerified, + Boolean serviceTerms, + Boolean privacyRequired, + Boolean locationService, + Boolean marketingConsent ) { return UserAgreement.builder() - .user(user) - .ageVerified(ageVerified) - .serviceTerms(serviceTerms) - .privacyRequired(privacyRequired) - .locationService(locationService) - .marketingConsent(marketingConsent) - .build(); + .user(user) + .ageVerified(ageVerified) + .serviceTerms(serviceTerms) + .privacyRequired(privacyRequired) + .locationService(locationService) + .marketingConsent(marketingConsent) + .build(); } public static UserAgreement of(User user, OnboardingRequest request) { return of( - user, - request.ageVerified(), - request.serviceTerms(), - request.privacyRequired(), - request.locationService(), - request.marketingConsent() + user, + request.ageVerified(), + request.serviceTerms(), + request.privacyRequired(), + request.locationService(), + request.marketingConsent() ); } } diff --git a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java index 62a32cce..bba83483 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java @@ -11,4 +11,6 @@ public interface UserAgreementRepository extends JpaRepository findByUser(User user); boolean existsByUser(User user); + + long countByMarketingConsentTrue(); } diff --git a/src/main/java/com/souzip/domain/user/repository/UserRepository.java b/src/main/java/com/souzip/domain/user/repository/UserRepository.java index 79600874..ab86490f 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserRepository.java @@ -1,12 +1,13 @@ package com.souzip.domain.user.repository; -import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; -import java.util.List; -import java.util.Optional; +import com.souzip.shared.domain.Provider; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; +import java.util.Optional; + public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(Provider provider, String providerId); @@ -24,4 +25,6 @@ public interface UserRepository extends JpaRepository { WHERE u.deleted = false AND u.email IS NOT NULL """) List findDistinctEmailsByActiveUsers(); + + long deleteByDeletedTrue(); } diff --git a/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java b/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java new file mode 100644 index 00000000..a608c812 --- /dev/null +++ b/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java @@ -0,0 +1,22 @@ +package com.souzip.domain.user.scheduler; + +import com.souzip.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserCleanupScheduler { + + private final UserService userService; + + @Scheduled(cron = "0 0 3 * * *") + public void runUserCleanup() { + log.info("탈퇴 유저 삭제 배치 스케줄러 실행 시작"); + long deletedCount = userService.deleteWithdrawnUsers(); + log.info("탈퇴 유저 {}명 삭제 완료", deletedCount); + } +} diff --git a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java index b8dd95f0..f0336d60 100644 --- a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java +++ b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,7 +20,7 @@ public class ProfileImageService { private static final String URL_FORMAT = "%s/%s/%s/%s%s"; private static final Set AVAILABLE_COLORS = Set.of( - "red", "blue", "yellow", "purple" + "red", "blue", "yellow", "purple" ); public String resolveProfileImageUrl(String input) { @@ -71,12 +71,12 @@ private boolean isValidColor(String color) { private String buildColorUrl(String color) { return String.format( - URL_FORMAT, - storageProperties.getEndpoint(), - storageProperties.getBucket(), - PROFILE_FOLDER, - color, - IMAGE_EXTENSION + URL_FORMAT, + storageProperties.getEndpoint(), + storageProperties.getBucket(), + PROFILE_FOLDER, + color, + IMAGE_EXTENSION ); } } diff --git a/src/main/java/com/souzip/domain/user/service/UserService.java b/src/main/java/com/souzip/domain/user/service/UserService.java index 04087f71..92cb7665 100644 --- a/src/main/java/com/souzip/domain/user/service/UserService.java +++ b/src/main/java/com/souzip/domain/user/service/UserService.java @@ -3,8 +3,8 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.auth.repository.RefreshTokenRepository; import com.souzip.domain.category.dto.CategoryDto; import com.souzip.domain.category.entity.Category; import com.souzip.domain.file.EntityType; @@ -21,14 +21,10 @@ import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -38,6 +34,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) @@ -49,6 +52,7 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final ProfileImageService profileImageService; private final SouvenirRepository souvenirRepository; + private final WishlistRepository wishlistRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -85,12 +89,20 @@ public OnboardingResponse completeOnboarding(Long userId, OnboardingRequest requ @Transactional public void withdraw(Long userId) { User user = findUserById(userId); - deleteRefreshTokenIfExists(user); + + deleteRefreshTokenIfExists(userId); deleteUserAgreementIfExists(user); + user.anonymize(); + userRepository.delete(user); } + @Transactional + public long deleteWithdrawnUsers() { + return userRepository.deleteByDeletedTrue(); + } + public UserProfileResponse getUserProfile(Long userId) { User user = findUserById(userId); return UserProfileResponse.from(user); @@ -107,12 +119,16 @@ public MySouvenirListResponse getMySouvenirs(Long userId, int page, int size) { .toList(); Map thumbnailMap = getThumbnails(souvenirIds); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(user.getUserId()); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); Page responsePage = souvenirPage.map(souvenir -> { String thumbnailUrl = Optional.ofNullable(thumbnailMap.get(souvenir.getId())) .map(FileResponse::url) .orElse(null); - return MySouvenirResponse.of(souvenir, thumbnailUrl); + boolean isWishlisted = wishlistedIds.contains(souvenir.getId()); + long wishlistCount = wishlistCountMap.getOrDefault(souvenir.getId(), 0L); + return MySouvenirResponse.of(souvenir, thumbnailUrl, isWishlisted, wishlistCount); }); return MySouvenirListResponse.from(responsePage); @@ -174,9 +190,8 @@ private boolean isRequiredAgreementNotAccepted(OnboardingRequest request) { || !request.locationService(); } - private void deleteRefreshTokenIfExists(User user) { - refreshTokenRepository.findByUser(user) - .ifPresent(refreshTokenRepository::delete); + private void deleteRefreshTokenIfExists(Long userId) { + refreshTokenRepository.deleteByUserId(userId); } private void deleteUserAgreementIfExists(User user) { diff --git a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java index 1526fae3..f4247470 100644 --- a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java +++ b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java @@ -1,11 +1,11 @@ package com.souzip.domain.wishlist.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.wishlist.dto.MyWishlistListResponse; import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.service.WishlistService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @@ -19,8 +19,8 @@ public class WishlistController { @PostMapping("/api/wishlists/{souvenirId}") @RequireAuth public SuccessResponse addWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId ) { return SuccessResponse.of(wishlistService.addWishlist(currentUserId, souvenirId)); } @@ -28,8 +28,8 @@ public SuccessResponse addWishlist( @DeleteMapping("/api/wishlists/{souvenirId}") @RequireAuth public SuccessResponse removeWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId ) { return SuccessResponse.of(wishlistService.removeWishlist(currentUserId, souvenirId)); } @@ -37,8 +37,8 @@ public SuccessResponse removeWishlist( @GetMapping("/api/users/me/wishlists") @RequireAuth public SuccessResponse getMyWishlist( - @CurrentUserId Long currentUserId, - Pageable pageable + @CurrentUserId Long currentUserId, + Pageable pageable ) { return SuccessResponse.of(wishlistService.getMyWishlist(currentUserId, pageable)); } diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java index b37266f0..f4edc416 100644 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java @@ -1,6 +1,6 @@ package com.souzip.domain.wishlist.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; +import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; import org.springframework.data.domain.Page; import java.util.List; diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java index 37007f92..2582f008 100644 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java @@ -10,7 +10,8 @@ public record MyWishlistResponse( String name, String countryCode, String thumbnailUrl, - LocalDateTime wishedAt + LocalDateTime wishedAt, + boolean isWishlisted ) { public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { Souvenir s = wishlist.getSouvenir(); @@ -19,7 +20,8 @@ public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { s.getName(), s.getCountryCode(), thumbnailUrl, - wishlist.getCreatedAt() + wishlist.getCreatedAt(), + true ); } } diff --git a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java index ddad5617..dc24fb1a 100644 --- a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java +++ b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java @@ -1,8 +1,8 @@ package com.souzip.domain.wishlist.entity; -import com.souzip.domain.shared.BaseEntity; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.User; +import com.souzip.shared.domain.BaseEntity; import jakarta.persistence.Entity; import lombok.*; diff --git a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java index a1c93bcb..c4de3895 100644 --- a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java +++ b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java @@ -9,10 +9,15 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @Repository public interface WishlistRepository extends JpaRepository { - boolean existsByUserIdAndSouvenirId(Long userId, Long souvenirId); + boolean existsByUserUserIdAndSouvenirId(String userId, Long souvenirId); @Modifying @Query("DELETE FROM Wishlist w WHERE w.user.id = :userId AND w.souvenir.id = :souvenirId") @@ -23,4 +28,21 @@ public interface WishlistRepository extends JpaRepository { countQuery = "SELECT COUNT(w) FROM Wishlist w WHERE w.user.id = :userId" ) Page findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT w.souvenir.id FROM Wishlist w WHERE w.user.userId = :userId") + Set findSouvenirIdsByUserId(@Param("userId") String userId); + + @Query("SELECT COUNT(w) FROM Wishlist w WHERE w.souvenir.id = :souvenirId") + long countBySouvenirId(@Param("souvenirId") Long souvenirId); + + @Query("SELECT w.souvenir.id, COUNT(w) FROM Wishlist w WHERE w.souvenir.id IN :souvenirIds GROUP BY w.souvenir.id") + List countBySouvenirIdsRaw(@Param("souvenirIds") List souvenirIds); + + default Map countBySouvenirIds(List souvenirIds) { + return countBySouvenirIdsRaw(souvenirIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } } diff --git a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java index dd3b9a6e..73c1ed6f 100644 --- a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java +++ b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java @@ -14,8 +14,8 @@ import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.entity.Wishlist; import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; @@ -53,7 +53,8 @@ public WishlistResponse addWishlist(Long userId, Long souvenirId) { @Transactional public WishlistResponse removeWishlist(Long userId, Long souvenirId) { - if (!wishlistRepository.existsByUserIdAndSouvenirId(userId, souvenirId)) { + User user = findUserById(userId); + if (!wishlistRepository.existsByUserUserIdAndSouvenirId(user.getUserId(), souvenirId)) { throw new BusinessException(ErrorCode.WISHLIST_NOT_FOUND); } wishlistRepository.deleteByUserIdAndSouvenirId(userId, souvenirId); diff --git a/src/main/java/com/souzip/global/config/AppConfig.java b/src/main/java/com/souzip/global/config/AppConfig.java deleted file mode 100644 index 45a37900..00000000 --- a/src/main/java/com/souzip/global/config/AppConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.souzip.global.config; - -import com.souzip.global.oauth.OAuthProperties; -import com.souzip.global.security.jwt.JwtProperties; -import com.souzip.adapter.config.ObjectStorageProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties({ - JwtProperties.class, - OAuthProperties.class, - ObjectStorageProperties.class, - ClovaStudioProperties.class, - AppleMigrationConfig.class, - CorsProperties.class, - MailSmtpProperties.class -}) -public class AppConfig { -} diff --git a/src/main/java/com/souzip/global/oauth/OAuthProperties.java b/src/main/java/com/souzip/global/oauth/OAuthProperties.java deleted file mode 100644 index a4807eee..00000000 --- a/src/main/java/com/souzip/global/oauth/OAuthProperties.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.souzip.global.oauth; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@RequiredArgsConstructor -@ConfigurationProperties(prefix = "oauth") -public class OAuthProperties { - - private final Kakao kakao; - private final Google google; - - @Getter - @RequiredArgsConstructor - public static class Kakao { - private final String userInfoUrl; - } - - @Getter - @RequiredArgsConstructor - public static class Google { - private final String userInfoUrl; - } -} diff --git a/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java deleted file mode 100644 index 5eda49e3..00000000 --- a/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.souzip.global.security.jwt; - -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Collections; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - - private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - authenticateRequest(request); - } catch (Exception e) { - log.error("사용자 인증 정보를 SecurityContext에 설정할 수 없습니다.", e); - } - - filterChain.doFilter(request, response); - } - - private void authenticateRequest(HttpServletRequest request) { - String token = extractTokenFromRequest(request); - - if (isTokenAbsent(token)) { - return; - } - - User user = getUserFromToken(token); - - if (isUserAbsent(user)) { - return; - } - - setAuthentication(user); - } - - private String extractTokenFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - - if (hasBearerToken(bearerToken)) { - return extractToken(bearerToken); - } - - return null; - } - - private boolean hasBearerToken(String bearerToken) { - return StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX); - } - - private String extractToken(String bearerToken) { - return bearerToken.substring(BEARER_PREFIX.length()); - } - - private boolean isTokenAbsent(String token) { - return isTokenNull(token) || isTokenInvalid(token); - } - - private boolean isTokenNull(String token) { - return token == null; - } - - private boolean isTokenInvalid(String token) { - return !jwtTokenProvider.validateToken(token); - } - - private User getUserFromToken(String token) { - String userId = jwtTokenProvider.getUserIdFromToken(token); - return userRepository.findByUserId(userId).orElse(null); - } - - private boolean isUserAbsent(User user) { - return user == null; - } - - private void setAuthentication(User user) { - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java deleted file mode 100644 index b09f64c8..00000000 --- a/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.souzip.global.security.jwt; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtTokenProvider { - - private final JwtProperties jwtProperties; - - public String generateToken(String userId) { - return createToken(userId, jwtProperties.getExpiration()); - } - - public String generateRefreshToken(String userId) { - return createToken(userId, jwtProperties.getRefreshExpiration()); - } - - private String createToken(String userId, Long expirationMs) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expirationMs); - - return Jwts.builder() - .subject(userId) - .issuedAt(now) - .expiration(expiryDate) - .signWith(getSigningKey()) - .compact(); - } - - public String getUserIdFromToken(String token) { - return parseClaims(token).getSubject(); - } - - public boolean validateToken(String token) { - try { - parseClaims(token); - return true; - } catch (Exception e) { - log.debug("Invalid JWT token: {}", e.getMessage()); - return false; - } - } - - private Claims parseClaims(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - private SecretKey getSigningKey() { - byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); - } -} diff --git a/src/main/java/com/souzip/global/aop/MdcTraceId.java b/src/main/java/com/souzip/shared/aop/MdcTraceId.java similarity index 91% rename from src/main/java/com/souzip/global/aop/MdcTraceId.java rename to src/main/java/com/souzip/shared/aop/MdcTraceId.java index 344a4a3f..a6231c29 100644 --- a/src/main/java/com/souzip/global/aop/MdcTraceId.java +++ b/src/main/java/com/souzip/shared/aop/MdcTraceId.java @@ -1,13 +1,15 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import java.util.UUID; + import org.slf4j.MDC; public class MdcTraceId { private static final String TRACE_ID = "traceId"; - private MdcTraceId() {} + private MdcTraceId() { + } public static String generate() { return UUID.randomUUID().toString().substring(0, 8); diff --git a/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java b/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java similarity index 75% rename from src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java rename to src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java index 2111e769..a6459721 100644 --- a/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java +++ b/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java @@ -1,6 +1,6 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; @@ -16,16 +16,17 @@ public class ServiceExceptionLoggingAspect { @Pointcut("execution(* com.souzip.api..service..*(..))") - public void serviceLayer() {} + public void serviceLayer() { + } @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex") public void logServiceException(JoinPoint joinPoint, Exception ex) { if (ex instanceof BusinessException) return; log.error("[SERVICE-ERROR] method={} message={}", - joinPoint.getSignature().toShortString(), - ex.getMessage(), - ex + joinPoint.getSignature().toShortString(), + ex.getMessage(), + ex ); } } diff --git a/src/main/java/com/souzip/global/aop/TraceIdAspect.java b/src/main/java/com/souzip/shared/aop/TraceIdAspect.java similarity index 96% rename from src/main/java/com/souzip/global/aop/TraceIdAspect.java rename to src/main/java/com/souzip/shared/aop/TraceIdAspect.java index 18670887..913289fe 100644 --- a/src/main/java/com/souzip/global/aop/TraceIdAspect.java +++ b/src/main/java/com/souzip/shared/aop/TraceIdAspect.java @@ -1,4 +1,4 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; diff --git a/src/main/java/com/souzip/global/audit/annotation/Audit.java b/src/main/java/com/souzip/shared/audit/annotation/Audit.java similarity index 89% rename from src/main/java/com/souzip/global/audit/annotation/Audit.java rename to src/main/java/com/souzip/shared/audit/annotation/Audit.java index e4c9fb49..96435ed9 100644 --- a/src/main/java/com/souzip/global/audit/annotation/Audit.java +++ b/src/main/java/com/souzip/shared/audit/annotation/Audit.java @@ -1,6 +1,7 @@ -package com.souzip.global.audit.annotation; +package com.souzip.shared.audit.annotation; import com.souzip.domain.audit.entity.AuditAction; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java b/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java similarity index 59% rename from src/main/java/com/souzip/global/audit/aspect/AuditAspect.java rename to src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java index 8cb6d87e..2cfd071e 100644 --- a/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java +++ b/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java @@ -1,17 +1,15 @@ -package com.souzip.global.audit.aspect; +package com.souzip.shared.audit.aspect; +import com.souzip.auth.application.dto.LoginInfo; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.service.AuditService; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.audit.dto.AuditContext; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.util.HttpRequestUtils; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.audit.dto.AuditContext; +import com.souzip.shared.domain.Provider; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; +import com.souzip.shared.util.HttpRequestUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,7 +32,7 @@ public class AuditAspect { private static final String ONBOARDING_METADATA_FORMAT = - "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; + "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; private final AuditService auditService; @@ -65,11 +63,11 @@ private void logSuccess(HttpServletRequest request, private void logFailure(HttpServletRequest request, AuditAction action, Exception e) { auditService.logFailure( - action, - e.getClass().getSimpleName(), - HttpRequestUtils.extractClientIp(request), - HttpRequestUtils.extractUserAgent(request), - HttpRequestUtils.extractAppVersion(request) + action, + e.getClass().getSimpleName(), + HttpRequestUtils.extractClientIp(request), + HttpRequestUtils.extractUserAgent(request), + HttpRequestUtils.extractAppVersion(request) ); } @@ -80,14 +78,14 @@ private AuditContext buildSuccessContext(HttpServletRequest request, Audit audit ) { return AuditContext.builder() - .userId(extractUserId(result, joinPoint, audit)) - .action(action) - .ipAddress(HttpRequestUtils.extractClientIp(request)) - .userAgent(HttpRequestUtils.extractUserAgent(request)) - .appVersion(HttpRequestUtils.extractAppVersion(request)) - .oauthProvider(extractOAuthProvider(joinPoint)) - .metadata(buildMetadata(joinPoint)) - .build(); + .userId(extractUserId(result, joinPoint, audit)) + .action(action) + .ipAddress(HttpRequestUtils.extractClientIp(request)) + .userAgent(HttpRequestUtils.extractUserAgent(request)) + .appVersion(HttpRequestUtils.extractAppVersion(request)) + .oauthProvider(extractOAuthProvider(joinPoint)) + .metadata(buildMetadata(joinPoint)) + .build(); } private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit audit) { @@ -95,8 +93,8 @@ private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit return extractUserIdFromArgs(joinPoint); } - if (isLoginResponse(result)) { - return extractUserIdFromLoginResponse((LoginResponse) result); + if (result instanceof LoginInfo loginInfo) { + return loginInfo.userId(); } return extractUserIdFromSecurity(); @@ -108,65 +106,50 @@ private boolean hasUserIdParam(Audit audit) { private String extractUserIdFromArgs(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(this::isUserIdArg) - .map(String.class::cast) - .findFirst() - .orElse(null); - } - - private boolean isLoginResponse(Object result) { - return result instanceof LoginResponse; - } - - private String extractUserIdFromLoginResponse(LoginResponse loginResponse) { - return Optional.ofNullable(loginResponse.getUser()) - .map(LoginUserInfo::userId) - .orElse(null); + .filter(this::isUserIdArg) + .map(String.class::cast) + .findFirst() + .orElse(null); } private String extractUserIdFromSecurity() { return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) - .filter(this::isAuthenticatedUser) - .map(Authentication::getPrincipal) - .map(User.class::cast) - .map(User::getUserId) - .orElse(null); - } - - private boolean isAuthenticatedUser(Authentication authentication) { - return authentication.getPrincipal() instanceof User; + .map(Authentication::getPrincipal) + .filter(Long.class::isInstance) + .map(principal -> String.valueOf((Long) principal)) + .orElse(null); } private Provider extractOAuthProvider(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(Provider.class::isInstance) - .map(Provider.class::cast) - .findFirst() - .orElse(null); + .filter(Provider.class::isInstance) + .map(Provider.class::cast) + .findFirst() + .orElse(null); } private String buildMetadata(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(OnboardingRequest.class::isInstance) - .map(OnboardingRequest.class::cast) - .findFirst() - .map(this::formatOnboardingMetadata) - .orElse(null); + .filter(OnboardingRequest.class::isInstance) + .map(OnboardingRequest.class::cast) + .findFirst() + .map(this::formatOnboardingMetadata) + .orElse(null); } private String formatOnboardingMetadata(OnboardingRequest req) { return String.format( - ONBOARDING_METADATA_FORMAT, - req.serviceTerms(), - req.privacyRequired(), - req.marketingConsent(), - req.locationService() + ONBOARDING_METADATA_FORMAT, + req.serviceTerms(), + req.privacyRequired(), + req.marketingConsent(), + req.locationService() ); } private HttpServletRequest getCurrentRequest() { ServletRequestAttributes attributes = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); validateRequestAttributes(attributes); return attributes.getRequest(); } @@ -184,4 +167,4 @@ private boolean isUserIdArg(Object arg) { private boolean isMissingAttributes(ServletRequestAttributes attributes) { return attributes == null; } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/audit/dto/AuditContext.java b/src/main/java/com/souzip/shared/audit/dto/AuditContext.java similarity index 88% rename from src/main/java/com/souzip/global/audit/dto/AuditContext.java rename to src/main/java/com/souzip/shared/audit/dto/AuditContext.java index 0f63ec8a..09e93183 100644 --- a/src/main/java/com/souzip/global/audit/dto/AuditContext.java +++ b/src/main/java/com/souzip/shared/audit/dto/AuditContext.java @@ -1,7 +1,7 @@ -package com.souzip.global.audit.dto; +package com.souzip.shared.audit.dto; import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.user.entity.Provider; +import com.souzip.shared.domain.Provider; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/souzip/global/clova/ClovaStudioClient.java b/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java similarity index 76% rename from src/main/java/com/souzip/global/clova/ClovaStudioClient.java rename to src/main/java/com/souzip/shared/clova/ClovaStudioClient.java index 2a516550..d326d8f0 100644 --- a/src/main/java/com/souzip/global/clova/ClovaStudioClient.java +++ b/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java @@ -1,8 +1,8 @@ -package com.souzip.global.clova; +package com.souzip.shared.clova; -import com.souzip.global.config.ClovaStudioProperties; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.config.ClovaStudioProperties; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; @@ -41,10 +41,11 @@ private String chat(String userMessage) { try { HttpEntity> request = createRequest(userMessage); ResponseEntity> response = restTemplate.exchange( - properties.getApiUrl(), - HttpMethod.POST, - request, - new ParameterizedTypeReference>() {} + properties.getApiUrl(), + HttpMethod.POST, + request, + new ParameterizedTypeReference>() { + } ); return parseResponse(response.getBody()); @@ -71,27 +72,27 @@ private HttpHeaders createHeaders() { private Map createRequestBody(String userMessage) { return Map.of( - "messages", createMessages(userMessage), - "topP", TOP_P, - "topK", TOP_K, - "maxTokens", MAX_TOKENS, - "temperature", TEMPERATURE, - "repeatPenalty", REPEAT_PENALTY, - "includeAiFilters", true + "messages", createMessages(userMessage), + "topP", TOP_P, + "topK", TOP_K, + "maxTokens", MAX_TOKENS, + "temperature", TEMPERATURE, + "repeatPenalty", REPEAT_PENALTY, + "includeAiFilters", true ); } private List> createMessages(String userMessage) { return List.of( - createMessage("system", CURATOR_SYSTEM_PROMPT), - createMessage("user", userMessage) + createMessage("system", CURATOR_SYSTEM_PROMPT), + createMessage("user", userMessage) ); } private Map createMessage(String role, String text) { return Map.of( - "role", role, - "content", List.of(Map.of("type", "text", "text", text)) + "role", role, + "content", List.of(Map.of("type", "text", "text", text)) ); } @@ -113,7 +114,7 @@ private String parseResponse(Map responseBody) { private String parseListContent(List> contentList) { return contentList.stream() - .map(item -> (String) item.get("text")) - .reduce("", String::concat); + .map(item -> (String) item.get("text")) + .reduce("", String::concat); } } diff --git a/src/main/java/com/souzip/global/clova/PromptLoader.java b/src/main/java/com/souzip/shared/clova/PromptLoader.java similarity index 84% rename from src/main/java/com/souzip/global/clova/PromptLoader.java rename to src/main/java/com/souzip/shared/clova/PromptLoader.java index 51b2c135..e9b265a1 100644 --- a/src/main/java/com/souzip/global/clova/PromptLoader.java +++ b/src/main/java/com/souzip/shared/clova/PromptLoader.java @@ -1,7 +1,7 @@ -package com.souzip.global.clova; +package com.souzip.shared.clova; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/souzip/global/common/dto/ErrorResponse.java b/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java similarity index 75% rename from src/main/java/com/souzip/global/common/dto/ErrorResponse.java rename to src/main/java/com/souzip/shared/common/dto/ErrorResponse.java index 07cb6312..bd47f359 100644 --- a/src/main/java/com/souzip/global/common/dto/ErrorResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java @@ -1,8 +1,10 @@ -package com.souzip.global.common.dto; +package com.souzip.shared.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import com.souzip.global.aop.MdcTraceId; +import com.souzip.shared.aop.MdcTraceId; + import java.util.List; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -24,17 +26,17 @@ private ErrorResponse(String traceId, String message, List errors) { public static ErrorResponse of(String message) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .build(); } public static ErrorResponse of(String message, List errors) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .errors(errors) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .errors(errors) + .build(); } @Getter @@ -53,10 +55,10 @@ private FieldError(String field, String value, String reason) { public static FieldError of(String field, String value, String reason) { return FieldError.builder() - .field(field) - .value(value) - .reason(reason) - .build(); + .field(field) + .value(value) + .reason(reason) + .build(); } } } diff --git a/src/main/java/com/souzip/global/common/dto/SuccessResponse.java b/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java similarity index 80% rename from src/main/java/com/souzip/global/common/dto/SuccessResponse.java rename to src/main/java/com/souzip/shared/common/dto/SuccessResponse.java index aacf8e03..94fea218 100644 --- a/src/main/java/com/souzip/global/common/dto/SuccessResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java @@ -1,4 +1,4 @@ -package com.souzip.global.common.dto; +package com.souzip.shared.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AccessLevel; @@ -20,9 +20,9 @@ private SuccessResponse(T data, String message) { public static SuccessResponse of(T data, String message) { return SuccessResponse.builder() - .data(data) - .message(message) - .build(); + .data(data) + .message(message) + .build(); } public static SuccessResponse of(T data) { @@ -31,7 +31,7 @@ public static SuccessResponse of(T data) { public static SuccessResponse of(String message) { return SuccessResponse.builder() - .message(message) - .build(); + .message(message) + .build(); } } diff --git a/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java similarity index 85% rename from src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java rename to src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java index ff9c9a08..55f5ff21 100644 --- a/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java +++ b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java @@ -1,6 +1,7 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import java.util.Optional; + import lombok.Getter; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -19,10 +20,10 @@ public class PaginationRequest { public PaginationRequest(Integer pageNo, Integer pageSize) { this.pageNo = validatePageNo( - Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) + Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) ); this.pageSize = validatePageSize( - Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) + Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) ); } diff --git a/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java similarity index 67% rename from src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java rename to src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java index 7b3050e4..f1e8774c 100644 --- a/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java @@ -1,7 +1,9 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.List; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -22,16 +24,16 @@ protected PaginationResponse(List content, PageInfo pagination) { public static PaginationResponse of(Page page, List content) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(page)) - .build(); + .content(content) + .pagination(PageInfo.of(page)) + .build(); } public static PaginationResponse of(List content, int pageNo, int pageSize, long total, int totalPages) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) - .build(); + .content(content) + .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) + .build(); } @Getter @@ -64,28 +66,28 @@ private PageInfo(int currentPage, int totalPages, public static PageInfo of(Page page) { return PageInfo.builder() - .currentPage(toDisplayPageNumber(page.getNumber())) - .totalPages(page.getTotalPages()) - .totalItems(page.getTotalElements()) - .pageSize(page.getSize()) - .first(page.isFirst()) - .last(page.isLast()) - .hasNext(page.hasNext()) - .hasPrevious(page.hasPrevious()) - .build(); + .currentPage(toDisplayPageNumber(page.getNumber())) + .totalPages(page.getTotalPages()) + .totalItems(page.getTotalElements()) + .pageSize(page.getSize()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); } public static PageInfo of(int pageNo, int pageSize, long total, int totalPages) { return PageInfo.builder() - .currentPage(pageNo) - .totalPages(totalPages) - .totalItems(total) - .pageSize(pageSize) - .first(pageNo == 1) - .last(pageNo >= totalPages) - .hasNext(pageNo < totalPages) - .hasPrevious(pageNo > 1) - .build(); + .currentPage(pageNo) + .totalPages(totalPages) + .totalItems(total) + .pageSize(pageSize) + .first(pageNo == 1) + .last(pageNo >= totalPages) + .hasNext(pageNo < totalPages) + .hasPrevious(pageNo > 1) + .build(); } private static int toDisplayPageNumber(int zeroBasedPageNumber) { diff --git a/src/main/java/com/souzip/shared/config/AppConfig.java b/src/main/java/com/souzip/shared/config/AppConfig.java new file mode 100644 index 00000000..5db0dfbe --- /dev/null +++ b/src/main/java/com/souzip/shared/config/AppConfig.java @@ -0,0 +1,18 @@ +package com.souzip.shared.config; + +import com.souzip.adapter.config.ObjectStorageProperties; +import com.souzip.auth.adapter.security.jwt.JwtProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + JwtProperties.class, + ObjectStorageProperties.class, + ClovaStudioProperties.class, + AppleMigrationConfig.class, + CorsProperties.class, + MailSmtpProperties.class +}) +public class AppConfig { +} diff --git a/src/main/java/com/souzip/global/config/AppleMigrationConfig.java b/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java similarity index 77% rename from src/main/java/com/souzip/global/config/AppleMigrationConfig.java rename to src/main/java/com/souzip/shared/config/AppleMigrationConfig.java index c094b100..b92c2020 100644 --- a/src/main/java/com/souzip/global/config/AppleMigrationConfig.java +++ b/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -18,13 +18,13 @@ public class AppleMigrationConfig { @ConstructorBinding public AppleMigrationConfig( - String clientId, - String teamId, - String keyId, - String privateKey, - String oldTeamId, - String oldKeyId, - String oldPrivateKey + String clientId, + String teamId, + String keyId, + String privateKey, + String oldTeamId, + String oldKeyId, + String oldPrivateKey ) { this.clientId = clientId; this.teamId = teamId; diff --git a/src/main/java/com/souzip/global/config/ClovaStudioProperties.java b/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java similarity index 89% rename from src/main/java/com/souzip/global/config/ClovaStudioProperties.java rename to src/main/java/com/souzip/shared/config/ClovaStudioProperties.java index 8134e860..6611a072 100644 --- a/src/main/java/com/souzip/global/config/ClovaStudioProperties.java +++ b/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/souzip/global/config/CorsProperties.java b/src/main/java/com/souzip/shared/config/CorsProperties.java similarity index 91% rename from src/main/java/com/souzip/global/config/CorsProperties.java rename to src/main/java/com/souzip/shared/config/CorsProperties.java index e4b5cba0..ddaf55c9 100644 --- a/src/main/java/com/souzip/global/config/CorsProperties.java +++ b/src/main/java/com/souzip/shared/config/CorsProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/souzip/global/config/FirebaseConfig.java b/src/main/java/com/souzip/shared/config/FirebaseConfig.java similarity index 97% rename from src/main/java/com/souzip/global/config/FirebaseConfig.java rename to src/main/java/com/souzip/shared/config/FirebaseConfig.java index db9660c4..4eccec0d 100644 --- a/src/main/java/com/souzip/global/config/FirebaseConfig.java +++ b/src/main/java/com/souzip/shared/config/FirebaseConfig.java @@ -1,11 +1,13 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; + import java.io.FileInputStream; import java.io.IOException; + import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/souzip/global/config/FirebaseProperties.java b/src/main/java/com/souzip/shared/config/FirebaseProperties.java similarity index 89% rename from src/main/java/com/souzip/global/config/FirebaseProperties.java rename to src/main/java/com/souzip/shared/config/FirebaseProperties.java index 36dad57f..d5c57fa7 100644 --- a/src/main/java/com/souzip/global/config/FirebaseProperties.java +++ b/src/main/java/com/souzip/shared/config/FirebaseProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/souzip/global/config/FirebasePropertiesConfiguration.java b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java similarity index 88% rename from src/main/java/com/souzip/global/config/FirebasePropertiesConfiguration.java rename to src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java index 98d1ca82..53ce0cbd 100644 --- a/src/main/java/com/souzip/global/config/FirebasePropertiesConfiguration.java +++ b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/global/config/JpaAuditingConfig.java b/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java similarity index 85% rename from src/main/java/com/souzip/global/config/JpaAuditingConfig.java rename to src/main/java/com/souzip/shared/config/JpaAuditingConfig.java index 147284b8..2662ebc7 100644 --- a/src/main/java/com/souzip/global/config/JpaAuditingConfig.java +++ b/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/com/souzip/global/config/MailSmtpConfig.java b/src/main/java/com/souzip/shared/config/MailSmtpConfig.java similarity index 97% rename from src/main/java/com/souzip/global/config/MailSmtpConfig.java rename to src/main/java/com/souzip/shared/config/MailSmtpConfig.java index 06cda901..4a9de9d9 100644 --- a/src/main/java/com/souzip/global/config/MailSmtpConfig.java +++ b/src/main/java/com/souzip/shared/config/MailSmtpConfig.java @@ -1,6 +1,7 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import java.util.Properties; + import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/souzip/global/config/MailSmtpProperties.java b/src/main/java/com/souzip/shared/config/MailSmtpProperties.java similarity index 92% rename from src/main/java/com/souzip/global/config/MailSmtpProperties.java rename to src/main/java/com/souzip/shared/config/MailSmtpProperties.java index 15624067..5767db37 100644 --- a/src/main/java/com/souzip/global/config/MailSmtpProperties.java +++ b/src/main/java/com/souzip/shared/config/MailSmtpProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java b/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java similarity index 92% rename from src/main/java/com/souzip/global/config/PasswordEncoderConfig.java rename to src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java index 25774aef..47035212 100644 --- a/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java +++ b/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/global/config/QuerydslConfig.java b/src/main/java/com/souzip/shared/config/QuerydslConfig.java similarity index 93% rename from src/main/java/com/souzip/global/config/QuerydslConfig.java rename to src/main/java/com/souzip/shared/config/QuerydslConfig.java index 6eae15b0..0a894a51 100644 --- a/src/main/java/com/souzip/global/config/QuerydslConfig.java +++ b/src/main/java/com/souzip/shared/config/QuerydslConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; diff --git a/src/main/java/com/souzip/global/config/RestTemplateConfig.java b/src/main/java/com/souzip/shared/config/RestTemplateConfig.java similarity index 90% rename from src/main/java/com/souzip/global/config/RestTemplateConfig.java rename to src/main/java/com/souzip/shared/config/RestTemplateConfig.java index 6c17ff42..c8522ac9 100644 --- a/src/main/java/com/souzip/global/config/RestTemplateConfig.java +++ b/src/main/java/com/souzip/shared/config/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/global/config/SchedulerConfig.java b/src/main/java/com/souzip/shared/config/SchedulerConfig.java similarity index 85% rename from src/main/java/com/souzip/global/config/SchedulerConfig.java rename to src/main/java/com/souzip/shared/config/SchedulerConfig.java index ae48e2ab..6f8bd45a 100644 --- a/src/main/java/com/souzip/global/config/SchedulerConfig.java +++ b/src/main/java/com/souzip/shared/config/SchedulerConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/src/main/java/com/souzip/domain/shared/BaseEntity.java b/src/main/java/com/souzip/shared/domain/BaseEntity.java similarity index 68% rename from src/main/java/com/souzip/domain/shared/BaseEntity.java rename to src/main/java/com/souzip/shared/domain/BaseEntity.java index 3ee96c6f..7a64119d 100644 --- a/src/main/java/com/souzip/domain/shared/BaseEntity.java +++ b/src/main/java/com/souzip/shared/domain/BaseEntity.java @@ -1,17 +1,13 @@ -package com.souzip.domain.shared; +package com.souzip.shared.domain; -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; +import jakarta.persistence.*; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; + @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/souzip/domain/shared/Coordinate.java b/src/main/java/com/souzip/shared/domain/Coordinate.java similarity index 97% rename from src/main/java/com/souzip/domain/shared/Coordinate.java rename to src/main/java/com/souzip/shared/domain/Coordinate.java index a9be6135..75ebc318 100644 --- a/src/main/java/com/souzip/domain/shared/Coordinate.java +++ b/src/main/java/com/souzip/shared/domain/Coordinate.java @@ -1,4 +1,4 @@ -package com.souzip.domain.shared; +package com.souzip.shared.domain; import lombok.AccessLevel; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/souzip/shared/domain/Provider.java b/src/main/java/com/souzip/shared/domain/Provider.java new file mode 100644 index 00000000..16d7778b --- /dev/null +++ b/src/main/java/com/souzip/shared/domain/Provider.java @@ -0,0 +1,24 @@ +package com.souzip.shared.domain; + +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + +import java.util.Arrays; + +public enum Provider { + KAKAO, + APPLE, + GOOGLE; + + public static Provider from(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); + } + + return Arrays.stream(values()) + .filter(provider -> provider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException( + ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); + } +} diff --git a/src/main/java/com/souzip/global/exception/BaseErrorCode.java b/src/main/java/com/souzip/shared/exception/BaseErrorCode.java similarity index 79% rename from src/main/java/com/souzip/global/exception/BaseErrorCode.java rename to src/main/java/com/souzip/shared/exception/BaseErrorCode.java index b834c33f..35d168e6 100644 --- a/src/main/java/com/souzip/global/exception/BaseErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/BaseErrorCode.java @@ -1,9 +1,11 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import org.springframework.http.HttpStatus; public interface BaseErrorCode { HttpStatus getStatus(); + String getMessage(); + String name(); } diff --git a/src/main/java/com/souzip/global/exception/BusinessException.java b/src/main/java/com/souzip/shared/exception/BusinessException.java similarity index 91% rename from src/main/java/com/souzip/global/exception/BusinessException.java rename to src/main/java/com/souzip/shared/exception/BusinessException.java index 3ba7e2dc..587875cb 100644 --- a/src/main/java/com/souzip/global/exception/BusinessException.java +++ b/src/main/java/com/souzip/shared/exception/BusinessException.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import lombok.Getter; diff --git a/src/main/java/com/souzip/global/exception/ErrorCode.java b/src/main/java/com/souzip/shared/exception/ErrorCode.java similarity index 73% rename from src/main/java/com/souzip/global/exception/ErrorCode.java rename to src/main/java/com/souzip/shared/exception/ErrorCode.java index e3b35863..25db9642 100644 --- a/src/main/java/com/souzip/global/exception/ErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/ErrorCode.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -20,24 +20,10 @@ public enum ErrorCode implements BaseErrorCode { INVALID_PROFILE_IMAGE_COLOR(HttpStatus.BAD_REQUEST, "유효하지 않은 프로필 이미지 색상입니다."), NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), - INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), - INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), - TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), - TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), - - KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), - GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), - APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."), - COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 국가 정보를 찾을 수 없습니다."), COUNTRY_REGION_INVALID(HttpStatus.BAD_REQUEST, "국가의 지역 코드가 유효하지 않습니다."), - CITY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 도시 정보를 찾을 수 없습니다."), - CURRENCY_NOT_FOUND(HttpStatus.BAD_REQUEST, "통화를 찾을 수 없습니다."), - EXCHANGE_RATE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 환율 정보를 찾을 수 없습니다."), SOUVENIR_NOT_FOUND(HttpStatus.NOT_FOUND, "기념품을 찾을 수 없습니다."), @@ -63,15 +49,9 @@ public enum ErrorCode implements BaseErrorCode { AI_RECOMMENDATION_NOT_READY(HttpStatus.BAD_REQUEST, "추천 시스템을 위해 기념품 업로드 이력이 있어야 합니다."), - APPLE_MIGRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 마이그레이션 준비 중 오류가 발생했습니다."), - APPLE_TRANSFER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "App Transfer가 아직 완료되지 않았습니다."), - NO_APPLE_USERS_FOUND(HttpStatus.NOT_FOUND, "마이그레이션할 Apple 사용자가 없습니다."), - FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 푸시 전송에 실패했습니다."), - - EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다.") - ; + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다."); private final HttpStatus status; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java b/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java similarity index 96% rename from src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java rename to src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java index 43e9e57a..6423ef1c 100644 --- a/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java @@ -1,10 +1,12 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; + +import com.souzip.shared.aop.MdcTraceId; +import com.souzip.shared.common.dto.ErrorResponse; -import com.souzip.global.aop.MdcTraceId; -import com.souzip.global.common.dto.ErrorResponse; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; + import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; diff --git a/src/main/java/com/souzip/global/util/HttpRequestUtils.java b/src/main/java/com/souzip/shared/util/HttpRequestUtils.java similarity index 90% rename from src/main/java/com/souzip/global/util/HttpRequestUtils.java rename to src/main/java/com/souzip/shared/util/HttpRequestUtils.java index 040ed251..f590ae7f 100644 --- a/src/main/java/com/souzip/global/util/HttpRequestUtils.java +++ b/src/main/java/com/souzip/shared/util/HttpRequestUtils.java @@ -1,4 +1,4 @@ -package com.souzip.global.util; +package com.souzip.shared.util; import jakarta.servlet.http.HttpServletRequest; import lombok.AccessLevel; @@ -21,7 +21,7 @@ public class HttpRequestUtils { private static final String UNKNOWN_IP = "unknown"; private static final Pattern APP_VERSION_PATTERN = - Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); + Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); public static String extractClientIp(HttpServletRequest request) { String ip = getFirstValidIp(request); @@ -44,14 +44,14 @@ public static String extractAppVersion(HttpServletRequest request) { private static String getFirstValidIp(HttpServletRequest request) { return Arrays.stream(new String[]{ - request.getHeader(HEADER_X_FORWARDED_FOR), - request.getHeader(HEADER_X_REAL_IP), - request.getHeader(HEADER_PROXY_CLIENT_IP), - request.getRemoteAddr() - }) - .filter(HttpRequestUtils::isValidIp) - .findFirst() - .orElse(UNKNOWN); + request.getHeader(HEADER_X_FORWARDED_FOR), + request.getHeader(HEADER_X_REAL_IP), + request.getHeader(HEADER_PROXY_CLIENT_IP), + request.getRemoteAddr() + }) + .filter(HttpRequestUtils::isValidIp) + .findFirst() + .orElse(UNKNOWN); } private static String parseVersionFromUserAgent(HttpServletRequest request) { diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index d670689c..a690286c 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -1,193 +1,211 @@ - FIELD + FIELD - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - name - address - -
- - - - - - - - - -
+ + + + +
+ + + + + + + + + + + +
- - - - entity_type - entity_id - display_order - -
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + name + address + +
+ + + + + + + + + +
- - - - - - - - - - - - - - - STRING - - - + +
+ + entity_type + entity_id + display_order + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
- - - - token - - - user_id - device_id - -
- - - - - - - - - - STRING - - - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + STRING + + + - -
- - - - - - - - - - - - - - - - - - - - - - - - + +
+ + token + + + user_id + device_id + +
+ + + + + + + + + + STRING + + + + + + + + + + + + + + + + + + + + +
- - - - user_id - souvenir_id - -
- - - - - - - - - - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + +
+ + user_id + souvenir_id + +
+ + + + + + + + + + +
+ + + + + + + + + + +
diff --git a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java index 82f1efe1..360434ad 100644 --- a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java +++ b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java @@ -2,7 +2,7 @@ import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse; import com.souzip.application.location.dto.AddressResult; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java index 85a0c4b8..0209ec4b 100644 --- a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java +++ b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java @@ -4,7 +4,7 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java index f9dd32d0..214f4282 100644 --- a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java @@ -4,7 +4,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java index a6b536e2..cc635b27 100644 --- a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java @@ -9,7 +9,7 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java index 9bcbe87f..8a487cba 100644 --- a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java +++ b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java @@ -1,17 +1,15 @@ package com.souzip.application.location; import com.souzip.application.location.dto.CitySearchResult; -import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.PlaceSearchResult; +import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.SearchResult; import com.souzip.application.location.required.CitySearchRepository; import com.souzip.application.location.required.PlaceSearchProvider; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.entity.Region; -import com.souzip.domain.shared.Coordinate; -import java.math.BigDecimal; -import java.util.List; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +21,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; +import java.math.BigDecimal; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java index 9a8923df..613cb329 100644 --- a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java +++ b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java @@ -2,7 +2,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.required.AddressProvider; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/auth/controller/AuthApiTest.java b/src/test/java/com/souzip/auth/controller/AuthApiTest.java new file mode 100644 index 00000000..b8abd2dc --- /dev/null +++ b/src/test/java/com/souzip/auth/controller/AuthApiTest.java @@ -0,0 +1,372 @@ +package com.souzip.auth.controller; + +import com.souzip.auth.adapter.web.AuthApi; +import com.souzip.auth.adapter.web.dto.LoginRequest; +import com.souzip.auth.adapter.web.dto.RefreshRequest; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.provided.Auth; +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.shared.domain.Provider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +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.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +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.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +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 AuthApiTest extends RestDocsSupport { + + private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; + + private final Auth auth = mock(Auth.class); + + @Override + protected Object initController() { + return new AuthApi(auth); + } + + private LoginInfo createLoginInfo(boolean needsOnboarding) { + return new LoginInfo( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + needsOnboarding, + "20251204", + "수집" + ); + } + + private static org.springframework.restdocs.payload.FieldDescriptor[] loginResponseFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + 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.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + }; + } + + @Test + @DisplayName("카카오 로그인 - 신규 사용자") + void loginWithKakao_newUser() throws Exception { + LoginRequest request = new LoginRequest("kakao_access_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(jsonPath("$.data.refreshToken").exists()) + .andExpect(jsonPath("$.data.user.userId").value("20251204")) + .andExpect(jsonPath("$.data.user.nickname").value("수집")) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-kakao-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("카카오 로그인 - 기존 사용자") + void loginWithKakao_existingUser() throws Exception { + LoginRequest request = new LoginRequest("kakao_access_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-kakao-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("카카오 로그인 - 카카오 API 에러") + void loginWithKakao_kakaoApiError() throws Exception { + LoginRequest request = new LoginRequest("invalid_kakao_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willThrow(AuthException.kakaoApiError()); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) + .andDo(document("auth/login-kakao-api-error", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("유효하지 않은 카카오 Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("Google 로그인 - 신규 사용자") + void loginWithGoogle_newUser() throws Exception { + LoginRequest request = new LoginRequest("google_access_token"); + given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-google-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Google 로그인 - 기존 사용자") + void loginWithGoogle_existingUser() throws Exception { + LoginRequest request = new LoginRequest("google_access_token"); + given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-google-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Apple 로그인 - 신규 사용자") + void loginWithApple_newUser() throws Exception { + LoginRequest request = new LoginRequest("apple_id_token"); + given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-apple-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Apple 로그인 - 기존 사용자") + void loginWithApple_existingUser() throws Exception { + LoginRequest request = new LoginRequest("apple_id_token"); + given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-apple-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("지원하지 않는 Provider - 400 에러") + void loginWithUnsupportedProvider_returns400() throws Exception { + LoginRequest request = new LoginRequest("naver_access_token"); + given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); + + mockMvc.perform(post("/api/auth/login/{provider}", "naver") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andDo(document("auth/login-unsupported-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("잘못된 Provider 형식 - 400 에러") + void loginWithInvalidProvider_returns400() throws Exception { + LoginRequest request = new LoginRequest("invalid_token"); + given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); + + mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andDo(document("auth/login-invalid-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("Refresh Token 재발급") + void refresh_withValidToken() throws Exception { + RefreshRequest request = new RefreshRequest("valid_refresh_token"); + given(auth.refresh(anyString())).willReturn( + new RefreshInfo("new_access_token", "valid_refresh_token") + ); + + mockMvc.perform(post("/api/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("auth/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("새 Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Refresh Token 만료 임박 - 둘 다 재발급") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + RefreshRequest request = new RefreshRequest("expiring_soon_token"); + given(auth.refresh(anyString())).willReturn( + new RefreshInfo("new_access_token", "new_refresh_token") + ); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.refreshToken").value("new_refresh_token")) + .andDo(document("auth/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("새 Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새 Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 에러") + void refresh_withExpiredToken_returns401() throws Exception { + RefreshRequest request = new RefreshRequest("expired_refresh_token"); + given(auth.refresh(anyString())).willThrow(AuthException.expiredRefreshToken()); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) + .andDo(document("auth/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 { + RefreshRequest request = new RefreshRequest("invalid_refresh_token"); + given(auth.refresh(anyString())).willThrow(AuthException.invalidRefreshToken()); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) + .andDo(document("auth/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("유효하지 않은 Refresh Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("로그아웃 성공") + void logout_success() throws Exception { + doNothing().when(auth).logout(anyLong()); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) + .andDo(document("auth/logout", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java b/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java similarity index 77% rename from src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java rename to src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java index e57e548f..e9d0fa4c 100644 --- a/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java +++ b/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java @@ -1,6 +1,7 @@ -package com.souzip.domain.auth.scheduler; +package com.souzip.auth.scheduler; -import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.auth.adapter.integration.scheduler.RefreshTokenCleanupScheduler; +import com.souzip.auth.application.required.RefreshTokenRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,8 +12,8 @@ import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class RefreshTokenCleanupSchedulerTest { @@ -26,14 +27,11 @@ class RefreshTokenCleanupSchedulerTest { @Test @DisplayName("만료된 Refresh Token을 삭제한다.") void cleanupExpiredTokens() { - // given - LocalDateTime now = LocalDateTime.now(); - // when scheduler.cleanUpExpiredRefreshTokens(); // then verify(refreshTokenRepository, times(1)) - .deleteAllByExpiresAtBefore(any(LocalDateTime.class)); + .deleteAllExpiredBefore(any(LocalDateTime.class)); } -} +} \ No newline at end of file diff --git a/src/test/java/com/souzip/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..1be66e0f --- /dev/null +++ b/src/test/java/com/souzip/auth/service/AuthServiceTest.java @@ -0,0 +1,168 @@ +package com.souzip.auth.service; + +import com.souzip.auth.application.AuthService; +import com.souzip.auth.application.SocialClientFactory; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.auth.application.required.TokenProvider; +import com.souzip.auth.application.required.UserPort; +import com.souzip.auth.domain.RefreshToken; +import com.souzip.shared.domain.Provider; +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 java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private SocialClientFactory socialClientFactory; + + @Mock + private UserPort userPort; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private TokenProvider tokenProvider; + + @Mock + private SocialClient socialClient; + + @InjectMocks + private AuthService authService; + + private static final Long USER_ID = 1L; + private static final String USER_STRING_ID = "550e8400"; + private static final String NICKNAME = "수집"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + private SocialUserInfo createSocialUserInfo() { + return new SocialUserInfo("kakao123", "test@gmail.com"); + } + + private UserInfo createUserInfo(boolean needsOnboarding) { + return new UserInfo(USER_ID, USER_STRING_ID, NICKNAME, needsOnboarding); + } + + @Test + @DisplayName("신규 사용자 로그인 시 토큰을 발급한다.") + void login_newUser_issuesTokens() { + // given + given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); + given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); + given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) + .willReturn(createUserInfo(true)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); + given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(result.accessToken()).isEqualTo(ACCESS_TOKEN); + assertThat(result.refreshToken()).isEqualTo(REFRESH_TOKEN); + assertThat(result.needsOnboarding()).isTrue(); + assertThat(result.userId()).isEqualTo(USER_STRING_ID); + assertThat(result.nickname()).isEqualTo(NICKNAME); + + verify(refreshTokenRepository).deleteByUserId(USER_ID); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("기존 사용자 로그인 시 needsOnboarding은 false다.") + void login_existingUser_needsOnboardingFalse() { + // given + given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); + given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); + given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) + .willReturn(createUserInfo(false)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); + given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(result.needsOnboarding()).isFalse(); + } + + @Test + @DisplayName("유효한 Refresh Token으로 재발급 시 새 Access Token을 발급한다.") + void refresh_withValidToken_returnsNewAccessToken() { + // given + RefreshToken storedToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().plusDays(20)); + + given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(storedToken)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn("new_access_token"); + given(tokenProvider.generateRefreshToken()).willReturn("new_refresh_token"); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + RefreshInfo result = authService.refresh(REFRESH_TOKEN); + + // then + assertThat(result.accessToken()).isEqualTo("new_access_token"); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 예외가 발생한다.") + void refresh_withExpiredToken_throwsException() { + // given + RefreshToken expiredToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().minusDays(1)); + + given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(expiredToken)); + + // when & then + assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN)) + .isInstanceOf(AuthException.class) + .hasMessageContaining("만료된 Refresh Token입니다."); + + verify(refreshTokenRepository).delete(expiredToken); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 예외가 발생한다.") + void refresh_withInvalidToken_throwsException() { + // given + given(refreshTokenRepository.findByToken("invalid_token")).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.refresh("invalid_token")) + .isInstanceOf(AuthException.class) + .hasMessageContaining("유효하지 않은 Refresh Token입니다."); + } + + @Test + @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") + void logout_success() { + // when + authService.logout(USER_ID); + + // then + verify(refreshTokenRepository).deleteByUserId(USER_ID); + } +} \ 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 52646a1b..ecd31b9e 100644 --- a/src/test/java/com/souzip/docs/RestDocsSupport.java +++ b/src/test/java/com/souzip/docs/RestDocsSupport.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.global.exception.GlobalExceptionHandler; +import com.souzip.shared.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.core.MethodParameter; diff --git a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java index 251c0d06..d9d12ca7 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java @@ -1,26 +1,22 @@ package com.souzip.domain.admin.application; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; 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.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.*; 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.time.LocalDateTime; import java.util.Optional; import java.util.UUID; @@ -54,7 +50,7 @@ void login_success() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "password123"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); given(passwordEncoder.matches(anyString(), anyString())).willReturn(true); @@ -83,7 +79,7 @@ void login_fail_not_found() { // when & then assertThatThrownBy(() -> adminAuthService.login(command)) - .isInstanceOf(AdminNotFoundException.class); + .isInstanceOf(AdminNotFoundException.class); } @DisplayName("비밀번호 불일치 시 예외가 발생한다.") @@ -92,14 +88,14 @@ void login_fail_password_mismatch() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "wrongpassword"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + 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); + .isInstanceOf(AdminLoginFailedException.class); } @DisplayName("리프레시 토큰 갱신에 성공한다.") @@ -108,7 +104,7 @@ void refresh_success() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); @@ -132,7 +128,7 @@ void refresh_fail_invalid_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("invalid-token")) - .isInstanceOf(AdminInvalidRefreshTokenException.class); + .isInstanceOf(AdminInvalidRefreshTokenException.class); } @DisplayName("만료된 리프레시 토큰으로 갱신 시 예외가 발생한다.") @@ -147,7 +143,7 @@ void refresh_fail_expired_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("refresh-token")) - .isInstanceOf(AdminExpiredRefreshTokenException.class); + .isInstanceOf(AdminExpiredRefreshTokenException.class); verify(refreshTokenRepository, times(1)).delete(refreshToken); } @@ -158,7 +154,7 @@ void refresh_renew_expiring_token() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(5); // 10일 미만 AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "old-refresh-token", expiresAt); diff --git a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java index f1953947..1315ee8f 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java @@ -4,9 +4,11 @@ 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 com.souzip.shared.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; 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 index 23208d4e..aaec602e 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java @@ -2,7 +2,7 @@ import com.souzip.domain.admin.model.AdminRefreshToken; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; -import com.souzip.global.config.QuerydslConfig; +import com.souzip.shared.config.QuerydslConfig; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +12,7 @@ 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; 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 index b89dacef..d1ef86ee 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java @@ -6,9 +6,11 @@ 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 com.souzip.shared.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; diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java new file mode 100644 index 00000000..47beb932 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java @@ -0,0 +1,244 @@ +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.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.payload.PayloadDocumentation.*; +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 AdminAuthApiTest 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/AdminAuthControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java deleted file mode 100644 index fdd1809b..00000000 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java +++ /dev/null @@ -1,242 +0,0 @@ -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 index 7fd3aba3..2934390c 100644 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java @@ -19,11 +19,13 @@ 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 com.souzip.shared.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; diff --git a/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java deleted file mode 100644 index 6834d1b2..00000000 --- a/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java +++ /dev/null @@ -1,593 +0,0 @@ -package com.souzip.domain.auth.controller; - -import com.souzip.docs.CommonDocumentation; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.auth.dto.LoginRequest; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; -import com.souzip.domain.auth.dto.RefreshRequest; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.service.AuthService; -import com.souzip.domain.user.entity.Provider; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - -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.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -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.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -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 AuthControllerTest extends RestDocsSupport { - - private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; - - private final AuthService authService = mock(AuthService.class); - - @Override - protected Object initController() { - return new AuthController(authService); - } - - @Test - @DisplayName("카카오 로그인 - 신규 사용자") - void loginWithKakao_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").exists()) - .andExpect(jsonPath("$.data.refreshToken").exists()) - .andExpect(jsonPath("$.data.user.userId").value("20251204")) - .andExpect(jsonPath("$.data.user.nickname").value("수집")) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-kakao-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("카카오 Access 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID 앞 8자리)"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) - .description("온보딩 필요 여부 (true = 온보딩 필요, false = 완료)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("카카오 로그인 - 기존 사용자") - void loginWithKakao_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-kakao-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("카카오 Access 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) - .description("온보딩 필요 여부 (false = 온보딩 완료, true = 필요)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("카카오 로그인 - 카카오 API 에러") - void loginWithKakao_kakaoApiError() throws Exception { - // given - LoginRequest request = new LoginRequest("invalid_kakao_token"); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willThrow(new BusinessException(ErrorCode.KAKAO_API_ERROR)); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) - .andDo(document("auth/login-kakao-api-error", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("유효하지 않은 카카오 Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("Google 로그인 - 신규 사용자") - void loginWithGoogle_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("google_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.GOOGLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-google-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("구글 Access 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Google 로그인 - 기존 사용자") - void loginWithGoogle_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("google_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.GOOGLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-google-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("구글 Access 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Apple 로그인 - 신규 사용자") - void loginWithApple_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("apple_id_token"); - - LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.APPLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-apple-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("애플 ID 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Apple 로그인 - 기존 사용자") - void loginWithApple_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("apple_id_token"); - - LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.APPLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-apple-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("애플 ID 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("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("지원하지 않는 Provider - 400 에러") - void loginWithUnsupportedProvider_returns400() throws Exception { - // given - LoginRequest request = new LoginRequest("naver_access_token"); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "naver") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("지원하지 않는 로그인 방식입니다: naver")) - .andDo(document("auth/login-unsupported-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("OAuth Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("잘못된 Provider 형식 - 400 에러") - void loginWithInvalidProvider_returns400() throws Exception { - // given - LoginRequest request = new LoginRequest("invalid_token"); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(document("auth/login-invalid-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("OAuth Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("대소문자 구분 없이 Provider 처리") - void loginWithCaseInsensitiveProvider_success() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "access_token", - "refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "KAKAO") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").exists()); - // NOTE: 이 테스트는 “동작 검증”용으로 남기고 문서화는 생략(실무에서도 보통 이렇게 함) - } - - @Test - @DisplayName("Refresh Token 재발급 - Access Token만") - void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { - // given - RefreshRequest request = new RefreshRequest("valid_refresh_token"); - RefreshResponse response = RefreshResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", - "valid_refresh_token" - ); - - given(authService.refresh(anyString())).willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token")) - .andExpect(jsonPath("$.data.refreshToken").value("valid_refresh_token")) - .andDo(document("auth/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("Refresh Token 만료 임박 - 둘 다 재발급") - void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { - // given - RefreshRequest request = new RefreshRequest("expiring_soon_token"); - RefreshResponse response = RefreshResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token" - ); - - given(authService.refresh(anyString())).willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.refreshToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token")) - .andDo(document("auth/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 - RefreshRequest request = new RefreshRequest("expired_refresh_token"); - given(authService.refresh(anyString())) - .willThrow(new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN)); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) - .andDo(document("auth/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 - RefreshRequest request = new RefreshRequest("invalid_refresh_token"); - given(authService.refresh(anyString())) - .willThrow(new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) - .andDo(document("auth/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 - doNothing().when(authService).logout(anyLong()); - - // when & then - mockMvc.perform(post("/api/auth/logout") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) - .andDo(document("auth/logout", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); - } -} diff --git a/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java deleted file mode 100644 index 2934b0ff..00000000 --- a/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,459 +0,0 @@ -package com.souzip.domain.auth.service; - -import com.souzip.domain.auth.client.OAuthClient; -import com.souzip.domain.auth.client.OAuthClientFactory; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.security.jwt.JwtTokenProvider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; - -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @Mock - private OAuthClientFactory oauthClientFactory; - - @Mock - private UserRepository userRepository; - - @Mock - private RefreshTokenRepository refreshTokenRepository; - - @Mock - private JwtTokenProvider jwtTokenProvider; - - @Mock - private OAuthClient oauthClient; - - @InjectMocks - private AuthService authService; - - private OAuthUserInfo createOAuthUserInfo() { - return new OAuthUserInfo() { - @Override - public String getProviderId() { - return "kakao123"; - } - - @Override - public String getName() { - return "수집"; - } - - @Override - public String getEmail() { - return "test@gmail.com"; - } - }; - } - - @Test - @DisplayName("신규 사용자 로그인 시 User를 생성하고 토큰을 발급한다.") - void login_newUser_createsUserAndIssuesTokens() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User newUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(newUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willReturn(spyUser); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.getUser().nickname()).isEmpty(); - assertThat(response.isNeedsOnboarding()).isTrue(); - - verify(spyUser).updateLastLoginAt(); - - verify(userRepository).save(any(User.class)); - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("온보딩 완료한 기존 사용자 로그인 시 needsOnboarding은 false다.") - void login_existingUserWithOnboardingCompleted_needsOnboardingFalse() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(false); - - RefreshToken existingToken = RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - ); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.isNeedsOnboarding()).isFalse(); // ← 온보딩 완료! - - verify(userRepository, never()).save(any(User.class)); - verify(spyUser, never()).restore(anyString()); - } - - @Test - @DisplayName("온보딩 안 한 기존 사용자 로그인 시 needsOnboarding은 true다.") - void login_existingUserWithoutOnboarding_needsOnboardingTrue() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - RefreshToken existingToken = RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - ); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.isNeedsOnboarding()).isTrue(); - - verify(userRepository, never()).save(any(User.class)); - verify(spyUser, never()).restore(anyString()); - } - - @Test - @DisplayName("탈퇴 회원 재로그인 시 User를 복구하고 needsOnboarding은 true다.") - void login_withdrawnUser_restoresUserAndNeedsOnboardingTrue() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User withdrawnUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(withdrawnUser); - - given(spyUser.isDeleted()).willReturn(true); - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - doCallRealMethod().when(spyUser).restore(anyString()); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(spyUser).restore("수집"); - assertThat(response.isNeedsOnboarding()).isTrue(); - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("Refresh Token이 없으면 새로 생성한다.") - void login_withoutRefreshToken_createsNewToken() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User newUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(newUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willReturn(spyUser); - given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("Refresh Token이 있으면 업데이트한다.") - void login_withExistingRefreshToken_updatesToken() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(false); - - RefreshToken existingToken = spy(RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - )); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("new_refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(existingToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("Refresh Token이 유효하고 만료 임박하지 않으면 Access Token만 재발급한다.") - void refresh_withValidToken_returnsNewAccessTokenOnly() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "valid_refresh_token", - LocalDateTime.now().plusDays(20) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(20)); - - given(refreshTokenRepository.findByToken("valid_refresh_token")) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - - // when - RefreshResponse response = authService.refresh("valid_refresh_token"); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - assertThat(response.refreshToken()).isEqualTo("valid_refresh_token"); - verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); - } - - @Test - @DisplayName("Refresh Token이 10일 이하로 남으면 둘 다 재발급한다.") - void refresh_withExpiringSoon_returnsBothNewTokens() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "expiring_token", - LocalDateTime.now().plusDays(5) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(5)); - - given(refreshTokenRepository.findByToken("expiring_token")) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")) - .willReturn("new_refresh_token"); - - // when - RefreshResponse response = authService.refresh("expiring_token"); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); - verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - } - - @Test - @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 에러가 발생한다.") - void refresh_withExpiredToken_deletesTokenAndThrowsException() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - - RefreshToken expiredToken = RefreshToken.of( - user, - "expired_token", - LocalDateTime.now().minusDays(1) - ); - RefreshToken spyToken = spy(expiredToken); - given(spyToken.isExpired()).willReturn(true); - - given(refreshTokenRepository.findByToken("expired_token")) - .willReturn(Optional.of(spyToken)); - - // when & then - assertThatThrownBy(() -> authService.refresh("expired_token")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("만료된 Refresh Token입니다."); - - verify(refreshTokenRepository).delete(spyToken); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 에러가 발생한다.") - void refresh_withInvalidToken_throwsException() { - // given - given(refreshTokenRepository.findByToken("invalid_token")) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> authService.refresh("invalid_token")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("유효하지 않은 Refresh Token입니다."); - } - - @ParameterizedTest - @DisplayName("Refresh Token 만료 임계값 경계 조건 테스트") - @CsvSource({ - "11, false, 11일 남음 - 갱신 안 함", - "10, true, 정확히 10일 남음 - 갱신", - "9, true, 9일 남음 - 갱신" - }) - void refresh_boundaryCondition_test(int daysRemaining, boolean shouldRenew, String description) { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - String tokenValue = "token_" + daysRemaining + "_days"; - RefreshToken refreshToken = RefreshToken.of( - spyUser, - tokenValue, - LocalDateTime.now().plusDays(daysRemaining) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(daysRemaining)); - - given(refreshTokenRepository.findByToken(tokenValue)) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - - if (shouldRenew) { - given(jwtTokenProvider.generateRefreshToken("550e8400")) - .willReturn("new_refresh_token"); - } - - // when - RefreshResponse response = authService.refresh(tokenValue); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - - if (shouldRenew) { - assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); - verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - } else { - assertThat(response.refreshToken()).isEqualTo(tokenValue); - verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); - verify(spyToken, never()).updateToken(anyString(), any(LocalDateTime.class)); - } - } - - @Test - @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") - void logout_success() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "refresh_token", - LocalDateTime.now().plusDays(30) - ); - - given(userRepository.findById(1L)) - .willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)) - .willReturn(Optional.of(refreshToken)); - - // when - authService.logout(1L); - - // then - verify(refreshTokenRepository).delete(refreshToken); - } - - @Test - @DisplayName("존재하지 않는 사용자로 로그아웃 시 에러가 발생한다.") - void logout_withNotExistUser_throwsException() { - // given - given(userRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> authService.logout(999L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); - } -} diff --git a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java index 9d5dd681..a5a967cb 100644 --- a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java +++ b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java @@ -8,9 +8,11 @@ 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.shared.exception.BusinessException; + import java.math.BigDecimal; import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java index d9112cf6..5412cd5b 100644 --- a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java +++ b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java @@ -8,11 +8,13 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.math.BigDecimal; import java.util.List; import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,15 +43,15 @@ class CountryServiceTest { private Country createCountry(String nameEn, String nameKr, String code, Currency currency) { return Country.of( - nameEn, - nameKr, - code, - "Seoul", - Region.ASIA, - "https://flagcdn.com/w320/kr.png", - new BigDecimal("37.0"), - new BigDecimal("127.0"), - currency + nameEn, + nameKr, + code, + "Seoul", + Region.ASIA, + "https://flagcdn.com/w320/kr.png", + new BigDecimal("37.0"), + new BigDecimal("127.0"), + currency ); } @@ -74,8 +76,8 @@ void getAllCountries() { // then assertThat(response.countries()).hasSize(2); assertThat(response.countries()) - .extracting(CountryResponseDto::code) - .containsExactly("KR", "JP"); + .extracting(CountryResponseDto::code) + .containsExactly("KR", "JP"); } @Test @@ -104,7 +106,7 @@ void getCountryByCode_throwsException_whenCountryNotFound() { // when & then assertThatThrownBy(() -> countryService.getCountryByCode("INVALID")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); } } diff --git a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java index b79e5541..a1ffa896 100644 --- a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java +++ b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java @@ -4,8 +4,8 @@ import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.country.entity.Country; import com.souzip.domain.currency.entity.Currency; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; diff --git a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java index 6e2f5963..11cfe65e 100644 --- a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java @@ -1,9 +1,9 @@ package com.souzip.domain.recommend.ai.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; -import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,12 +29,13 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class AiRecommendationControllerTest extends RestDocsSupport { private final AiRecommendationService aiRecommendationService = - org.mockito.Mockito.mock(AiRecommendationService.class); + org.mockito.Mockito.mock(AiRecommendationService.class); @Override protected Object initController() { @@ -44,138 +45,136 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - return 1L; - } - }) - .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return 1L; + } + }) + .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) + .build(); } @Test @DisplayName("사용자 선호 카테고리 기반 AI 추천 기념품 조회") void getAiCategoryRecommendations() throws Exception { - // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg") + AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg", 3L, true), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg", 4L, false), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg", 0L, false) ); given(aiRecommendationService.getCategoryRecommendationsForUser(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-category") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) - .andDo(document("recommend/ai/category", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) + .andDo(document("recommend/ai/category", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품 기반 AI 추천 기념품 조회") void getAiRecentUploadRecommendations() throws Exception { - // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg") + AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg", 3L, true), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg", 0L, false) ); given(aiRecommendationService.getRecentSouvenirRecommendations(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) - .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) - .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) - .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) - .andDo(document("recommend/ai/recent-upload", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) + .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) + .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) + .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) + .andDo(document("recommend/ai/recent-upload", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품이 없으면 AI 추천은 빈 배열을 반환한다") void getAiRecentUploadRecommendations_empty() throws Exception { - // given given(aiRecommendationService.getRecentSouvenirRecommendations(1L)) - .willReturn(new AiRecommendationResponse(Collections.emptyList())); + .willReturn(new AiRecommendationResponse(Collections.emptyList())); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs").isArray()) - .andExpect(jsonPath("$.data.souvenirs").isEmpty()) - .andDo(document("recommend/ai/recent-upload-empty", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) - .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs").isArray()) + .andExpect(jsonPath("$.data.souvenirs").isEmpty()) + .andDo(document("recommend/ai/recent-upload-empty", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) + .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java index f10780ed..55f3b901 100644 --- a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java @@ -19,6 +19,8 @@ import static com.souzip.docs.CommonDocumentation.apiResponseFields; import static org.mockito.BDDMockito.given; 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.get; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -40,211 +42,224 @@ protected Object initController() { @DisplayName("카테고리별 추천 기념품 Top10 조회") @Test void getCategoryTop10() throws Exception { - // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK")) - .willReturn(responseList); - - // when & then + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/thumb" + i + ".jpg", + 3L, + null + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK", null)) + .willReturn(responseList); + mockMvc.perform(get("/api/discovery/general/category/{categoryName}", "FOOD_SNACK")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andDo(document("recommend/category-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andDo(document("recommend/category-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") + ), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("나라별 추천 기념품 Top10 조회") @Test void getCountryTop10() throws Exception { - // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "KR", - "https://example.com/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCountry("KR")) - .willReturn(responseList); - - // when & then + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "KR", + "https://example.com/thumb" + i + ".jpg", + 2L, + null + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCountry("KR", null)) + .willReturn(responseList); + mockMvc.perform(get("/api/discovery/general/country/{countryCode}", "KR")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andExpect(jsonPath("$.data[0].name").value("기념품 1")) - .andDo(document("recommend/country-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].name").value("기념품 1")) + .andDo(document("recommend/country-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") + ), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top10 조회(통계만)") @Test void getTopCountriesTop10() throws Exception { - // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L), - new GeneralRecommendationStatsDto("HK", "홍콩", 8L), - new GeneralRecommendationStatsDto("VN", "베트남", 4L), - new GeneralRecommendationStatsDto("KR", "한국", 4L), - new GeneralRecommendationStatsDto("DE", "독일", 4L), - new GeneralRecommendationStatsDto("FR", "프랑스", 4L), - new GeneralRecommendationStatsDto("CN", "중국", 3L), - new GeneralRecommendationStatsDto("PH", "필리핀", 3L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L), + new GeneralRecommendationStatsDto("HK", "홍콩", 8L), + new GeneralRecommendationStatsDto("VN", "베트남", 4L), + new GeneralRecommendationStatsDto("KR", "한국", 4L), + new GeneralRecommendationStatsDto("DE", "독일", 4L), + new GeneralRecommendationStatsDto("FR", "프랑스", 4L), + new GeneralRecommendationStatsDto("CN", "중국", 3L), + new GeneralRecommendationStatsDto("PH", "필리핀", 3L) ); given(generalRecommendationService.getTop10CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); - // when & then mockMvc.perform(get("/api/discovery/general/countries/top10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(10)) - .andDo(document("recommend/stats-top10", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(10)) + .andDo(document("recommend/stats-top10", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top3 조회(통계만)") @Test void getTopCountriesTop3() throws Exception { - // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L) ); given(generalRecommendationService.getTop3CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); - // when & then mockMvc.perform(get("/api/discovery/general/stats")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(3)) - .andDo(document("recommend/stats-top3", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(3)) + .andDo(document("recommend/stats-top3", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 최다등록 Top10 국가 + 국가별 기념품 Top10 조회") @Test void getTopCountriesWithTop10Souvenirs() throws Exception { - // given List jpSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 200L + i, - "일본 기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/jp/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 200L + i, + "일본 기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/jp/thumb" + i + ".jpg", + 5L, + null + )) + .collect(Collectors.toList()); List usSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 300L + i, - "미국 기념품 " + i, - Category.SOUVENIR_BASIC, - "US", - "https://example.com/us/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 300L + i, + "미국 기념품 " + i, + Category.SOUVENIR_BASIC, + "US", + "https://example.com/us/thumb" + i + ".jpg", + 2L, + null + )) + .collect(Collectors.toList()); List response = List.of( - new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), - new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) + new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), + new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) ); - given(generalRecommendationService.getTopCountriesWithTop10Souvenirs()) - .willReturn(response); + given(generalRecommendationService.getTopCountriesWithTop10Souvenirs(null)) + .willReturn(response); - // when & then mockMvc.perform(get("/api/countries/souvenirs")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andDo(document("recommend/countries-top10-souvenirs-top10", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), - fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andDo(document("recommend/countries-top10-souvenirs-top10", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), + fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java index 56554ed1..a6045114 100644 --- a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java +++ b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java @@ -3,10 +3,10 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/domain/shared/CoordinateTest.java b/src/test/java/com/souzip/domain/shared/CoordinateTest.java index 14938ea0..837c5565 100644 --- a/src/test/java/com/souzip/domain/shared/CoordinateTest.java +++ b/src/test/java/com/souzip/domain/shared/CoordinateTest.java @@ -1,10 +1,12 @@ package com.souzip.domain.shared; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.Test; import java.math.BigDecimal; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class CoordinateTest { diff --git a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java index 399a477f..5d1d5d46 100644 --- a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java +++ b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java @@ -1,19 +1,12 @@ package com.souzip.domain.souvenir.controller; +import com.souzip.application.file.dto.FileResponse; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.category.entity.Category; -import com.souzip.application.file.dto.FileResponse; -import com.souzip.domain.souvenir.dto.PriceResponse; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.entity.Purpose; import com.souzip.domain.souvenir.service.SouvenirService; -import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,15 +35,12 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.partWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.restdocs.request.RequestDocumentation.*; 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.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class SouvenirControllerTest extends RestDocsSupport { @@ -64,26 +54,26 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory - ) { - return 1L; - } - }) - .apply(documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return 1L; + } + }) + .apply(documentationConfiguration(provider)) + .build(); } @DisplayName("근처 기념품 조회") @@ -94,58 +84,64 @@ void getNearbySouvenirs() throws Exception { int radiusMeter = 4000; List nearbySouvenirs = List.of( - SouvenirNearbyResponse.from( - 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, - 10000, 120000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", - new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), - "Some address A" - ), - SouvenirNearbyResponse.from( - 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, - 20000, 240000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", - new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), - "Some address B" - ) + SouvenirNearbyResponse.from( + 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, + 10000, 120000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", + new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), + "Some address A", + 5L, + null + ), + SouvenirNearbyResponse.from( + 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, + 20000, 240000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", + new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), + "Some address B", + 3L, + null + ) ); - given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter)) - .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); + given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter, null)) + .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); mockMvc.perform(get("/api/souvenirs/nearby") - .param("latitude", String.valueOf(userLatitude)) - .param("longitude", String.valueOf(userLongitude)) - .param("radiusMeter", String.valueOf(radiusMeter))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) - .andDo(document("souvenirs/get-nearby-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), - parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), - parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), - fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), - fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), - fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .param("latitude", String.valueOf(userLatitude)) + .param("longitude", String.valueOf(userLongitude)) + .param("radiusMeter", String.valueOf(radiusMeter))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) + .andDo(document("souvenirs/get-nearby-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), + parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), + parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), + fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), + fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), + fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 조회") @@ -155,198 +151,202 @@ void getSouvenir() throws Exception { Long souvenirId = 1L; List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirDetailResponse response = new SouvenirDetailResponse( - souvenirId, - "테스트 기념품", - 10000, // v1 호환용 - deprecated - "$", // v1 호환용 - deprecated - 95000, // v1 호환용 - deprecated - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US", - "닉네임", - "https://example.com/profile.jpg", - true, - filesResponse + souvenirId, + "테스트 기념품", + 10000, // v1 호환용 - deprecated + "$", // v1 호환용 - deprecated + 95000, // v1 호환용 - deprecated + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US", + "닉네임", + "https://example.com/profile.jpg", + true, + true, + 15, + filesResponse ); String jwt = "Bearer test.jwt.token"; given(souvenirService.getSouvenir(souvenirId, jwt)).willReturn(response); mockMvc.perform(get("/api/souvenirs/{id}", souvenirId) - .header("Authorization", jwt)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/get-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") - ) - )); + .header("Authorization", jwt)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/get-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), + fieldWithPath("data.wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") + ) + )); } @DisplayName("기념품 생성 (v1)") @Test void createSouvenirWithFiles() throws Exception { SouvenirCreateRequest request = new SouvenirCreateRequest( - "테스트 기념품", - 10000, - "$", - 95000, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "테스트 기념품", + 10000, + "$", + 95000, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품", - 10000, "$", 95000, - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품", + 10000, "$", 95000, + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenir(any(SouvenirCreateRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 생성 (v2)") @@ -354,114 +354,114 @@ void createSouvenirWithFiles() throws Exception { void createSouvenirV2WithFiles() throws Exception { // TODO: v1 호환 필드(localPrice, currencySymbol, krwPrice) 향후 제거 예정 SouvenirRequest request = new SouvenirRequest( - "테스트 기념품 v2", - 1000, - "JPY", - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "테스트 기념품 v2", + 1000, + "JPY", + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1000, "¥"), - new PriceResponse.PriceDetail(9337, "₩") + new PriceResponse.PriceDetail(1000, "¥"), + new PriceResponse.PriceDetail(9337, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품 v2", - 1000, "¥", 9337, // v1 호환용 - deprecated - priceResponse, - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품 v2", + 1000, "¥", 9337, // v1 호환용 - deprecated + priceResponse, + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenirV2(any(SouvenirRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v1)") @@ -470,105 +470,108 @@ void updateSouvenir() throws Exception { Long souvenirId = 1L; SouvenirUpdateRequest requestDto = new SouvenirUpdateRequest( - "수정된 테스트 기념품", - 10000, - "$", - 95000, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "수정된 테스트 기념품", + 10000, + "$", + 95000, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 테스트 기념품", - 10000, "$", 95000, - priceResponse, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 테스트 기념품", + 10000, "$", 95000, + priceResponse, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenir(eq(souvenirId), any(SouvenirUpdateRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { req.setMethod("PUT"); return req; })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { + req.setMethod("PUT"); + return req; + })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v2)") @@ -578,103 +581,106 @@ void updateSouvenirV2() throws Exception { Long souvenirId = 1L; SouvenirRequest requestDto = new SouvenirRequest( - "수정된 기념품 v2", - 1500, - "JPY", - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "수정된 기념품 v2", + 1500, + "JPY", + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1500, "¥"), - new PriceResponse.PriceDetail(14005, "₩") + new PriceResponse.PriceDetail(1500, "¥"), + new PriceResponse.PriceDetail(14005, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 기념품 v2", - 1500, "¥", 14005, // v1 호환용 - deprecated - priceResponse, - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 기념품 v2", + 1500, "¥", 14005, // v1 호환용 - deprecated + priceResponse, + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenirV2(eq(souvenirId), any(SouvenirRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { req.setMethod("PUT"); return req; })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { + req.setMethod("PUT"); + return req; + })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 삭제") @@ -683,21 +689,21 @@ void deleteSouvenir() throws Exception { Long souvenirId = 1L; mockMvc.perform(delete("/api/souvenirs/{id}", souvenirId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").exists()) - .andDo(document("souvenirs/delete-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.NULL).optional() - .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()) + .andDo(document("souvenirs/delete-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).optional() + .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java index 02c8615a..61fb5ff8 100644 --- a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java @@ -11,9 +11,9 @@ import com.souzip.domain.user.dto.UserAgreementInfo; import com.souzip.domain.user.dto.UserProfileResponse; import com.souzip.domain.user.service.UserService; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -55,9 +55,9 @@ protected Object initController() { // Mock 헬퍼 메서드 private MySouvenirListResponse createMockMySouvenirListResponse( - List content, - int currentPage, int totalPages, long totalItems, int pageSize, - boolean first, boolean last, boolean hasNext, boolean hasPrevious + List content, + int currentPage, int totalPages, long totalItems, int pageSize, + boolean first, boolean last, boolean hasNext, boolean hasPrevious ) { MySouvenirListResponse response = mock(MySouvenirListResponse.class); PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); @@ -87,31 +87,31 @@ void checkNickname_available() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(true)) - .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) - .andDo(document("user/check-nickname-available", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(true)) + .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) + .andDo(document("user/check-nickname-available", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -125,31 +125,31 @@ void checkNickname_unavailable() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(false)) - .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/check-nickname-unavailable", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (이미 사용 중)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (false: 중복됨)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(false)) + .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/check-nickname-unavailable", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (이미 사용 중)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (false: 중복됨)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -157,38 +157,38 @@ void checkNickname_unavailable() throws Exception { void completeOnboarding_nicknameAlreadyExists() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", + "red", + List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); + .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/onboarding-nickname-duplicated", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/onboarding-nickname-duplicated", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -196,72 +196,72 @@ void completeOnboarding_nicknameAlreadyExists() throws Exception { void completeOnboarding_success() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, true, true, true, false, + "수집", "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); List categoryDtos = List.of( - new CategoryDto("FOOD_SNACK", "먹거리·간식"), - new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), - new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") + new CategoryDto("FOOD_SNACK", "먹거리·간식"), + new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), + new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") ); UserAgreementInfo agreementInfo = new UserAgreementInfo(true, true, true, true, false); OnboardingResponse response = new OnboardingResponse( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "수집", - "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", - categoryDtos, - agreementInfo + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "수집", + "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", + categoryDtos, + agreementInfo ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willReturn(response); + .willReturn(response); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) - .andExpect(jsonPath("$.data.nickname").value("수집")) - .andExpect(jsonPath("$.data.profileImageUrl").exists()) - .andExpect(jsonPath("$.data.categories").isArray()) - .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) - .andDo(document("user/onboarding-success", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), - fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), - fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), - fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .andExpect(jsonPath("$.data.nickname").value("수집")) + .andExpect(jsonPath("$.data.profileImageUrl").exists()) + .andExpect(jsonPath("$.data.categories").isArray()) + .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) + .andDo(document("user/onboarding-success", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), + fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), + fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), + fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -269,36 +269,36 @@ void completeOnboarding_success() throws Exception { void completeOnboarding_requiredAgreementNotChecked() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, false, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, false, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); + .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) - .andDo(document("user/onboarding-required-agreement-not-checked", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) + .andDo(document("user/onboarding-required-agreement-not-checked", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -306,36 +306,36 @@ void completeOnboarding_requiredAgreementNotChecked() throws Exception { void completeOnboarding_alreadyCompleted() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); + .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) - .andDo(document("user/onboarding-already-completed", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) + .andDo(document("user/onboarding-already-completed", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -343,36 +343,36 @@ void completeOnboarding_alreadyCompleted() throws Exception { void completeOnboarding_invalidCategory() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); + .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) - .andDo(document("user/onboarding-invalid-category", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) + .andDo(document("user/onboarding-invalid-category", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -380,36 +380,36 @@ void completeOnboarding_invalidCategory() throws Exception { void completeOnboarding_invalidProfileColor() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "invalid_color", List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "invalid_color", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); + .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) - .andDo(document("user/onboarding-invalid-profile-color", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) + .andDo(document("user/onboarding-invalid-profile-color", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -420,17 +420,17 @@ void withdraw_success() throws Exception { // when & then mockMvc.perform(delete("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) - .andDo(document("user/withdraw", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) + .andDo(document("user/withdraw", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -438,151 +438,157 @@ void withdraw_success() throws Exception { void getMyProfile_success() throws Exception { // given UserProfileResponse response = new UserProfileResponse( - "a1b2c3d4...", - "테스트닉네임", - "test@kakao.com", - "https://example.com/profile.jpg" + "a1b2c3d4...", + "테스트닉네임", + "test@kakao.com", + "https://example.com/profile.jpg" ); given(userService.getUserProfile(any())).willReturn(response); // when & then mockMvc.perform(get("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) - .andDo(document("user/my-profile", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), - fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) + .andDo(document("user/my-profile", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("내가 등록한 기념품 목록을 조회한다.") void getMySouvenirs_success() throws Exception { - // given List content = List.of( - new MySouvenirResponse( - 1L, - "https://example.com/image1.jpg", - "KR", - LocalDateTime.of(2024, 1, 15, 10, 30), - LocalDateTime.of(2024, 1, 20, 15, 0) - ), - new MySouvenirResponse( - 2L, - "https://example.com/image2.jpg", - "FR", - LocalDateTime.of(2024, 1, 10, 9, 0), - LocalDateTime.of(2024, 1, 10, 9, 0) - ) + new MySouvenirResponse( + 1L, + "https://example.com/image1.jpg", + "KR", + LocalDateTime.of(2024, 1, 15, 10, 30), + LocalDateTime.of(2024, 1, 20, 15, 0), + 5L, + true + ), + new MySouvenirResponse( + 2L, + "https://example.com/image2.jpg", + "FR", + LocalDateTime.of(2024, 1, 10, 9, 0), + LocalDateTime.of(2024, 1, 10, 9, 0), + 0L, + false + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 1, 1, 2L, 12, true, true, false, false + content, 1, 1, 2L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); - // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 12)").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[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - 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() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 12)").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[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).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() + ) + )); } @Test @DisplayName("기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") void getMySouvenirs_withPage() throws Exception { - // given List content = List.of( - new MySouvenirResponse( - 3L, - null, - "KR", - LocalDateTime.of(2024, 1, 5, 10, 0), - LocalDateTime.of(2024, 1, 5, 10, 0) - ) + new MySouvenirResponse( + 3L, + null, + "KR", + LocalDateTime.of(2024, 1, 5, 10, 0), + LocalDateTime.of(2024, 1, 5, 10, 0), + 0L, + false + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 2, 3, 25L, 12, false, false, true, true + content, 2, 3, 25L, 12, false, false, true, true ); given(userService.getMySouvenirs(any(), eq(2), eq(12))).willReturn(response); - // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "2") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-page-2", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호"), - parameterWithName("size").description("페이지 크기") - ), - 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[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - 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() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "2") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-page-2", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + 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[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).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() + ) + )); } @Test @@ -590,39 +596,39 @@ void getMySouvenirs_withPage() throws Exception { void getMySouvenirs_empty() throws Exception { // given MySouvenirListResponse response = createMockMySouvenirListResponse( - List.of(), 1, 0, 0L, 12, true, true, false, false + List.of(), 1, 0, 0L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-empty", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호").optional(), - parameterWithName("size").description("페이지 크기").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).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() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-empty", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).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() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/entity/UserTest.java b/src/test/java/com/souzip/domain/user/entity/UserTest.java index 79d20520..49b74bf1 100644 --- a/src/test/java/com/souzip/domain/user/entity/UserTest.java +++ b/src/test/java/com/souzip/domain/user/entity/UserTest.java @@ -1,9 +1,11 @@ package com.souzip.domain.user.entity; -import java.time.LocalDateTime; +import com.souzip.shared.domain.Provider; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; + import static org.assertj.core.api.Assertions.assertThat; class UserTest { @@ -11,28 +13,20 @@ class UserTest { @Test @DisplayName("anonymize 호출 시 개인정보가 익명화된다.") void anonymize_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); - // when user.anonymize(); - // then - assertThat(user.getName()).isEqualTo("탈퇴한사용자"); assertThat(user.getNickname()).isEqualTo("탈퇴한사용자"); } @Test - @DisplayName("restore 호출 시 name이 복구되고 nickname은 빈값으로 초기화된다.") + @DisplayName("restore 호출 시 nickname은 빈값으로 초기화된다.") void restore_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "탈퇴한사용자", "탈퇴한사용자", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); - // when user.restore("테스트유저"); - // then - assertThat(user.getName()).isEqualTo("테스트유저"); assertThat(user.getNickname()).isEmpty(); assertThat(user.getDeleted()).isFalse(); assertThat(user.getRestoredAt()).isNotNull(); @@ -44,16 +38,13 @@ void restore_success() { @Test @DisplayName("updateLastLoginAt 호출 시 lastLoginAt이 현재 시간으로 설정된다.") void updateLastLoginAt_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트", "테스트", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); LocalDateTime before = LocalDateTime.now(); - // when user.updateLastLoginAt(); - // then assertThat(user.getLastLoginAt()).isNotNull(); assertThat(user.getLastLoginAt()).isAfterOrEqualTo(before); assertThat(user.getLastLoginAt()).isBeforeOrEqualTo(LocalDateTime.now()); } -} +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java index 00e41ca6..9f11b256 100644 --- a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,11 +21,11 @@ class ProfileImageServiceTest { @BeforeEach void setUp() { properties = new ObjectStorageProperties( - "https://kr.object.ncloudstorage.com", - "kr-standard", - "souzip-dev-images", - "test-access-key", - "test-secret-key" + "https://kr.object.ncloudstorage.com", + "kr-standard", + "souzip-dev-images", + "test-access-key", + "test-secret-key" ); profileImageService = new ProfileImageService(properties); } @@ -49,8 +49,8 @@ void resolveProfileImageUrl_allValidColors(String color) { // then assertThat(url) - .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") - .endsWith(color + ".png"); + .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") + .endsWith(color + ".png"); } @ParameterizedTest @@ -82,9 +82,9 @@ void resolveProfileImageUrl_withNullOrEmpty_returnsDefault(String input) { void resolveProfileImageUrl_withInvalidColor_throwsException(String invalidColor) { // when & then assertThatThrownBy(() -> profileImageService.resolveProfileImageUrl(invalidColor)) - .isInstanceOf(BusinessException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) - .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) + .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); } @Test @@ -95,7 +95,7 @@ void resolveProfileImageUrl_urlFormat() { // then assertThat(url) - .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); + .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); } @Test @@ -106,7 +106,7 @@ void getAvailableColors() { // then assertThat(colors) - .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") - .hasSize(4); + .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") + .hasSize(4); } } diff --git a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java index 3b442968..d0ce4e93 100644 --- a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java @@ -1,19 +1,15 @@ package com.souzip.domain.user.service; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.user.dto.NicknameCheckResponse; import com.souzip.domain.user.dto.OnboardingRequest; import com.souzip.domain.user.dto.OnboardingResponse; -import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; +import com.souzip.shared.domain.Provider; +import com.souzip.shared.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,15 +17,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; +import java.util.Optional; + 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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class UserServiceTest { @@ -52,60 +47,45 @@ class UserServiceTest { @Test @DisplayName("사용 가능한 닉네임을 확인한다.") void checkNickname_available() { - // given - String nickname = "새로운닉네임"; - given(userRepository.existsByNickname(nickname)).willReturn(false); + given(userRepository.existsByNickname("새로운닉네임")).willReturn(false); - // when - NicknameCheckResponse response = userService.checkNickname(nickname); + NicknameCheckResponse response = userService.checkNickname("새로운닉네임"); - // then assertThat(response.isAvailable()).isTrue(); assertThat(response.getMessage()).isEqualTo("사용 가능한 닉네임입니다."); - verify(userRepository).existsByNickname(nickname); + verify(userRepository).existsByNickname("새로운닉네임"); } @Test @DisplayName("이미 사용 중인 닉네임을 확인한다.") void checkNickname_unavailable() { - // given - String nickname = "중복닉네임"; - given(userRepository.existsByNickname(nickname)).willReturn(true); + given(userRepository.existsByNickname("중복닉네임")).willReturn(true); - // when - NicknameCheckResponse response = userService.checkNickname(nickname); + NicknameCheckResponse response = userService.checkNickname("중복닉네임"); - // then assertThat(response.isAvailable()).isFalse(); assertThat(response.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); - verify(userRepository).existsByNickname(nickname); } @Test @DisplayName("온보딩 시 닉네임이 중복되면 에러가 발생한다.") void completeOnboarding_nicknameAlreadyExists() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userRepository.existsByNickname("중복닉네임")).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 사용 중인 닉네임입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 사용 중인 닉네임입니다."); - verify(userRepository).existsByNickname("중복닉네임"); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -113,28 +93,18 @@ void completeOnboarding_nicknameAlreadyExists() { @Test @DisplayName("온보딩을 완료하면 User와 UserAgreement가 저장된다.") void completeOnboarding_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", "https://kakao.com/profile.jpg"); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, - true, - true, - true, - false, - "수집", - "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, true, true, true, false, + "수집", "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); String expectedImageUrl = "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"; - UserAgreement agreement = UserAgreement.of( - spyUser, - true, true, true, true, false - ); + UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); @@ -145,15 +115,12 @@ void completeOnboarding_success() { given(spyUser.getNickname()).willReturn("수집"); given(spyUser.getProfileImageUrl()).willReturn(expectedImageUrl); - // when OnboardingResponse response = userService.completeOnboarding(1L, request); - // then assertThat(response.userId()).isEqualTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); assertThat(response.nickname()).isEqualTo("수집"); assertThat(response.profileImageUrl()).isEqualTo(expectedImageUrl); assertThat(response.categories()).hasSize(3); - assertThat(response.agreements()).isNotNull(); assertThat(response.agreements().ageVerified()).isTrue(); assertThat(response.agreements().marketingConsent()).isFalse(); @@ -165,29 +132,20 @@ void completeOnboarding_success() { @Test @DisplayName("필수 약관에 동의하지 않으면 온보딩에 실패한다.") void completeOnboarding_requiredAgreementNotChecked() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, - false, - true, - true, - false, - "수집", - "red", - List.of("FOOD_SNACK") + true, false, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -196,26 +154,21 @@ void completeOnboarding_requiredAgreementNotChecked() { @Test @DisplayName("이미 약관에 동의한 사용자는 중복 저장되지 않는다.") void completeOnboarding_agreementAlreadyExists() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 약관에 동의한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 약관에 동의한 사용자입니다."); verify(userAgreementRepository, never()).save(any()); } @@ -223,25 +176,20 @@ void completeOnboarding_agreementAlreadyExists() { @Test @DisplayName("이미 온보딩을 완료한 사용자는 다시 온보딩할 수 없다.") void completeOnboarding_alreadyCompleted() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "수집", "수집", - "test@kakao.com", "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "새닉네임", - "blue", - List.of("FOOD_SNACK") + true, true, true, true, false, + "새닉네임", "blue", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(false); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); verify(profileImageService, never()).resolveProfileImageUrl(anyString()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -250,26 +198,21 @@ void completeOnboarding_alreadyCompleted() { @Test @DisplayName("유효하지 않은 카테고리로 온보딩 시 에러가 발생한다.") void completeOnboarding_invalidCategory() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("INVALID_CATEGORY", "FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("INVALID_CATEGORY", "FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(false); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("유효하지 않은 카테고리입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("유효하지 않은 카테고리입니다."); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -277,50 +220,33 @@ void completeOnboarding_invalidCategory() { @Test @DisplayName("존재하지 않는 사용자로 온보딩 시 에러가 발생한다.") void completeOnboarding_userNotFound() { - // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(999L)).willReturn(Optional.empty()); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(999L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test @DisplayName("회원탈퇴 시 User는 익명화되고 약관 동의도 삭제된다.") void withdraw_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "refresh_token", - LocalDateTime.now().plusDays(30) - ); - - UserAgreement agreement = UserAgreement.of( - spyUser, - true, true, true, true, false - ); + UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(refreshToken)); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.of(agreement)); - // when userService.withdraw(1L); - // then verify(spyUser).anonymize(); - verify(refreshTokenRepository).delete(refreshToken); + verify(refreshTokenRepository).deleteByUserId(1L); verify(userAgreementRepository).delete(agreement); verify(userRepository).delete(spyUser); } @@ -328,33 +254,42 @@ void withdraw_success() { @Test @DisplayName("존재하지 않는 사용자로 회원탈퇴 시 에러가 발생한다.") void withdraw_withNotExistUser_throwsException() { - // given given(userRepository.findById(999L)).willReturn(Optional.empty()); - // when & then assertThatThrownBy(() -> userService.withdraw(999L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test - @DisplayName("Refresh Token과 약관 동의가 없어도 회원탈퇴는 성공한다.") - void withdraw_withoutRefreshTokenAndAgreement_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); + @DisplayName("약관 동의가 없어도 회원탈퇴는 성공한다.") + void withdraw_withoutAgreement_success() { + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.empty()); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.empty()); - // when userService.withdraw(1L); - // then verify(spyUser).anonymize(); + verify(refreshTokenRepository).deleteByUserId(1L); verify(userRepository).delete(spyUser); - verify(refreshTokenRepository, never()).delete(any()); verify(userAgreementRepository, never()).delete(any()); } -} + + @Test + @DisplayName("탈퇴된 유저 삭제 쿼리를 호출하고 삭제 결과를 반환한다") + void deleteWithdrawnUsers_callsRepositoryAndReturnsCount() { + // given + given(userRepository.deleteByDeletedTrue()) + .willReturn(3L); + + // when + long result = userService.deleteWithdrawnUsers(); + + // then + verify(userRepository).deleteByDeletedTrue(); + assertThat(result).isEqualTo(3L); + } +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java b/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java new file mode 100644 index 00000000..afc90a45 --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java @@ -0,0 +1,315 @@ +package com.souzip.domain.wishlist.controller; + +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.wishlist.dto.MyWishlistListResponse; +import com.souzip.domain.wishlist.dto.MyWishlistResponse; +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.service.WishlistService; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.LocalDateTime; +import java.util.List; + +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.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +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 WishlistControllerTest extends RestDocsSupport { + + private final WishlistService wishlistService = mock(WishlistService.class); + + @Override + protected Object initController() { + return new WishlistController(wishlistService); + } + + @BeforeEach + void setUpWithResolver(RestDocumentationContextProvider provider) { + this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers( + new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return 1L; + } + }, + new PageableHandlerMethodArgumentResolver() + ) + .apply(documentationConfiguration(provider)) + .build(); + } + + private MyWishlistListResponse createMockMyWishlistListResponse( + List content, + int currentPage, int totalPages, long totalItems, int pageSize, + boolean first, boolean last, boolean hasNext, boolean hasPrevious + ) { + MyWishlistListResponse response = mock(MyWishlistListResponse.class); + PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); + + given(response.content()).willReturn(content); + given(response.pagination()).willReturn(pageInfo); + given(pageInfo.getCurrentPage()).willReturn(currentPage); + given(pageInfo.getTotalPages()).willReturn(totalPages); + given(pageInfo.getTotalItems()).willReturn(totalItems); + given(pageInfo.getPageSize()).willReturn(pageSize); + given(pageInfo.isFirst()).willReturn(first); + given(pageInfo.isLast()).willReturn(last); + given(pageInfo.isHasNext()).willReturn(hasNext); + given(pageInfo.isHasPrevious()).willReturn(hasPrevious); + + return response; + } + + @DisplayName("찜 등록") + @Test + void addWishlist() throws Exception { + Long souvenirId = 1L; + + given(wishlistService.addWishlist(1L, souvenirId)) + .willReturn(WishlistResponse.of(souvenirId, true)); + + mockMvc.perform(post("/api/wishlists/{souvenirId}", souvenirId) + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) + .andExpect(jsonPath("$.data.wishlisted").value(true)) + .andDo(document("wishlists/add", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("souvenirId").description("찜할 기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (true: 찜 등록됨)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @DisplayName("찜 취소") + @Test + void removeWishlist() throws Exception { + Long souvenirId = 1L; + + given(wishlistService.removeWishlist(1L, souvenirId)) + .willReturn(WishlistResponse.of(souvenirId, false)); + + mockMvc.perform(delete("/api/wishlists/{souvenirId}", souvenirId) + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) + .andExpect(jsonPath("$.data.wishlisted").value(false)) + .andDo(document("wishlists/remove", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("souvenirId").description("찜 취소할 기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (false: 찜 취소됨)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("내가 찜한 기념품 목록을 조회한다.") + void getMyWishlists_success() throws Exception { + List content = List.of( + new MyWishlistResponse( + 1L, + "글로시에 립밤", + "US", + "https://example.com/image1.jpg", + LocalDateTime.of(2024, 1, 15, 10, 30), + true + ), + new MyWishlistResponse( + 2L, + "말차초콜릿", + "JP", + "https://example.com/image2.jpg", + LocalDateTime.of(2024, 1, 10, 9, 0), + true + ) + ); + + MyWishlistListResponse response = createMockMyWishlistListResponse( + content, 1, 1, 2L, 12, true, true, false, false + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "0") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), + fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).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() + ) + )); + } + + @Test + @DisplayName("찜한 기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") + void getMyWishlists_withPage() throws Exception { + List content = List.of( + new MyWishlistResponse( + 3L, + "전통 부채", + "KR", + null, + LocalDateTime.of(2024, 1, 5, 10, 0), + true + ) + ); + + MyWishlistListResponse response = createMockMyWishlistListResponse( + content, 2, 3, 25L, 12, false, false, true, true + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists-page-2", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), + fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).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() + ) + )); + } + + @Test + @DisplayName("찜한 기념품이 없으면 빈 목록을 반환한다.") + void getMyWishlists_empty() throws Exception { + MyWishlistListResponse response = createMockMyWishlistListResponse( + List.of(), 1, 0, 0L, 12, true, true, false, false + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "0") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists-empty", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).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() + ) + )); + } +} diff --git a/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java b/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java new file mode 100644 index 00000000..41090b8d --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java @@ -0,0 +1,27 @@ +package com.souzip.domain.wishlist.entity; + +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class WishlistTest { + + @Test + @DisplayName("of 호출 시 user와 souvenir가 올바르게 설정된다.") + void of_success() { + // given + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + // when + Wishlist wishlist = Wishlist.of(user, souvenir); + + // then + assertThat(wishlist.getUser()).isEqualTo(user); + assertThat(wishlist.getSouvenir()).isEqualTo(souvenir); + } +} diff --git a/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java b/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java new file mode 100644 index 00000000..c5544bf6 --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java @@ -0,0 +1,148 @@ +package com.souzip.domain.wishlist.service; + +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.souvenir.repository.SouvenirRepository; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.entity.Wishlist; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.exception.BusinessException; +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.dao.DataIntegrityViolationException; + +import java.util.Optional; + +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WishlistServiceTest { + + @Mock + private WishlistRepository wishlistRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private SouvenirRepository souvenirRepository; + + @InjectMocks + private WishlistService wishlistService; + + @Test + @DisplayName("찜 등록에 성공한다.") + void addWishlist_success() { + // given + Long userId = 1L; + Long souvenirId = 1L; + + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); + given(wishlistRepository.save(any(Wishlist.class))).willReturn(any()); + + // when + WishlistResponse response = wishlistService.addWishlist(userId, souvenirId); + + // then + assertThat(response.souvenirId()).isEqualTo(souvenirId); + assertThat(response.wishlisted()).isTrue(); + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("이미 찜한 기념품을 다시 찜하면 에러가 발생한다.") + void addWishlist_alreadyWishlisted() { + // given + Long userId = 1L; + Long souvenirId = 1L; + + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); + given(wishlistRepository.save(any(Wishlist.class))).willThrow(DataIntegrityViolationException.class); + + // when & then + assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("존재하지 않는 기념품을 찜하면 에러가 발생한다.") + void addWishlist_souvenirNotFound() { + // given + Long userId = 1L; + Long souvenirId = 999L; + + User user = mock(User.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository, never()).save(any()); + } + + @Test + @DisplayName("찜 취소에 성공한다.") + void removeWishlist_success() { + // given + Long userId = 1L; + Long souvenirId = 1L; + String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; + + User user = mock(User.class); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(user.getUserId()).willReturn(userUuid); + given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(true); + + // when + WishlistResponse response = wishlistService.removeWishlist(userId, souvenirId); + + // then + assertThat(response.souvenirId()).isEqualTo(souvenirId); + assertThat(response.wishlisted()).isFalse(); + verify(wishlistRepository).deleteByUserIdAndSouvenirId(userId, souvenirId); + } + + @Test + @DisplayName("찜하지 않은 기념품을 취소하면 에러가 발생한다.") + void removeWishlist_notFound() { + // given + Long userId = 1L; + Long souvenirId = 1L; + String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; + + User user = mock(User.class); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(user.getUserId()).willReturn(userUuid); + given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> wishlistService.removeWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository, never()).deleteByUserIdAndSouvenirId(any(), any()); + } +} diff --git a/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java b/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java similarity index 98% rename from src/test/java/com/souzip/global/aop/MdcTraceIdTest.java rename to src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java index 5f0d6273..d082026b 100644 --- a/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java +++ b/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java similarity index 97% rename from src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java rename to src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java index 39255358..750f6ef7 100644 --- a/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java +++ b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java similarity index 82% rename from src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java rename to src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java index 2271c4b5..0dd702eb 100644 --- a/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java +++ b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java @@ -1,6 +1,7 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -15,21 +16,21 @@ class PaginationResponseTest { @DisplayName("페이지 정보 확인") @CsvSource({ - "0, 10, 23, 1, 3, true, false, true, false", - "1, 10, 30, 2, 3, false, false, true, true", - "2, 10, 23, 3, 3, false, true, false, true" + "0, 10, 23, 1, 3, true, false, true, false", + "1, 10, 30, 2, 3, false, false, true, true", + "2, 10, 23, 3, 3, false, true, false, true" }) @ParameterizedTest void pageInfo( - int pageNumber, - int pageSize, - long totalElements, - int expectedCurrentPage, - int expectedTotalPages, - boolean expectedFirst, - boolean expectedLast, - boolean expectedHasNext, - boolean expectedHasPrevious + int pageNumber, + int pageSize, + long totalElements, + int expectedCurrentPage, + int expectedTotalPages, + boolean expectedFirst, + boolean expectedLast, + boolean expectedHasNext, + boolean expectedHasPrevious ) { // given List content = List.of("item1", "item2", "item3"); diff --git a/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java b/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java similarity index 96% rename from src/test/java/com/souzip/global/exception/BusinessExceptionTest.java rename to src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java index bb274d21..f486b543 100644 --- a/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java +++ b/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test;