diff --git a/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java b/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java index 28e2ec9..b9d7608 100644 --- a/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java +++ b/src/main/java/com/zimdugo/common/openapi/config/OpenApiConfig.java @@ -20,8 +20,7 @@ public OpenAPI zimdugoOpenAPI() { .info(new Info() .title("Zimdugo API") .description("Zimdugo 백엔드 API 문서")) - .addServersItem(new Server().url("https://api.zimdugo.com").description("Production")) - .addServersItem(new Server().url("http://localhost:8080").description("Local")) + .addServersItem(new Server().url("/").description("Current server")) .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) .components(new Components() .addSecuritySchemes(SECURITY_SCHEME_NAME, new SecurityScheme() diff --git a/src/main/java/com/zimdugo/core/exception/ErrorCode.java b/src/main/java/com/zimdugo/core/exception/ErrorCode.java index 97beed5..f2a8da6 100644 --- a/src/main/java/com/zimdugo/core/exception/ErrorCode.java +++ b/src/main/java/com/zimdugo/core/exception/ErrorCode.java @@ -25,6 +25,9 @@ public enum ErrorCode implements BaseCode { USER_ALREADY_WITHDRAWN("U4002", "user.already_withdrawn", HttpStatus.BAD_REQUEST), + LOCKER_NOT_FOUND("L4041", "locker.not_found", HttpStatus.NOT_FOUND), + + UNSUPPORTED_SOCIAL_LOGIN("A4005", "auth.unsupported_social_login", HttpStatus.BAD_REQUEST), AUTHENTICATED_USER_NOT_FOUND("A4011", "auth.authenticated_user_not_found", HttpStatus.UNAUTHORIZED); diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java new file mode 100644 index 0000000..48b8a6c --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerCommandService.java @@ -0,0 +1,27 @@ +package com.zimdugo.locker.application; + +import com.zimdugo.locker.domain.FavoriteLockerStore; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class FavoriteLockerCommandService { + + private final FavoriteLockerStore favoriteLockerStore; + + public void add(Long userId, Long lockerId) { + favoriteLockerStore.add(userId, lockerId); + } + + public void remove(Long userId, Long lockerId) { + favoriteLockerStore.remove(userId, lockerId); + } + + public void reorder(Long userId, List lockerIds) { + favoriteLockerStore.reorder(userId, lockerIds); + } +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java new file mode 100644 index 0000000..5d138d8 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerItemResponse.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.application; + +import java.time.LocalDateTime; + +public record FavoriteLockerItemResponse( + Long lockerId, + String poiName, + String roadAddress, + double latitude, + double longitude, + LocalDateTime favoritedAt, + LocalDateTime lastCompletedVoteAt, + Long distanceMeters +) { +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java new file mode 100644 index 0000000..f7d151c --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerQueryService.java @@ -0,0 +1,51 @@ +package com.zimdugo.locker.application; + +import com.zimdugo.locker.domain.FavoriteLocker; +import com.zimdugo.locker.domain.FavoriteLockerPage; +import com.zimdugo.locker.domain.FavoriteLockerReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FavoriteLockerQueryService { + + private final FavoriteLockerReader favoriteLockerReader; + + @Transactional(readOnly = true) + public FavoriteLockerResponse getFavorites(Long userId, int page, int size, Double latitude, Double longitude) { + FavoriteLockerPage favoritePage = favoriteLockerReader.findByUserId(userId, page, size, latitude, longitude); + + return new FavoriteLockerResponse( + favoritePage.totalCount(), + favoritePage.page(), + favoritePage.size(), + favoritePage.hasNext(), + favoritePage.favorites().stream() + .map(this::toItemResponse) + .toList() + ); + } + + @Transactional(readOnly = true) + public FavoriteLockerStatusResponse getFavoriteStatus(Long userId, Long lockerId) { + return new FavoriteLockerStatusResponse( + lockerId, + favoriteLockerReader.existsByUserIdAndLockerId(userId, lockerId) + ); + } + + private FavoriteLockerItemResponse toItemResponse(FavoriteLocker favorite) { + return new FavoriteLockerItemResponse( + favorite.lockerId(), + favorite.poiName(), + favorite.roadAddress(), + favorite.latitude(), + favorite.longitude(), + favorite.favoritedAt(), + favorite.lastCompletedVoteAt(), + favorite.distanceMeters() + ); + } +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java new file mode 100644 index 0000000..ac8c289 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerResponse.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.application; + +import java.util.List; + +public record FavoriteLockerResponse( + long totalCount, + int page, + int size, + boolean hasNext, + List items +) { +} diff --git a/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java b/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java new file mode 100644 index 0000000..f9f7e38 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/application/FavoriteLockerStatusResponse.java @@ -0,0 +1,7 @@ +package com.zimdugo.locker.application; + +public record FavoriteLockerStatusResponse( + Long lockerId, + boolean favorite +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java new file mode 100644 index 0000000..6af5723 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLocker.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.domain; + +import java.time.LocalDateTime; + +public record FavoriteLocker( + Long lockerId, + String poiName, + String roadAddress, + double latitude, + double longitude, + LocalDateTime favoritedAt, + LocalDateTime lastCompletedVoteAt, + Long distanceMeters +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java new file mode 100644 index 0000000..9e1e015 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerPage.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.domain; + +import java.util.List; + +public record FavoriteLockerPage( + long totalCount, + int page, + int size, + boolean hasNext, + List favorites +) { +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java new file mode 100644 index 0000000..ca52786 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerReader.java @@ -0,0 +1,8 @@ +package com.zimdugo.locker.domain; + +public interface FavoriteLockerReader { + + FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude); + + boolean existsByUserIdAndLockerId(Long userId, Long lockerId); +} diff --git a/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java new file mode 100644 index 0000000..94e743d --- /dev/null +++ b/src/main/java/com/zimdugo/locker/domain/FavoriteLockerStore.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.domain; + +import java.util.List; + +public interface FavoriteLockerStore { + + void add(Long userId, Long lockerId); + + void remove(Long userId, Long lockerId); + + void reorder(Long userId, List lockerIds); +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java new file mode 100644 index 0000000..ef20890 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/FavoriteLockerOrderUpdateRequest.java @@ -0,0 +1,12 @@ +package com.zimdugo.locker.entrypoint; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; + +public record FavoriteLockerOrderUpdateRequest( + @NotEmpty(message = "validation.not_empty") + List<@NotNull @Positive Long> lockerIds +) { +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java new file mode 100644 index 0000000..43c2683 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteApi.java @@ -0,0 +1,129 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.core.response.RestResponse; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Locker Favorite", description = "보관함 즐겨찾기 API") +public interface LockerFavoriteApi { + + @Operation( + summary = "내 즐겨찾기 보관함 조회", + description = "로그인 사용자가 즐겨찾기한 보관함 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 페이지 요청"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @GetMapping("/me/favorite-lockers") + ResponseEntity> getMyFavoriteLockers( + Authentication authentication, + @RequestParam(name = "page", defaultValue = "0") + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") + @Min(0) + int page, + @RequestParam(name = "size", defaultValue = "20") + @Parameter(description = "페이지 크기", example = "20") + @Min(1) + @Max(50) + int size, + @RequestParam(name = "lat", required = false) + @Parameter(description = "현재 사용자 위도", example = "37.556") + @DecimalMin(value = "-90.0") + @DecimalMax(value = "90.0") + Double latitude, + @RequestParam(name = "lng", required = false) + @Parameter(description = "현재 사용자 경도", example = "126.923") + @DecimalMin(value = "-180.0") + @DecimalMax(value = "180.0") + Double longitude + ); + + @Operation( + summary = "보관함 즐겨찾기 상태 조회", + description = "로그인 사용자의 특정 보관함 즐겨찾기 등록 여부를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @GetMapping("/me/favorite-lockers/{lockerId}/status") + ResponseEntity> getFavoriteLockerStatus( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + @Positive + Long lockerId + ); + + @Operation( + summary = "즐겨찾기 순서 조정", + description = "로그인 사용자의 전체 즐겨찾기 보관함 순서를 전달한 순서대로 재정렬합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "순서 변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 순서 변경 요청"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @PatchMapping("/me/favorite-lockers/order") + ResponseEntity> reorderFavoriteLockers( + Authentication authentication, + @Valid @RequestBody FavoriteLockerOrderUpdateRequest request + ); + + @Operation( + summary = "보관함 즐겨찾기 등록", + description = "로그인 사용자의 즐겨찾기 보관함으로 등록합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "등록 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요"), + @ApiResponse(responseCode = "404", description = "보관함이 존재하지 않음") + }) + @PostMapping("/me/favorite-lockers/{lockerId}") + ResponseEntity> addFavoriteLocker( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + @Positive + Long lockerId + ); + + @Operation( + summary = "보관함 즐겨찾기 해제", + description = "로그인 사용자의 즐겨찾기 보관함에서 제거합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "해제 성공"), + @ApiResponse(responseCode = "401", description = "로그인 필요") + }) + @DeleteMapping("/me/favorite-lockers/{lockerId}") + ResponseEntity> removeFavoriteLocker( + Authentication authentication, + @PathVariable("lockerId") + @Parameter(description = "보관함 ID", example = "10") + @Positive + Long lockerId + ); +} diff --git a/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java new file mode 100644 index 0000000..70be2f6 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/entrypoint/LockerFavoriteController.java @@ -0,0 +1,95 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.core.exception.BusinessException; +import com.zimdugo.core.exception.ErrorCode; +import com.zimdugo.core.response.RestResponse; +import com.zimdugo.core.response.SuccessCode; +import com.zimdugo.locker.application.FavoriteLockerCommandService; +import com.zimdugo.locker.application.FavoriteLockerQueryService; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class LockerFavoriteController implements LockerFavoriteApi { + + private final FavoriteLockerQueryService favoriteLockerQueryService; + private final FavoriteLockerCommandService favoriteLockerCommandService; + + @Override + public ResponseEntity> getMyFavoriteLockers( + Authentication authentication, + int page, + int size, + Double latitude, + Double longitude + ) { + FavoriteLockerResponse response = favoriteLockerQueryService.getFavorites( + extractUserId(authentication), + page, + size, + latitude, + longitude + ); + return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, response)); + } + + @Override + public ResponseEntity> getFavoriteLockerStatus( + Authentication authentication, + Long lockerId + ) { + FavoriteLockerStatusResponse response = favoriteLockerQueryService.getFavoriteStatus( + extractUserId(authentication), + lockerId + ); + return ResponseEntity.ok(RestResponse.of(SuccessCode.OK, response)); + } + + @Override + public ResponseEntity> reorderFavoriteLockers( + Authentication authentication, + FavoriteLockerOrderUpdateRequest request + ) { + favoriteLockerCommandService.reorder(extractUserId(authentication), request.lockerIds()); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + @Override + public ResponseEntity> addFavoriteLocker( + Authentication authentication, + Long lockerId + ) { + favoriteLockerCommandService.add(extractUserId(authentication), lockerId); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + @Override + public ResponseEntity> removeFavoriteLocker( + Authentication authentication, + Long lockerId + ) { + favoriteLockerCommandService.remove(extractUserId(authentication), lockerId); + return ResponseEntity.ok(RestResponse.ok(SuccessCode.OK)); + } + + private Long extractUserId(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + throw new BusinessException(ErrorCode.AUTHENTICATED_USER_NOT_FOUND); + } + + try { + return Long.valueOf(authentication.getName()); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.AUTHENTICATED_USER_NOT_FOUND); + } + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java new file mode 100644 index 0000000..b894042 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerProperties.java @@ -0,0 +1,15 @@ +package com.zimdugo.locker.infrastructure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "locker.favorite") +public record FavoriteLockerProperties( + DefaultOrigin defaultOrigin +) { + + public record DefaultOrigin( + double latitude, + double longitude + ) { + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java new file mode 100644 index 0000000..76509a1 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapter.java @@ -0,0 +1,120 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.domain.FavoriteLocker; +import com.zimdugo.locker.domain.FavoriteLockerPage; +import com.zimdugo.locker.domain.FavoriteLockerReader; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import static java.util.stream.Collectors.toMap; + +@Component +@RequiredArgsConstructor +public class FavoriteLockerReaderAdapter implements FavoriteLockerReader { + + private final UserLockerFavoriteRepository userLockerFavoriteRepository; + private final LockerReportRepository lockerReportRepository; + private final LockerRepository lockerRepository; + private final FavoriteLockerProperties favoriteLockerProperties; + + @Override + public FavoriteLockerPage findByUserId(Long userId, int page, int size, Double latitude, Double longitude) { + Page favorites = + userLockerFavoriteRepository.findActiveFavoritesByUserId( + userId, + PageRequest.of(page, size) + ); + Coordinate origin = resolveOrigin(latitude, longitude); + Map lastCompletedVoteAtByLockerId = + getLastCompletedVoteAtByLockerId(favorites.getContent()); + Map distanceMetersByLockerId = getDistanceMetersByLockerId(favorites.getContent(), origin); + + return new FavoriteLockerPage( + favorites.getTotalElements(), + favorites.getNumber(), + favorites.getSize(), + favorites.hasNext(), + favorites.getContent().stream() + .map(favorite -> toFavoriteLocker(favorite, lastCompletedVoteAtByLockerId, distanceMetersByLockerId)) + .toList() + ); + } + + @Override + public boolean existsByUserIdAndLockerId(Long userId, Long lockerId) { + return userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId(userId, lockerId) > 0; + } + + private FavoriteLocker toFavoriteLocker( + UserLockerFavoriteEntity favorite, + Map lastCompletedVoteAtByLockerId, + Map distanceMetersByLockerId + ) { + Long lockerId = favorite.getLocker().getId(); + return new FavoriteLocker( + lockerId, + favorite.getLocker().getName(), + favorite.getLocker().getRoadAddress(), + favorite.getLocker().getLatitude(), + favorite.getLocker().getLongitude(), + favorite.getCreatedAt(), + lastCompletedVoteAtByLockerId.get(lockerId), + distanceMetersByLockerId.get(lockerId) + ); + } + + private Map getLastCompletedVoteAtByLockerId(List favorites) { + List lockerIds = favorites.stream() + .map(favorite -> favorite.getLocker().getId()) + .toList(); + + if (lockerIds.isEmpty()) { + return Map.of(); + } + + return lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(lockerIds).stream() + .collect(toMap( + LockerReportLatestUpdateProjection::getLockerId, + LockerReportLatestUpdateProjection::getLastCompletedVoteAt + )); + } + + private Map getDistanceMetersByLockerId(List favorites, Coordinate origin) { + List lockerIds = favorites.stream() + .map(favorite -> favorite.getLocker().getId()) + .toList(); + + if (lockerIds.isEmpty()) { + return Map.of(); + } + + return lockerRepository.findDistancesByLockerIds(origin.latitude(), origin.longitude(), lockerIds).stream() + .collect(toMap( + LockerDistanceProjection::getLockerId, + LockerDistanceProjection::getDistanceMeters + )); + } + + private Coordinate resolveOrigin(Double latitude, Double longitude) { + if (latitude != null && longitude != null) { + return new Coordinate(latitude, longitude); + } + + return new Coordinate( + favoriteLockerProperties.defaultOrigin().latitude(), + favoriteLockerProperties.defaultOrigin().longitude() + ); + } + + private record Coordinate( + double latitude, + double longitude + ) { + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java new file mode 100644 index 0000000..ecf7151 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/FavoriteLockerStoreAdapter.java @@ -0,0 +1,89 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.core.exception.BusinessException; +import com.zimdugo.core.exception.ErrorCode; +import com.zimdugo.locker.domain.FavoriteLockerStore; +import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.zimdugo.user.infrastructure.UserRepository; +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class FavoriteLockerStoreAdapter implements FavoriteLockerStore { + + private final UserLockerFavoriteRepository userLockerFavoriteRepository; + private final UserRepository userRepository; + private final LockerRepository lockerRepository; + + @Override + public void add(Long userId, Long lockerId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + LockerEntity locker = lockerRepository.findActiveById(lockerId) + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); + int displayOrder = nextDisplayOrder(userId); + + try { + userLockerFavoriteRepository.save(new UserLockerFavoriteEntity(user, locker, displayOrder)); + } catch (DataIntegrityViolationException e) { + // Concurrent favorite requests may race on the unique constraint. + if (userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId(userId, lockerId) > 0) { + return; + } + throw e; + } + } + + @Override + public void remove(Long userId, Long lockerId) { + userLockerFavoriteRepository.deleteByUserIdAndLockerId(userId, lockerId); + } + + @Override + public void reorder(Long userId, List lockerIds) { + long favoriteCount = userLockerFavoriteRepository.countActiveFavoritesByUserId(userId); + if (favoriteCount != lockerIds.size()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + long matchedFavoriteCount = userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerIds( + userId, + lockerIds + ); + if (matchedFavoriteCount != lockerIds.size()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + List favorites = + userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds(userId, lockerIds); + + Map favoriteByLockerId = new HashMap<>(); + for (UserLockerFavoriteEntity favorite : favorites) { + favoriteByLockerId.put(favorite.getLocker().getId(), favorite); + } + + for (int index = 0; index < lockerIds.size(); index++) { + Long lockerId = lockerIds.get(index); + UserLockerFavoriteEntity favorite = favoriteByLockerId.remove(lockerId); + if (favorite == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + favorite.updateDisplayOrder(index); + } + } + + private int nextDisplayOrder(Long userId) { + Integer maxDisplayOrder = userLockerFavoriteRepository.findMaxDisplayOrderAmongActiveFavoritesByUserId(userId); + if (maxDisplayOrder == null) { + return 0; + } + return maxDisplayOrder + 1; + } +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java new file mode 100644 index 0000000..61e9958 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerDistanceProjection.java @@ -0,0 +1,6 @@ +package com.zimdugo.locker.infrastructure; + +public interface LockerDistanceProjection { + Long getLockerId(); + Long getDistanceMeters(); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java new file mode 100644 index 0000000..148f1a8 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportLatestUpdateProjection.java @@ -0,0 +1,10 @@ +package com.zimdugo.locker.infrastructure; + +import java.time.LocalDateTime; + +public interface LockerReportLatestUpdateProjection { + + Long getLockerId(); + + LocalDateTime getLastCompletedVoteAt(); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java index fd413ae..4dba0ef 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportRepository.java @@ -1,7 +1,28 @@ package com.zimdugo.locker.infrastructure; import com.zimdugo.locker.infrastructure.persistence.LockerReportEntity; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface LockerReportRepository extends JpaRepository { + + @EntityGraph(attributePaths = "locker") + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + @Query(""" + SELECT lr.locker.id AS lockerId, MAX(lr.updatedAt) AS lastCompletedVoteAt + FROM LockerReportEntity lr + WHERE lr.locker.id IN :lockerIds + AND lr.status = com.zimdugo.locker.domain.LockerReportStatus.COMPLETED + GROUP BY lr.locker.id + """) + List findLatestCompletedVoteAtByLockerIdIn( + @Param("lockerIds") Collection lockerIds + ); } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java index 50bb523..00c76ae 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerReportStoreAdapter.java @@ -22,8 +22,8 @@ public class LockerReportStoreAdapter implements LockerReportStore { @Override public SavedLockerReport create(LockerReportCreateInfo createInfo) { - LockerEntity locker = lockerRepository.findById(createInfo.lockerId()) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + LockerEntity locker = lockerRepository.findActiveById(createInfo.lockerId()) + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); UserEntity user = userRepository.findById(createInfo.userId()) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java index edc6e52..2e11c17 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerRepository.java @@ -1,13 +1,23 @@ package com.zimdugo.locker.infrastructure; import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface LockerRepository extends JpaRepository { + @Query(""" + select l + from LockerEntity l + where l.id = :id + and l.deleted = false + """) + Optional findActiveById(@Param("id") Long id); + @Query(value = """ -- 조회 기준 좌표 WITH target AS ( @@ -24,6 +34,7 @@ nearby AS ( FROM lockers l CROSS JOIN target WHERE ST_DWithin(l.location, target.point, :radiusMeters) + AND l.deleted = false ) SELECT nearby.id AS id, @@ -46,4 +57,22 @@ List findNearby( @Param("longitude") double longitude, @Param("radiusMeters") int radiusMeters ); + + @Query(value = """ + WITH target AS ( + SELECT ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography AS point + ) + SELECT + l.id AS lockerId, + ROUND(ST_Distance(l.location, target.point))::bigint AS distanceMeters + FROM lockers l + CROSS JOIN target + WHERE l.id IN (:lockerIds) + AND l.deleted = false + """, nativeQuery = true) + List findDistancesByLockerIds( + @Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("lockerIds") Collection lockerIds + ); } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java index 906b823..b8bae08 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/LockerStoreAdapter.java @@ -24,9 +24,9 @@ public ReportLocker create(String name, String roadAddress, double latitude, dou @Override public ReportLocker getById(Long id) { - return lockerRepository.findById(id) + return lockerRepository.findActiveById(id) .map(this::toDomain) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.LOCKER_NOT_FOUND)); } private ReportLocker toDomain(LockerEntity locker) { diff --git a/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java new file mode 100644 index 0000000..483c5bf --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepository.java @@ -0,0 +1,97 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserLockerFavoriteRepository extends JpaRepository { + + @EntityGraph(attributePaths = "locker") + @Query( + value = """ + select favorite + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + order by favorite.displayOrder asc, favorite.createdAt desc + """, + countQuery = """ + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """ + ) + Page findActiveFavoritesByUserId( + @Param("userId") Long userId, + Pageable pageable + ); + + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id = :lockerId + and locker.deleted = false + """) + long countActiveFavoritesByUserIdAndLockerId( + @Param("userId") Long userId, + @Param("lockerId") Long lockerId + ); + + void deleteByUserIdAndLockerId(Long userId, Long lockerId); + + @EntityGraph(attributePaths = "locker") + @Query(""" + select favorite + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id in :lockerIds + and locker.deleted = false + """) + List findActiveFavoritesByUserIdAndLockerIds( + @Param("userId") Long userId, + @Param("lockerIds") Collection lockerIds + ); + + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """) + long countActiveFavoritesByUserId(@Param("userId") Long userId); + + @Query(""" + select max(favorite.displayOrder) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.deleted = false + """) + Integer findMaxDisplayOrderAmongActiveFavoritesByUserId(@Param("userId") Long userId); + + @Query(""" + select count(favorite) + from UserLockerFavoriteEntity favorite + join favorite.locker locker + where favorite.user.id = :userId + and locker.id in :lockerIds + and locker.deleted = false + """) + long countActiveFavoritesByUserIdAndLockerIds( + @Param("userId") Long userId, + @Param("lockerIds") Collection lockerIds + ); +} diff --git a/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java b/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java index d9eaa8f..4a2556b 100644 --- a/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java +++ b/src/main/java/com/zimdugo/locker/infrastructure/persistence/LockerEntity.java @@ -32,10 +32,18 @@ public class LockerEntity { @Column(nullable = false) private double longitude; + @Column(nullable = false, columnDefinition = "boolean default false") + private boolean deleted; + public LockerEntity(String name, String roadAddress, double latitude, double longitude) { this.name = name; this.roadAddress = roadAddress; this.latitude = latitude; this.longitude = longitude; + this.deleted = false; + } + + public void markDeleted() { + this.deleted = true; } } diff --git a/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java b/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java new file mode 100644 index 0000000..79211c8 --- /dev/null +++ b/src/main/java/com/zimdugo/locker/infrastructure/persistence/UserLockerFavoriteEntity.java @@ -0,0 +1,69 @@ +package com.zimdugo.locker.infrastructure.persistence; + +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "user_locker_favorites", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_locker_favorites_user_locker", + columnNames = {"user_id", "locker_id"} + ), + indexes = { + @Index(name = "idx_user_locker_favorites_user_id", columnList = "user_id"), + @Index(name = "idx_user_locker_favorites_locker_id", columnList = "locker_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserLockerFavoriteEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "locker_id", nullable = false) + private LockerEntity locker; + + @Column(nullable = false) + private int displayOrder; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public UserLockerFavoriteEntity(UserEntity user, LockerEntity locker, int displayOrder) { + this.user = user; + this.locker = locker; + this.displayOrder = displayOrder; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1b8d482..4916708 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -67,6 +67,12 @@ jwt: access-token-expiration-seconds: 900 refresh-token-expiration-seconds: 2592000 +locker: + favorite: + default-origin: + latitude: ${LOCKER_FAVORITE_DEFAULT_LATITUDE:37.497942} + longitude: ${LOCKER_FAVORITE_DEFAULT_LONGITUDE:127.027621} + springdoc: swagger-ui: display-request-duration: true diff --git a/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java new file mode 100644 index 0000000..43440c8 --- /dev/null +++ b/src/test/java/com/zimdugo/locker/entrypoint/LockerFavoriteControllerTest.java @@ -0,0 +1,268 @@ +package com.zimdugo.locker.entrypoint; + +import com.zimdugo.auth.entrypoint.JwtAuthenticationFilter; +import com.zimdugo.auth.entrypoint.OAuth2CallbackUrlCaptureFilter; +import com.zimdugo.common.config.SecurityConfig; +import com.zimdugo.locker.application.FavoriteLockerCommandService; +import com.zimdugo.locker.application.FavoriteLockerItemResponse; +import com.zimdugo.locker.application.FavoriteLockerQueryService; +import com.zimdugo.locker.application.FavoriteLockerResponse; +import com.zimdugo.locker.application.FavoriteLockerStatusResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration; +import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + controllers = LockerFavoriteController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = SecurityConfig.class + ), + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + SecurityFilterAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class + } +) +@AutoConfigureMockMvc(addFilters = false) +class LockerFavoriteControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private FavoriteLockerQueryService favoriteLockerQueryService; + + @MockitoBean + private FavoriteLockerCommandService favoriteLockerCommandService; + + @MockitoBean + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @MockitoBean + private OAuth2CallbackUrlCaptureFilter oAuth2CallbackUrlCaptureFilter; + + @Test + @DisplayName("인증된 사용자의 즐겨찾기 보관함 목록을 최신순 페이지로 조회한다") + void getMyFavoriteLockersReturnsOk() throws Exception { + given(favoriteLockerQueryService.getFavorites(1L, 0, 20, 37.555, 126.922)) + .willReturn(new FavoriteLockerResponse( + 11, + 0, + 20, + false, + List.of(new FavoriteLockerItemResponse( + 10L, + "홍대입구역 보관함", + "서울 마포구 양화로 160", + 37.556, + 126.923, + LocalDateTime.of(2026, 5, 11, 10, 30), + LocalDateTime.of(2026, 5, 10, 18, 0), + 120L + )) + )); + + mockMvc.perform(get("/api/v1/me/favorite-lockers") + .param("lat", "37.555") + .param("lng", "126.922") + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")) + .andExpect(jsonPath("$.data.totalCount").value(11)) + .andExpect(jsonPath("$.data.items[0].lockerId").value(10)) + .andExpect(jsonPath("$.data.items[0].poiName").value("홍대입구역 보관함")) + .andExpect(jsonPath("$.data.items[0].roadAddress").value("서울 마포구 양화로 160")) + .andExpect(jsonPath("$.data.items[0].distanceMeters").value(120)) + .andExpect(jsonPath("$.data.items[0].lastCompletedVoteAt").exists()); + } + + @Test + @DisplayName("인증 정보 없이 즐겨찾기 목록을 조회하면 401을 반환한다") + void getMyFavoriteLockersWithoutAuthenticationReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + + @Test + @DisplayName("숫자가 아닌 인증 이름이면 401을 반환한다") + void getMyFavoriteLockersWithNonNumericAuthenticationNameReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers") + .principal(authenticationWithName("user-name"))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + + @Test + @DisplayName("특정 보관함의 즐겨찾기 상태를 조회한다") + void getFavoriteLockerStatusReturnsOk() throws Exception { + given(favoriteLockerQueryService.getFavoriteStatus(1L, 10L)) + .willReturn(new FavoriteLockerStatusResponse(10L, true)); + + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")) + .andExpect(jsonPath("$.data.lockerId").value(10)) + .andExpect(jsonPath("$.data.favorite").value(true)); + } + + @Test + @DisplayName("삭제된 보관함은 즐겨찾기 상태 조회에서 미등록으로 본다") + void getFavoriteLockerStatusReturnsFalseForDeletedLocker() throws Exception { + given(favoriteLockerQueryService.getFavoriteStatus(1L, 99L)) + .willReturn(new FavoriteLockerStatusResponse(99L, false)); + + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 99L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.lockerId").value(99)) + .andExpect(jsonPath("$.data.favorite").value(false)); + } + + @Test + @DisplayName("0 이하 lockerId로 상태 조회하면 400을 반환한다") + void getFavoriteLockerStatusWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + @Test + @DisplayName("인증 정보 없이 즐겨찾기 상태를 조회하면 401을 반환한다") + void getFavoriteLockerStatusWithoutAuthenticationReturnsUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/me/favorite-lockers/{lockerId}/status", 10L)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A4011")) + .andExpect(jsonPath("$.message").value("auth.authenticated_user_not_found")); + } + + @Test + @DisplayName("즐겨찾기 순서를 변경한다") + void reorderFavoriteLockersReturnsOk() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [20, 10, 30] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).reorder(1L, List.of(20L, 10L, 30L)); + } + + @Test + @DisplayName("비어 있는 순서 변경 요청은 400을 반환한다") + void reorderFavoriteLockersWithEmptyIdsReturnsBadRequest() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + @Test + @DisplayName("음수 lockerId가 포함된 순서 변경 요청은 400을 반환한다") + void reorderFavoriteLockersWithNegativeLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(patch("/api/v1/me/favorite-lockers/order") + .principal(authenticatedUser()) + .contentType("application/json") + .content(""" + { + "lockerIds": [20, -1, 30] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + @Test + @DisplayName("보관함을 즐겨찾기로 등록한다") + void addFavoriteLockerReturnsOk() throws Exception { + mockMvc.perform(post("/api/v1/me/favorite-lockers/{lockerId}", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).add(1L, 10L); + } + + @Test + @DisplayName("0 이하 lockerId로 즐겨찾기 등록하면 400을 반환한다") + void addFavoriteLockerWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(post("/api/v1/me/favorite-lockers/{lockerId}", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + @Test + @DisplayName("보관함 즐겨찾기를 해제한다") + void removeFavoriteLockerReturnsOk() throws Exception { + mockMvc.perform(delete("/api/v1/me/favorite-lockers/{lockerId}", 10L) + .principal(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("S200")); + + verify(favoriteLockerCommandService).remove(1L, 10L); + } + + @Test + @DisplayName("0 이하 lockerId로 즐겨찾기 해제하면 400을 반환한다") + void removeFavoriteLockerWithNonPositiveLockerIdReturnsBadRequest() throws Exception { + mockMvc.perform(delete("/api/v1/me/favorite-lockers/{lockerId}", 0L) + .principal(authenticatedUser())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C400")); + } + + private UsernamePasswordAuthenticationToken authenticatedUser() { + return authenticationWithName("1"); + } + + private UsernamePasswordAuthenticationToken authenticationWithName(String name) { + return new UsernamePasswordAuthenticationToken( + name, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + } +} diff --git a/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java new file mode 100644 index 0000000..2acf65c --- /dev/null +++ b/src/test/java/com/zimdugo/locker/infrastructure/FavoriteLockerReaderAdapterTest.java @@ -0,0 +1,123 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.domain.FavoriteLockerPage; +import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import com.zimdugo.user.domain.UserRole; +import com.zimdugo.user.domain.UserStatus; +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class FavoriteLockerReaderAdapterTest { + + @Mock + private UserLockerFavoriteRepository userLockerFavoriteRepository; + + @Mock + private LockerReportRepository lockerReportRepository; + + @Mock + private LockerRepository lockerRepository; + + @Test + @DisplayName("현재 위치가 없으면 기본 위치로 거리를 계산하고 최신 완료 제보 시각을 반영한다") + void findByUserIdUsesConfiguredDefaultOriginAndLatestCompletedVoteAt() { + double defaultLatitude = 37.497942; + double defaultLongitude = 127.027621; + FavoriteLockerReaderAdapter adapter = new FavoriteLockerReaderAdapter( + userLockerFavoriteRepository, + lockerReportRepository, + lockerRepository, + new FavoriteLockerProperties( + new FavoriteLockerProperties.DefaultOrigin(defaultLatitude, defaultLongitude) + ) + ); + + UserLockerFavoriteEntity favorite = favoriteLocker(10L, 37.556, 126.923); + LocalDateTime lastCompletedVoteAt = LocalDateTime.of(2026, 5, 13, 19, 45); + + given(userLockerFavoriteRepository.findActiveFavoritesByUserId( + 1L, + PageRequest.of(0, 20) + )) + .willReturn(new PageImpl<>(List.of(favorite), PageRequest.of(0, 20), 1)); + given(lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(List.of(10L))) + .willReturn(List.of(latestUpdateProjection(10L, lastCompletedVoteAt))); + given(lockerRepository.findDistancesByLockerIds(defaultLatitude, defaultLongitude, List.of(10L))) + .willReturn(List.of(distanceProjection(10L, 14_352L))); + + FavoriteLockerPage result = adapter.findByUserId(1L, 0, 20, null, null); + + assertThat(result.favorites()).hasSize(1); + assertThat(result.favorites().get(0).distanceMeters()).isEqualTo(14_352L); + assertThat(result.favorites().get(0).lastCompletedVoteAt()).isEqualTo(lastCompletedVoteAt); + } + + private UserLockerFavoriteEntity favoriteLocker(Long lockerId, double latitude, double longitude) { + UserEntity user = new UserEntity( + null, + "favorite-reader@example.com", + "favorite-reader", + null, + UserStatus.ACTIVE, + UserRole.USER, + LocalDateTime.now(), + LocalDateTime.now() + ); + LockerEntity locker = new LockerEntity( + "홍대입구역 보관함", + "서울 마포구 양화로 160", + latitude, + longitude + ); + ReflectionTestUtils.setField(locker, "id", lockerId); + return new UserLockerFavoriteEntity(user, locker, 0); + } + + private LockerReportLatestUpdateProjection latestUpdateProjection( + Long lockerId, + LocalDateTime lastCompletedVoteAt + ) { + return new LockerReportLatestUpdateProjection() { + @Override + public Long getLockerId() { + return lockerId; + } + + @Override + public LocalDateTime getLastCompletedVoteAt() { + return lastCompletedVoteAt; + } + }; + } + + private LockerDistanceProjection distanceProjection( + Long lockerId, + Long distanceMeters + ) { + return new LockerDistanceProjection() { + @Override + public Long getLockerId() { + return lockerId; + } + + @Override + public Long getDistanceMeters() { + return distanceMeters; + } + }; + } +} diff --git a/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java index ff970ab..e603998 100644 --- a/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java +++ b/src/test/java/com/zimdugo/locker/infrastructure/LockerReportRepositoryTest.java @@ -9,6 +9,7 @@ import com.zimdugo.user.infrastructure.persistence.UserEntity; import jakarta.persistence.EntityManager; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +30,7 @@ class LockerReportRepositoryTest { @DisplayName("선택 입력값이 없어도 제보 원본 정보를 저장한다") void saveReportWithOptionalFieldsNull() { UserEntity user = saveUser(); - LockerEntity locker = saveLocker(); + LockerEntity locker = saveLocker("홍대입구역 보관함"); LockerReportEntity report = lockerReportRepository.save(new LockerReportEntity( locker, @@ -66,11 +67,74 @@ void saveReportWithOptionalFieldsNull() { assertThat(savedReport.getUpdatedAt()).isNotNull(); } + @Test + @DisplayName("보관함별 최신 완료 제보 시각을 조회한다") + void findLatestCompletedVoteAtByLockerIdInReturnsLatestUpdatedAt() { + UserEntity user = saveUser("vote-user@example.com", "vote-user"); + LockerEntity targetLocker = saveLocker("대상 보관함"); + LockerEntity otherLocker = saveLocker("다른 보관함"); + + LockerReportEntity olderReport = saveReport(user, targetLocker, "이전 제보"); + LockerReportEntity latestReport = saveReport(user, targetLocker, "최신 제보"); + LockerReportEntity otherReport = saveReport(user, otherLocker, "다른 제보"); + entityManager.flush(); + + updateUpdatedAt(olderReport.getId(), LocalDateTime.of(2026, 5, 10, 10, 0)); + updateUpdatedAt(latestReport.getId(), LocalDateTime.of(2026, 5, 13, 19, 30)); + updateUpdatedAt(otherReport.getId(), LocalDateTime.of(2026, 5, 9, 9, 0)); + entityManager.clear(); + + List result = + lockerReportRepository.findLatestCompletedVoteAtByLockerIdIn(List.of(targetLocker.getId())); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getLockerId()).isEqualTo(targetLocker.getId()); + assertThat(result.get(0).getLastCompletedVoteAt()).isEqualTo(LocalDateTime.of(2026, 5, 13, 19, 30)); + } + + private LockerReportEntity saveReport(UserEntity user, LockerEntity locker, String name) { + LockerReportEntity report = new LockerReportEntity( + locker, + user, + DuplicateHandlingType.CREATE_NEW, + name, + "서울 마포구 양화로 160", + null, + null, + null, + null, + null, + null, + null, + null, + null, + 37.556, + 126.923 + ); + entityManager.persist(report); + return report; + } + + private void updateUpdatedAt(Long reportId, LocalDateTime updatedAt) { + entityManager.createNativeQuery(""" + UPDATE locker_reports + SET updated_at = :updatedAt + WHERE id = :reportId + """) + .setParameter("updatedAt", updatedAt) + .setParameter("reportId", reportId) + .executeUpdate(); + } + private UserEntity saveUser() { + return saveUser("reporter@example.com", "reporter"); + } + + private UserEntity saveUser(String email, String nickname) { UserEntity user = new UserEntity( null, - "reporter@example.com", - "reporter", + email, + nickname, null, UserStatus.ACTIVE, UserRole.USER, @@ -81,18 +145,14 @@ private UserEntity saveUser() { return user; } - private LockerEntity saveLocker() { - entityManager.createNativeQuery(""" - INSERT INTO lockers (name, road_address, latitude, longitude) - VALUES ('홍대입구역 보관함', '서울 마포구 양화로 160', 37.556, 126.923) - """).executeUpdate(); - - Long lockerId = ((Number) entityManager.createNativeQuery(""" - SELECT id - FROM lockers - WHERE name = '홍대입구역 보관함' - """).getSingleResult()).longValue(); - - return entityManager.getReference(LockerEntity.class, lockerId); + private LockerEntity saveLocker(String name) { + LockerEntity locker = new LockerEntity( + name, + "서울 마포구 양화로 160", + 37.556, + 126.923 + ); + entityManager.persist(locker); + return locker; } } diff --git a/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java new file mode 100644 index 0000000..8348a65 --- /dev/null +++ b/src/test/java/com/zimdugo/locker/infrastructure/UserLockerFavoriteRepositoryTest.java @@ -0,0 +1,198 @@ +package com.zimdugo.locker.infrastructure; + +import com.zimdugo.locker.infrastructure.persistence.LockerEntity; +import com.zimdugo.locker.infrastructure.persistence.UserLockerFavoriteEntity; +import com.zimdugo.user.domain.UserRole; +import com.zimdugo.user.domain.UserStatus; +import com.zimdugo.user.infrastructure.persistence.UserEntity; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class UserLockerFavoriteRepositoryTest { + + @Autowired + private UserLockerFavoriteRepository userLockerFavoriteRepository; + + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("사용자별 즐겨찾기 보관함을 표시 순서 기준으로 조회한다") + void findByUserIdOrderByDisplayOrderAscCreatedAtDescReturnsOnlyUserFavorites() { + UserEntity user = saveUser("favorite-user@example.com", "favorite-user"); + UserEntity otherUser = saveUser("other-favorite-user@example.com", "other-favorite-user"); + LockerEntity firstLocker = saveLocker("첫 번째 보관함"); + LockerEntity secondLocker = saveLocker("두 번째 보관함"); + LockerEntity otherLocker = saveLocker("다른 사용자의 보관함"); + + saveFavorite(user, firstLocker, 1); + saveFavorite(user, secondLocker, 0); + saveFavorite(otherUser, otherLocker, 0); + entityManager.flush(); + entityManager.clear(); + + Page result = + userLockerFavoriteRepository.findActiveFavoritesByUserId( + user.getId(), + PageRequest.of(0, 10) + ); + + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getContent()) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("두 번째 보관함", "첫 번째 보관함"); + } + + @Test + @DisplayName("삭제된 보관함은 즐겨찾기 목록 조회에서 제외된다") + void findByUserIdExcludesDeletedLockers() { + UserEntity user = saveUser("deleted-locker-user@example.com", "deleted-locker-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 0); + saveFavorite(user, deletedLocker, 1); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + Page result = + userLockerFavoriteRepository.findActiveFavoritesByUserId( + user.getId(), + PageRequest.of(0, 10) + ); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("정상 보관함"); + } + + @Test + @DisplayName("사용자와 보관함 ID로 즐겨찾기를 삭제한다") + void deleteByUserIdAndLockerIdDeletesFavorite() { + UserEntity user = saveUser("delete-favorite-user@example.com", "delete-favorite-user"); + LockerEntity locker = saveLocker("삭제 대상 보관함"); + saveFavorite(user, locker, 0); + entityManager.flush(); + + userLockerFavoriteRepository.deleteByUserIdAndLockerId(user.getId(), locker.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userLockerFavoriteRepository.countActiveFavoritesByUserIdAndLockerId( + user.getId(), + locker.getId() + )) + .isZero(); + } + + @Test + @DisplayName("주어진 보관함 ID 목록으로 사용자의 즐겨찾기를 조회한다") + void findByUserIdAndLockerIdInReturnsMatchingFavorites() { + UserEntity user = saveUser("match-user@example.com", "match-user"); + LockerEntity firstLocker = saveLocker("첫 번째 보관함"); + LockerEntity secondLocker = saveLocker("두 번째 보관함"); + LockerEntity thirdLocker = saveLocker("세 번째 보관함"); + + saveFavorite(user, firstLocker, 0); + saveFavorite(user, secondLocker, 1); + saveFavorite(user, thirdLocker, 2); + entityManager.flush(); + entityManager.clear(); + + List favorites = userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds( + user.getId(), + List.of(firstLocker.getId(), thirdLocker.getId()) + ); + + assertThat(favorites) + .extracting(favorite -> favorite.getLocker().getId()) + .containsExactlyInAnyOrder(firstLocker.getId(), thirdLocker.getId()); + } + + @Test + @DisplayName("삭제된 보관함은 reorder 대상 조회와 개수에서 제외된다") + void activeFavoriteQueriesExcludeDeletedLockers() { + UserEntity user = saveUser("reorder-user@example.com", "reorder-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 0); + saveFavorite(user, deletedLocker, 1); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + List favorites = + userLockerFavoriteRepository.findActiveFavoritesByUserIdAndLockerIds( + user.getId(), + List.of(activeLocker.getId(), deletedLocker.getId()) + ); + + assertThat(userLockerFavoriteRepository.countActiveFavoritesByUserId(user.getId())).isEqualTo(1); + assertThat(favorites) + .extracting(favorite -> favorite.getLocker().getName()) + .containsExactly("정상 보관함"); + } + + @Test + @DisplayName("삭제된 보관함은 최대 displayOrder 조회에서 제외된다") + void findMaxDisplayOrderAmongActiveFavoritesByUserIdExcludesDeletedLocker() { + UserEntity user = saveUser("display-order-user@example.com", "display-order-user"); + LockerEntity activeLocker = saveLocker("정상 보관함"); + LockerEntity deletedLocker = saveLocker("삭제된 보관함"); + + saveFavorite(user, activeLocker, 1); + saveFavorite(user, deletedLocker, 5); + deletedLocker.markDeleted(); + entityManager.flush(); + entityManager.clear(); + + Integer result = userLockerFavoriteRepository.findMaxDisplayOrderAmongActiveFavoritesByUserId(user.getId()); + + assertThat(result).isEqualTo(1); + } + + private UserLockerFavoriteEntity saveFavorite(UserEntity user, LockerEntity locker, int displayOrder) { + UserLockerFavoriteEntity favorite = new UserLockerFavoriteEntity(user, locker, displayOrder); + entityManager.persist(favorite); + return favorite; + } + + private UserEntity saveUser(String email, String nickname) { + UserEntity user = new UserEntity( + null, + email, + nickname, + null, + UserStatus.ACTIVE, + UserRole.USER, + LocalDateTime.now(), + LocalDateTime.now() + ); + entityManager.persist(user); + return user; + } + + private LockerEntity saveLocker(String name) { + LockerEntity locker = new LockerEntity( + name, + "서울 마포구 양화로 160", + 37.556, + 126.923 + ); + entityManager.persist(locker); + return locker; + } +}