diff --git a/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepository.java b/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepository.java index 033fb5ad..05b5120d 100644 --- a/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepository.java @@ -10,4 +10,6 @@ public interface CommentRepository extends JpaRepository, Comment @Modifying @Query("delete from Comment c where c.comment.id = :parentId") void deleteReplies(Long parentId); + + long countByHistoryIdAndBannedFalse(Long historyId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java b/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java index a252514b..ae805cff 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java @@ -7,7 +7,11 @@ import org.clokey.code.GlobalBaseSuccessCode; import org.clokey.domain.history.dto.request.HistoryCreateRequest; import org.clokey.domain.history.dto.request.HistoryUpdateRequest; +import org.clokey.domain.history.dto.response.DailyHistoryResponse; +import org.clokey.domain.history.dto.response.HistoryClothTagListResponse; import org.clokey.domain.history.dto.response.HistoryCreateResponse; +import org.clokey.domain.history.dto.response.HistoryOwnershipCheckResponse; +import org.clokey.domain.history.dto.response.MonthlyHistoryResponse; import org.clokey.domain.history.dto.response.SituationListResponse; import org.clokey.domain.history.dto.response.StyleListResponse; import org.clokey.domain.history.service.HistoryService; @@ -65,4 +69,47 @@ public BaseResponse getAllSituations() { SituationListResponse response = historyService.getAllSituations(); return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } + + @GetMapping("/{historyId}") + @Operation( + operationId = "History_getHistoryDetails", + summary = "일별 기록 조회", + description = "기록 ID를 통해 일별 기록의 정보를 조회합니다.") + public BaseResponse getDailyHistory(@PathVariable Long historyId) { + DailyHistoryResponse response = historyService.getDailyHistory(historyId); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/cloth-tag/{historyImageId}") + @Operation( + operationId = "History_getHistoryClothTags", + summary = "기록 이미지의 옷 태그 조회", + description = "기록 이미지 ID를 통해 해당 이미지에 태그된 옷들의 정보와 위치를 조회합니다.") + public BaseResponse getHistoryClothTags( + @PathVariable Long historyImageId) { + HistoryClothTagListResponse response = historyService.getHistoryClothTags(historyImageId); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/monthly/{memberId}") + @Operation( + operationId = "History_getMonthlyHistory", + summary = "월별 기록 조회", + description = "특정 회원의 특정 년도/월에 해당하는 모든 기록의 ID와 첫 번째 이미지 URL을 조회합니다.") + public BaseResponse getMonthlyHistory( + @PathVariable Long memberId, @RequestParam int year, @RequestParam int month) { + MonthlyHistoryResponse response = historyService.getMonthlyHistory(memberId, year, month); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/{historyId}/ownership") + @Operation( + operationId = "History_checkHistoryOwnership", + summary = "나의 기록 여부 확인", + description = "기록 ID를 통해 해당 기록이 현재 사용자의 기록인지 확인합니다.") + public BaseResponse checkHistoryOwnership( + @PathVariable Long historyId) { + HistoryOwnershipCheckResponse response = historyService.checkHistoryOwnership(historyId); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/DailyHistoryResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/DailyHistoryResponse.java new file mode 100644 index 00000000..29fd112e --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/DailyHistoryResponse.java @@ -0,0 +1,54 @@ +package org.clokey.domain.history.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +public record DailyHistoryResponse( + @Schema(description = "회원 ID", example = "1") Long memberId, + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + String profileImageUrl, + @Schema(description = "닉네임", example = "포테토남") String nickname, + @Schema(description = "기록 이미지 목록") List images, + @Schema(description = "좋아요 개수", example = "10") Long likeCount, + @Schema(description = "댓글 개수", example = "5") Long commentCount, + @Schema(description = "기록 날짜", example = "2025-01-01") LocalDate historyDate, + @Schema(description = "상황 ID", example = "1") Long situationId, + @Schema(description = "상황 이름", example = "데일리") String situationName, + @Schema(description = "스타일 목록") List styles) { + + public static DailyHistoryResponse of( + Long memberId, + String profileImageUrl, + String nickname, + List images, + Long likeCount, + Long commentCount, + LocalDate historyDate, + Long situationId, + String situationName, + List styles) { + return new DailyHistoryResponse( + memberId, + profileImageUrl, + nickname, + images, + likeCount, + commentCount, + historyDate, + situationId, + situationName, + styles); + } + + @Schema(name = "DailyHistoryResponseImagePayload", description = "기록 이미지 정보") + public record ImagePayload( + @Schema(description = "이미지 ID", example = "1") Long imageId, + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String imageUrl) {} + + @Schema(name = "DailyHistoryResponseStylePayload", description = "스타일 정보") + public record StylePayload( + @Schema(description = "스타일 ID", example = "1") Long styleId, + @Schema(description = "스타일 이름", example = "캐주얼") String styleName) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryClothTagListResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryClothTagListResponse.java new file mode 100644 index 00000000..525fd8c9 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryClothTagListResponse.java @@ -0,0 +1,23 @@ +package org.clokey.domain.history.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record HistoryClothTagListResponse( + @Schema(description = "옷 태그 목록") List payloads) { + + public static HistoryClothTagListResponse of(List payloads) { + return new HistoryClothTagListResponse(payloads); + } + + @Schema(name = "HistoryClothTagListResponsePayload", description = "옷 태그 정보") + public record Payload( + @Schema(description = "옷 태그 ID", example = "1") Long historyClothTagId, + @Schema(description = "옷 ID", example = "1") Long clothId, + @Schema(description = "옷 이미지 URL", example = "https://example.com/image.jpg") + String clothImageUrl, + @Schema(description = "옷 이름", example = "맨투맨") String name, + @Schema(description = "옷 브랜드", example = "나이키") String brand, + @Schema(description = "X 좌표", example = "0.5") Double locationX, + @Schema(description = "Y 좌표", example = "0.7") Double locationY) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryOwnershipCheckResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryOwnershipCheckResponse.java new file mode 100644 index 00000000..e701c262 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryOwnershipCheckResponse.java @@ -0,0 +1,10 @@ +package org.clokey.domain.history.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record HistoryOwnershipCheckResponse( + @Schema(description = "내 기록 여부", example = "true") boolean isOwner) { + public static HistoryOwnershipCheckResponse of(boolean isOwner) { + return new HistoryOwnershipCheckResponse(isOwner); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/MonthlyHistoryResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/MonthlyHistoryResponse.java new file mode 100644 index 00000000..6e9f1021 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/MonthlyHistoryResponse.java @@ -0,0 +1,18 @@ +package org.clokey.domain.history.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record MonthlyHistoryResponse( + @Schema(description = "기록 목록") List payloads) { + + public static MonthlyHistoryResponse of(List payloads) { + return new MonthlyHistoryResponse(payloads); + } + + @Schema(name = "MonthlyHistoryResponsePayload", description = "월별 기록 정보") + public record Payload( + @Schema(description = "기록 ID", example = "1") Long historyId, + @Schema(description = "첫 번째 이미지 URL", example = "https://example.com/image.jpg") + String firstImageUrl) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/history/exception/HistoryErrorCode.java b/clokey-api/src/main/java/org/clokey/domain/history/exception/HistoryErrorCode.java index 335df566..673a3e2e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/exception/HistoryErrorCode.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/exception/HistoryErrorCode.java @@ -8,8 +8,13 @@ @Getter @AllArgsConstructor public enum HistoryErrorCode implements BaseErrorCode { + BANNED_HISTORY(400, "HISTORY_4001", "신고당한 기록은 조회할 수 없습니다."), + + LIMITED_AUTHORITY(403, "HISTORY_4031", "기록에 대한 접근 권한이 없습니다."), + BLOCKED_AUTHORITY(403, "HISTORY_4032", "기록 작성자를 차단했거나 차단 당했습니다"), + HISTORY_NOT_FOUND(404, "HISTORY_4041", "존재하지 않는 기록입니다."), - LIMITED_AUTHORITY(403, "HISTORY_4031", "기록에 대한 접근 권한이 없습니다."); + HISTORY_IMAGE_NOT_FOUND(404, "HISTORY_4042", "존재하지 않는 기록 이미지입니다."); private final int status; private final String code; diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryClothTagRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryClothTagRepository.java index 00b2f94b..e1eb32a4 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryClothTagRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryClothTagRepository.java @@ -15,4 +15,10 @@ public interface HistoryClothTagRepository @Modifying @Query("DELETE FROM HistoryClothTag hct WHERE hct.cloth.id = :clothId") void deleteAllByClothId(Long clothId); + + @Query( + "select hct from HistoryClothTag hct " + + "join fetch hct.cloth " + + "where hct.historyImage.id = :historyImageId") + List findAllByHistoryImageIdWithCloth(Long historyImageId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java index 75f9e4dc..6f2a2524 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java @@ -3,7 +3,22 @@ import java.util.List; import org.clokey.history.entity.HistoryImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface HistoryImageRepository extends JpaRepository { + @Query( + """ + SELECT hi.history.id, hi.imageUrl + FROM HistoryImage hi + WHERE hi.history.id IN :historyIds + AND hi.id = ( + SELECT MIN(h2.id) + FROM HistoryImage h2 + WHERE h2.history.id = hi.history.id + ) + """) + List getFirstImageUrlsWithHistoryId(@Param("historyIds") List historyIds); + List findByHistoryId(Long historyId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java index 69909dda..31e829ef 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryRepository.java @@ -1,8 +1,25 @@ package org.clokey.domain.history.repository; +import java.util.List; +import java.util.Optional; import org.clokey.history.entity.History; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface HistoryRepository extends JpaRepository { + @Query("SELECT h FROM History h JOIN FETCH h.member m WHERE h.id = :id") + Optional findByIdWithMember(Long id); + boolean existsByMemberId(Long memberId); + + @Query( + """ + select h from History h + where h.member.id = :memberId + and FUNCTION('YEAR', h.historyDate) = :year + and FUNCTION('MONTH', h.historyDate) = :month + and h.banned = false + order by h.historyDate asc + """) + List findByMemberIdAndYearAndMonthNotBanned(Long memberId, int year, int month); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryService.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryService.java index a2462110..42f1bb34 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryService.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryService.java @@ -2,9 +2,7 @@ import org.clokey.domain.history.dto.request.HistoryCreateRequest; import org.clokey.domain.history.dto.request.HistoryUpdateRequest; -import org.clokey.domain.history.dto.response.HistoryCreateResponse; -import org.clokey.domain.history.dto.response.SituationListResponse; -import org.clokey.domain.history.dto.response.StyleListResponse; +import org.clokey.domain.history.dto.response.*; public interface HistoryService { HistoryCreateResponse createHistory(HistoryCreateRequest request); @@ -14,4 +12,12 @@ public interface HistoryService { StyleListResponse getAllStyles(); SituationListResponse getAllSituations(); + + DailyHistoryResponse getDailyHistory(Long historyId); + + HistoryClothTagListResponse getHistoryClothTags(Long historyImageId); + + MonthlyHistoryResponse getMonthlyHistory(Long memberId, int year, int month); + + HistoryOwnershipCheckResponse checkHistoryOwnership(Long historyId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index a2098faf..7ce58c7d 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -8,19 +8,29 @@ import org.clokey.cloth.entity.Cloth; import org.clokey.domain.cloth.exception.ClothErrorCode; import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.comment.repository.CommentRepository; import org.clokey.domain.history.dto.request.HistoryCreateRequest; import org.clokey.domain.history.dto.request.HistoryUpdateRequest; +import org.clokey.domain.history.dto.response.DailyHistoryResponse; +import org.clokey.domain.history.dto.response.HistoryClothTagListResponse; import org.clokey.domain.history.dto.response.HistoryCreateResponse; +import org.clokey.domain.history.dto.response.HistoryOwnershipCheckResponse; +import org.clokey.domain.history.dto.response.MonthlyHistoryResponse; import org.clokey.domain.history.dto.response.SituationListResponse; import org.clokey.domain.history.dto.response.StyleListResponse; import org.clokey.domain.history.exception.HistoryErrorCode; import org.clokey.domain.history.exception.SituationErrorCode; import org.clokey.domain.history.exception.StyleErrorCode; import org.clokey.domain.history.repository.*; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.report.repository.ReportRepository; import org.clokey.exception.BaseCustomException; import org.clokey.global.util.MemberUtil; import org.clokey.history.entity.*; import org.clokey.member.entity.Member; +import org.clokey.member.enums.Visibility; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,6 +50,11 @@ public class HistoryServiceImpl implements HistoryService { private final SituationRepository situationRepository; private final HistoryImageRepository historyImageRepository; private final HistoryClothTagRepository historyClothTagRepository; + private final MemberLikeRepository memberLikeRepository; + private final CommentRepository commentRepository; + private final BlockRepository blockRepository; + private final ReportRepository reportRepository; + private final MemberRepository memberRepository; @Override @Transactional @@ -193,6 +208,116 @@ public SituationListResponse getAllSituations() { return situationRepository.findAllSituations(); } + @Override + public DailyHistoryResponse getDailyHistory(Long historyId) { + final Member currentMember = memberUtil.getCurrentMember(); + final History history = getHistoryById(historyId); + + validateBlockedAccess(currentMember.getId(), history); + + List images = + history.getHistoryImages().stream() + .map( + image -> + new DailyHistoryResponse.ImagePayload( + image.getId(), image.getImageUrl())) + .toList(); + + long likeCount = memberLikeRepository.countByHistoryId(historyId); + + long commentCount = commentRepository.countByHistoryIdAndBannedFalse(historyId); + + List styles = + history.getHistoryStyles().stream() + .map( + historyStyle -> + new DailyHistoryResponse.StylePayload( + historyStyle.getStyle().getId(), + historyStyle.getStyle().getName())) + .toList(); + + return DailyHistoryResponse.of( + history.getMember().getId(), + history.getMember().getProfileImageUrl(), + history.getMember().getNickname(), + images, + likeCount, + commentCount, + history.getHistoryDate(), + history.getSituation().getId(), + history.getSituation().getName(), + styles); + } + + @Override + public HistoryClothTagListResponse getHistoryClothTags(Long historyImageId) { + getHistoryImageById(historyImageId); + + List historyClothTags = + historyClothTagRepository.findAllByHistoryImageIdWithCloth(historyImageId); + + List payloads = + historyClothTags.stream() + .map( + tag -> + new HistoryClothTagListResponse.Payload( + tag.getId(), + tag.getCloth().getId(), + tag.getCloth().getClothImageUrl(), + tag.getCloth().getName(), + tag.getCloth().getBrand(), + tag.getLocation().getLocationX(), + tag.getLocation().getLocationY())) + .toList(); + + return HistoryClothTagListResponse.of(payloads); + } + + @Override + public MonthlyHistoryResponse getMonthlyHistory(Long memberId, int year, int month) { + final Member currentMember = memberUtil.getCurrentMember(); + final Member targetMember = getMemberById(memberId); + + validateMemberAccess(currentMember.getId(), targetMember); + + List histories = + historyRepository.findByMemberIdAndYearAndMonthNotBanned(memberId, year, month); + + List payloads = + histories.stream() + .map( + history -> { + String firstImageUrl = + history.getHistoryImages().isEmpty() + ? null + : history.getHistoryImages() + .get(0) + .getImageUrl(); + return new MonthlyHistoryResponse.Payload( + history.getId(), firstImageUrl); + }) + .toList(); + + return MonthlyHistoryResponse.of(payloads); + } + + @Override + public HistoryOwnershipCheckResponse checkHistoryOwnership(Long historyId) { + final Member currentMember = memberUtil.getCurrentMember(); + final History history = getHistoryById(historyId); + + boolean isOwner = history.getMember().getId().equals(currentMember.getId()); + + return HistoryOwnershipCheckResponse.of(isOwner); + } + + private HistoryImage getHistoryImageById(Long historyImageId) { + return historyImageRepository + .findById(historyImageId) + .orElseThrow( + () -> new BaseCustomException(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND)); + } + private void saveHistoryRelations( History history, List styleIds, @@ -385,4 +510,50 @@ private void deleteClothTagsByHistoryImageIds(List historyImageIds) { historyClothTagRepository.deleteAllInBatch(historyClothTags); } } + + private Member getMemberById(Long memberId) { + return memberRepository + .findById(memberId) + .orElseThrow( + () -> + new BaseCustomException( + org.clokey.domain.member.exception.MemberErrorCode + .MEMBER_NOT_FOUND)); + } + + private void validateMemberAccess(Long currentMemberId, Member targetMember) { + // 유저가 비공개이고 본인이 아닌 경우 접근 불가 + if (targetMember.getVisibility() == Visibility.PRIVATE + && !targetMember.getId().equals(currentMemberId)) { + throw new BaseCustomException(HistoryErrorCode.LIMITED_AUTHORITY); + } + + // 유저를 차단했거나 차단 당한 경우 + if (blockRepository.existsByBlockerIdAndBlockedId(targetMember.getId(), currentMemberId) + || blockRepository.existsByBlockerIdAndBlockedId( + currentMemberId, targetMember.getId())) { + throw new BaseCustomException(HistoryErrorCode.BLOCKED_AUTHORITY); + } + } + + private void validateBlockedAccess(Long currentMemberId, History history) { + // 기록 작성자가 비공개이고 본인 기록이 아닌 경우 접근 불가 + if (history.getMember().getVisibility() == Visibility.PRIVATE + && !history.getMember().getId().equals(currentMemberId)) { + throw new BaseCustomException(HistoryErrorCode.LIMITED_AUTHORITY); + } + + // 기록 작성자를 차단했거나 차단 당한 경우 + if (blockRepository.existsByBlockerIdAndBlockedId( + history.getMember().getId(), currentMemberId) + || blockRepository.existsByBlockerIdAndBlockedId( + currentMemberId, history.getMember().getId())) { + throw new BaseCustomException(HistoryErrorCode.BLOCKED_AUTHORITY); + } + + // 기록이 신고로 인해 ban 당한 경우 + if (history.isBanned()) { + throw new BaseCustomException(HistoryErrorCode.BANNED_HISTORY); + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java new file mode 100644 index 00000000..f69ae9c6 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -0,0 +1,51 @@ +package org.clokey.domain.like.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.global.annotation.PageSize; +import org.clokey.response.BaseResponse; +import org.clokey.response.SliceResponse; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +@Tag(name = "09. 좋아요 API", description = "좋아요 관련 API입니다.") +@Validated +public class LikeController { + + private final LikeService likeService; + + @GetMapping("/histories") + @Operation(summary = "좋아요한 기록 조회", description = "사용자가 좋아요한 기록을 조회합니다.") + public BaseResponse> + getLikedHistories( + @Parameter(description = "이전 페이지의 좋아요 ID (첫 요청 시 생략)") + @RequestParam(required = false) + Long lastLikeId, + @Parameter(description = "페이지당 조회할 개수") @RequestParam @PageSize Integer size) { + SliceResponse response = + likeService.getLikedHistories(lastLikeId, size); + + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @PostMapping + @Operation(operationId = "Like_toggleLike", summary = "좋아요 생성", description = "기록에 좋아요를 추가합니다") + public BaseResponse toggleLike(@RequestParam("historyId") Long historyId) { + + likeService.toggleLike(historyId); + + return BaseResponse.onSuccess(GlobalBaseSuccessCode.NO_CONTENT, null); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java new file mode 100644 index 00000000..b567d8b0 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -0,0 +1,19 @@ +package org.clokey.domain.like.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "좋아요 히스토리 조회 결과 (Slice 기반)") +public record LikedHistoriesResponse( + @Schema(description = "히스토리 미리보기 목록") List historyPreviews, + @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { + + @Schema(description = "히스토리 미리보기 DTO") + public record LikedHistoryPreview( + @Schema(description = "히스토리 ID", example = "30") Long id, + @Schema( + description = "히스토리 대표 이미지 URL", + example = + "https://clokeybucket.s3.ap-northeast-2.amazonaws.com/example.jpg") + String imageUrl) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index a645167e..b96c90da 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,6 +1,36 @@ package org.clokey.domain.like.repository; +import java.util.List; +import java.util.Optional; import org.clokey.like.entity.MemberLike; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface MemberLikeRepository extends JpaRepository {} +public interface MemberLikeRepository extends JpaRepository { + + long countByHistoryId(Long historyId); + + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.member.id = :memberId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikedHistoriesByMemberId( + Long memberId, Long lastLikeId, Pageable pageable); + + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.history.id = :historyId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikeMembersByHistoryId(Long historyId, Long lastLikeId, Pageable pageable); + + Optional findByMemberIdAndHistoryId(Long memberId, Long historyId); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java new file mode 100644 index 00000000..2e2ebea4 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -0,0 +1,11 @@ +package org.clokey.domain.like.service; + +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.response.SliceResponse; + +public interface LikeService { + SliceResponse getLikedHistories( + Long lastLikedId, Integer size); + + void toggleLike(Long historyId); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java new file mode 100644 index 00000000..d74868ad --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -0,0 +1,118 @@ +package org.clokey.domain.like.service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.history.exception.HistoryErrorCode; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.exception.BaseCustomException; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeServiceImpl implements LikeService { + + private final MemberUtil memberUtil; + private final MemberLikeRepository memberLikeRepository; + private final HistoryImageRepository historyImageRepository; + private final HistoryRepository historyRepository; + private final BlockRepository blockRepository; + + @Override + public SliceResponse getLikedHistories( + Long lastLikeId, Integer size) { + + Member currentMember = memberUtil.getCurrentMember(); + + // limit + 1 조회 + Pageable pageable = PageRequest.of(0, size + 1); + + List likes = + memberLikeRepository.findLikedHistoriesByMemberId( + currentMember.getId(), lastLikeId, pageable); + + boolean isLast = likes.size() <= size; + + if (!isLast) { + likes = likes.subList(0, size); + } + + if (likes.isEmpty()) { + return new SliceResponse<>(List.of(), true); + } + + List historyIds = likes.stream().map(like -> like.getHistory().getId()).toList(); + + Map imageMap = findFirstImagesByHistoryIds(historyIds); + + List previews = + likes.stream() + .map( + like -> + new LikedHistoriesResponse.LikedHistoryPreview( + like.getHistory().getId(), + imageMap.get(like.getHistory().getId()))) + .toList(); + + return new SliceResponse<>(previews, isLast); + } + + private Map findFirstImagesByHistoryIds(List historyIds) { + if (historyIds.isEmpty()) return Map.of(); + + List rows = historyImageRepository.getFirstImageUrlsWithHistoryId(historyIds); + + return rows.stream() + .collect( + Collectors.toMap( + row -> ((Number) row[0]).longValue(), row -> (String) row[1])); + } + + @Override + @Transactional + public void toggleLike(Long historyId) { + final Member currentMember = memberUtil.getCurrentMember(); + final History history = + historyRepository + .findByIdWithMember(historyId) + .orElseThrow( + () -> new BaseCustomException(HistoryErrorCode.HISTORY_NOT_FOUND)); + + final Member historyOwner = history.getMember(); + + if (isBlockedByOrBlocking(currentMember.getId(), historyOwner.getId())) { + return; + } + + Optional existingLike = + memberLikeRepository.findByMemberIdAndHistoryId(currentMember.getId(), historyId); + + if (existingLike.isPresent()) { + memberLikeRepository.delete(existingLike.get()); + } else { + MemberLike newLike = MemberLike.createMemberLike(currentMember, history); + memberLikeRepository.save(newLike); + } + } + + private boolean isBlockedByOrBlocking(Long fromId, Long toId) { + return blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( + fromId, toId, + toId, fromId); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/member/repository/BlockRepository.java b/clokey-api/src/main/java/org/clokey/domain/member/repository/BlockRepository.java index 63bebab9..fd16bcdf 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/repository/BlockRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/repository/BlockRepository.java @@ -9,4 +9,7 @@ public interface BlockRepository extends JpaRepository, BlockReposi Optional findByBlockerIdAndBlockedId(Long blockerId, Long blockedId); boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + boolean existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( + Long fromId1, Long toId1, Long fromId2, Long toId2); } diff --git a/clokey-api/src/main/java/org/clokey/domain/report/controller/ReportController.java b/clokey-api/src/main/java/org/clokey/domain/report/controller/ReportController.java index 79e5912f..e6e3c75f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/report/controller/ReportController.java +++ b/clokey-api/src/main/java/org/clokey/domain/report/controller/ReportController.java @@ -8,6 +8,7 @@ import org.clokey.code.GlobalBaseSuccessCode; import org.clokey.domain.report.dto.request.ReportCreateRequest; import org.clokey.domain.report.dto.response.ReportCreateResponse; +import org.clokey.domain.report.dto.response.ReportedCheckResponse; import org.clokey.domain.report.service.ReportService; import org.clokey.response.BaseResponse; import org.springframework.web.bind.annotation.*; @@ -31,4 +32,15 @@ public BaseResponse createNewReport( ReportCreateResponse response = reportService.createReport(reportCreatRequest); return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); } + + @GetMapping("/received") + @Operation( + operationId = "Report_checkReportReceived", + summary = "사용자에 접수된 신고 확인", + description = "사용자에 접수된 신고(미확인 상태)가 있는지 확인합니다.") + public BaseResponse checkReportReceived() { + ReportedCheckResponse response = reportService.checkReportReceived(); + + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/report/dto/response/ReportedCheckResponse.java b/clokey-api/src/main/java/org/clokey/domain/report/dto/response/ReportedCheckResponse.java new file mode 100644 index 00000000..7679ee05 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/report/dto/response/ReportedCheckResponse.java @@ -0,0 +1,13 @@ +package org.clokey.domain.report.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.clokey.report.enums.TargetType; + +public record ReportedCheckResponse( + @Schema(description = "UNCHECKED 상태의 신고가 존재하는지", example = "true") boolean isReported, + @Schema(description = "신고의 타입", example = "TargetType.HISTORY") TargetType targetType) { + + public static ReportedCheckResponse of(boolean isReported, TargetType targetType) { + return new ReportedCheckResponse(isReported, targetType); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/report/repository/ReportRepository.java b/clokey-api/src/main/java/org/clokey/domain/report/repository/ReportRepository.java index 0d3e1890..ff45aab5 100644 --- a/clokey-api/src/main/java/org/clokey/domain/report/repository/ReportRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/report/repository/ReportRepository.java @@ -1,5 +1,6 @@ package org.clokey.domain.report.repository; +import java.util.Optional; import org.clokey.report.entity.Report; import org.clokey.report.enums.ReportStatus; import org.clokey.report.enums.TargetType; @@ -8,4 +9,7 @@ public interface ReportRepository extends JpaRepository { boolean existsByTargetTypeAndTargetIdAndReportStatusIsNot( TargetType targetType, Long TargetId, ReportStatus reportStatus); + + Optional findTopByReported_IdAndReportStatusOrderByCreatedAtDesc( + Long memberId, ReportStatus reportStatus); } diff --git a/clokey-api/src/main/java/org/clokey/domain/report/service/ReportService.java b/clokey-api/src/main/java/org/clokey/domain/report/service/ReportService.java index 04852e22..5d21dee0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/report/service/ReportService.java +++ b/clokey-api/src/main/java/org/clokey/domain/report/service/ReportService.java @@ -2,8 +2,11 @@ import org.clokey.domain.report.dto.request.ReportCreateRequest; import org.clokey.domain.report.dto.response.ReportCreateResponse; +import org.clokey.domain.report.dto.response.ReportedCheckResponse; public interface ReportService { ReportCreateResponse createReport(ReportCreateRequest request); + + ReportedCheckResponse checkReportReceived(); } diff --git a/clokey-api/src/main/java/org/clokey/domain/report/service/ReportServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/report/service/ReportServiceImpl.java index 462a362e..2aed862e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/report/service/ReportServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/report/service/ReportServiceImpl.java @@ -7,6 +7,7 @@ import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.report.dto.request.ReportCreateRequest; import org.clokey.domain.report.dto.response.ReportCreateResponse; +import org.clokey.domain.report.dto.response.ReportedCheckResponse; import org.clokey.domain.report.exception.ReportErrorCode; import org.clokey.domain.report.repository.ReportRepository; import org.clokey.exception.BaseCustomException; @@ -32,14 +33,15 @@ public class ReportServiceImpl implements ReportService { @Override @Transactional public ReportCreateResponse createReport(ReportCreateRequest request) { - final Member reporter = memberUtil.getCurrentMember(); - validateTargetExists(request.targetType(), request.targetId()); + Member reporter = memberUtil.getCurrentMember(); + Member reported = getReportedMember(request.targetType(), request.targetId()); validateDuplicateReport(request); Report report = Report.createReport( request.targetId(), reporter, + reported, request.targetType(), request.reportReason(), request.content()); @@ -49,6 +51,37 @@ public ReportCreateResponse createReport(ReportCreateRequest request) { return ReportCreateResponse.from(report); } + @Override + public ReportedCheckResponse checkReportReceived() { + Member member = memberUtil.getCurrentMember(); + + Report report = + reportRepository + .findTopByReported_IdAndReportStatusOrderByCreatedAtDesc( + member.getId(), ReportStatus.UNCHECKED) + .orElse(null); + + if (report != null) { + return ReportedCheckResponse.of(true, report.getTargetType()); + } + + return ReportedCheckResponse.of(false, null); + } + + private Member getReportedMember(TargetType targetType, Long targetId) { + if (targetType.equals(TargetType.COMMENT)) { + return commentRepository + .findById(targetId) + .orElseThrow(() -> new BaseCustomException(CommentErrorCode.COMMENT_NOT_FOUND)) + .getMember(); + } else { + return historyRepository + .findById(targetId) + .orElseThrow(() -> new BaseCustomException(HistoryErrorCode.HISTORY_NOT_FOUND)) + .getMember(); + } + } + private void validateDuplicateReport(ReportCreateRequest request) { boolean exists = reportRepository.existsByTargetTypeAndTargetIdAndReportStatusIsNot( @@ -58,16 +91,4 @@ private void validateDuplicateReport(ReportCreateRequest request) { throw new BaseCustomException(ReportErrorCode.REPORT_DUPLICATED); } } - - private void validateTargetExists(TargetType targetType, Long targetId) { - if (targetType.equals(TargetType.COMMENT)) { - if (!commentRepository.existsById(targetId)) { - throw new BaseCustomException(CommentErrorCode.COMMENT_NOT_FOUND); - } - } else { - if (!historyRepository.existsById(targetId)) { - throw new BaseCustomException(HistoryErrorCode.HISTORY_NOT_FOUND); - } - } - } } diff --git a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java index 9346d27d..156bf9b4 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java @@ -9,7 +9,11 @@ import java.util.List; import org.clokey.domain.history.dto.request.HistoryCreateRequest; import org.clokey.domain.history.dto.request.HistoryUpdateRequest; +import org.clokey.domain.history.dto.response.DailyHistoryResponse; +import org.clokey.domain.history.dto.response.HistoryClothTagListResponse; import org.clokey.domain.history.dto.response.HistoryCreateResponse; +import org.clokey.domain.history.dto.response.HistoryOwnershipCheckResponse; +import org.clokey.domain.history.dto.response.MonthlyHistoryResponse; import org.clokey.domain.history.dto.response.SituationListResponse; import org.clokey.domain.history.dto.response.StyleListResponse; import org.clokey.domain.history.service.HistoryService; @@ -698,4 +702,178 @@ class 전체_상황_목록_요청_시 { .andExpect(jsonPath("$.result.contents[2].name").value("testSituation3")); } } + + @Nested + class 일별_기록_조회_요청_시 { + + @Test + void 유효한_요청이면_일별_기록을_반환한다() throws Exception { + // given + DailyHistoryResponse testDailyHistoryResponse = + DailyHistoryResponse.of( + 1L, + "testProfileImageUrl", + "testNickname", + List.of( + new DailyHistoryResponse.ImagePayload(1L, "testImageUrl1"), + new DailyHistoryResponse.ImagePayload(2L, "testImageUrl2")), + 10L, + 5L, + java.time.LocalDate.of(2025, 1, 1), + 1L, + "testSituation", + List.of( + new DailyHistoryResponse.StylePayload(1L, "testStyle1"), + new DailyHistoryResponse.StylePayload(2L, "testStyle2"))); + + given(historyService.getDailyHistory(1L)).willReturn(testDailyHistoryResponse); + + // when & then + ResultActions perform = + mockMvc.perform(get("/histories/1").contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.memberId").value(1L)) + .andExpect(jsonPath("$.result.profileImageUrl").value("testProfileImageUrl")) + .andExpect(jsonPath("$.result.nickname").value("testNickname")) + .andExpect(jsonPath("$.result.images").isArray()) + .andExpect(jsonPath("$.result.images.length()").value(2)) + .andExpect(jsonPath("$.result.images[0].imageId").value(1L)) + .andExpect(jsonPath("$.result.images[0].imageUrl").value("testImageUrl1")) + .andExpect(jsonPath("$.result.likeCount").value(10L)) + .andExpect(jsonPath("$.result.commentCount").value(5L)) + .andExpect(jsonPath("$.result.historyDate").value("2025-01-01")) + .andExpect(jsonPath("$.result.situationId").value(1L)) + .andExpect(jsonPath("$.result.situationName").value("testSituation")) + .andExpect(jsonPath("$.result.styles").isArray()) + .andExpect(jsonPath("$.result.styles.length()").value(2)) + .andExpect(jsonPath("$.result.styles[0].styleId").value(1L)) + .andExpect(jsonPath("$.result.styles[0].styleName").value("testStyle1")); + } + } + + @Nested + class 기록_이미지_옷_태그_조회_요청_시 { + + @Test + void 유효한_요청이면_기록_이미지에_태그_된_옷들의_정보를_반환한다() throws Exception { + // given + HistoryClothTagListResponse testHistoryClothTagListResponse = + HistoryClothTagListResponse.of( + List.of( + new HistoryClothTagListResponse.Payload( + 1L, + 1L, + "testClothImageUrl1", + "testClothName1", + "testBrand1", + 0.5, + 0.7), + new HistoryClothTagListResponse.Payload( + 2L, + 2L, + "testClothImageUrl2", + "testClothName2", + "testBrand2", + 0.3, + 0.4))); + + given(historyService.getHistoryClothTags(1L)) + .willReturn(testHistoryClothTagListResponse); + + // when & then + ResultActions perform = + mockMvc.perform( + get("/histories/cloth-tag/1").contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.payloads").isArray()) + .andExpect(jsonPath("$.result.payloads.length()").value(2)) + .andExpect(jsonPath("$.result.payloads[0].historyClothTagId").value(1L)) + .andExpect(jsonPath("$.result.payloads[0].clothId").value(1L)) + .andExpect( + jsonPath("$.result.payloads[0].clothImageUrl") + .value("testClothImageUrl1")) + .andExpect(jsonPath("$.result.payloads[0].name").value("testClothName1")) + .andExpect(jsonPath("$.result.payloads[0].brand").value("testBrand1")) + .andExpect(jsonPath("$.result.payloads[0].locationX").value(0.5)) + .andExpect(jsonPath("$.result.payloads[0].locationY").value(0.7)); + } + } + + @Nested + class 월별_기록_조회_요청_시 { + + @Test + void 유효한_요청이면_대상의_월별_기록을_반환한다() throws Exception { + // given + MonthlyHistoryResponse testMonthlyHistoryResponse = + MonthlyHistoryResponse.of( + List.of( + new MonthlyHistoryResponse.Payload(1L, "testFirstImageUrl1"), + new MonthlyHistoryResponse.Payload(2L, "testFirstImageUrl2"), + new MonthlyHistoryResponse.Payload(3L, "testFirstImageUrl3"))); + + given(historyService.getMonthlyHistory(1L, 2025, 1)) + .willReturn(testMonthlyHistoryResponse); + + // when & then + ResultActions perform = + mockMvc.perform( + get("/histories/monthly/1") + .param("year", String.valueOf(2025)) + .param("month", String.valueOf(1)) + .contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.payloads").isArray()) + .andExpect(jsonPath("$.result.payloads.length()").value(3)) + .andExpect(jsonPath("$.result.payloads[0].historyId").value(1L)) + .andExpect( + jsonPath("$.result.payloads[0].firstImageUrl") + .value("testFirstImageUrl1")) + .andExpect(jsonPath("$.result.payloads[1].historyId").value(2L)) + .andExpect( + jsonPath("$.result.payloads[1].firstImageUrl") + .value("testFirstImageUrl2")) + .andExpect(jsonPath("$.result.payloads[2].historyId").value(3L)) + .andExpect( + jsonPath("$.result.payloads[2].firstImageUrl") + .value("testFirstImageUrl3")); + } + } + + @Nested + class 기록_소유_여부_확인_요청_시 { + + @Test + void testCheckHistoryOwnership() throws Exception { + // given + HistoryOwnershipCheckResponse testHistoryOwnershipCheckResponse = + HistoryOwnershipCheckResponse.of(true); + + given(historyService.checkHistoryOwnership(1L)) + .willReturn(testHistoryOwnershipCheckResponse); + + // when & then + ResultActions perform = + mockMvc.perform( + get("/histories/1/ownership").contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.isOwner").value(true)); + } + } } diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index 339a13df..44941697 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -13,21 +13,29 @@ import org.clokey.domain.category.repository.CategoryRepository; import org.clokey.domain.cloth.exception.ClothErrorCode; import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.comment.repository.CommentRepository; import org.clokey.domain.history.dto.request.HistoryCreateRequest; import org.clokey.domain.history.dto.request.HistoryCreateRequest.ClothTag; import org.clokey.domain.history.dto.request.HistoryCreateRequest.Payload; import org.clokey.domain.history.dto.request.HistoryUpdateRequest; +import org.clokey.domain.history.dto.response.DailyHistoryResponse; +import org.clokey.domain.history.dto.response.HistoryClothTagListResponse; import org.clokey.domain.history.dto.response.HistoryCreateResponse; +import org.clokey.domain.history.dto.response.HistoryOwnershipCheckResponse; +import org.clokey.domain.history.dto.response.MonthlyHistoryResponse; import org.clokey.domain.history.dto.response.SituationListResponse; import org.clokey.domain.history.dto.response.StyleListResponse; import org.clokey.domain.history.exception.HistoryErrorCode; import org.clokey.domain.history.exception.SituationErrorCode; import org.clokey.domain.history.exception.StyleErrorCode; import org.clokey.domain.history.repository.*; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.exception.BaseCustomException; import org.clokey.global.util.MemberUtil; import org.clokey.history.entity.*; +import org.clokey.member.entity.Block; import org.clokey.member.entity.Member; import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; @@ -51,6 +59,9 @@ class HistoryServiceImplTest extends IntegrationTest { @Autowired private HistoryClothTagRepository historyClothTagRepository; @Autowired private ClothRepository clothRepository; @Autowired private CategoryRepository categoryRepository; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private CommentRepository commentRepository; + @Autowired private BlockRepository blockRepository; @MockitoBean private MemberUtil memberUtil; @@ -606,4 +617,409 @@ void setUp() { tuple(3L, "testSituation3")); } } + + @Nested + class 기록_이미지_옷_태그를_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + memberRepository.save(member1); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + Category category = Category.createCategory("testCategory", null); + categoryRepository.save(category); + + Cloth cloth1 = + Cloth.createCloth( + "testClothImageUrl1", + null, + "testClothName1", + "testBrand1", + Season.SPRING, + category, + member1); + Cloth cloth2 = + Cloth.createCloth( + "testClothImageUrl2", + null, + "testClothName2", + "testBrand2", + Season.SPRING, + category, + member1); + clothRepository.saveAll(List.of(cloth1, cloth2)); + + History history = + History.createHistory(LocalDate.now(), "testContent", member1, situation1); + historyRepository.save(history); + + HistoryImage historyImage = HistoryImage.createHistoryImage("testImageUrl", history); + historyImageRepository.save(historyImage); + + HistoryClothTag tag1 = + HistoryClothTag.createHistoryClothTag(historyImage, cloth1, 0.5, 0.7); + HistoryClothTag tag2 = + HistoryClothTag.createHistoryClothTag(historyImage, cloth2, 0.3, 0.4); + historyClothTagRepository.bulkInsertHistoryClothTags(List.of(tag1, tag2)); + } + + @Test + void 유효한_요청이면_기록_이미지에_태그된_옷들의_정보를_반환한다() { + // when + HistoryClothTagListResponse response = historyService.getHistoryClothTags(1L); + + // then + assertThat(response.payloads()).hasSize(2); + assertThat(response.payloads()) + .extracting( + HistoryClothTagListResponse.Payload::clothId, + HistoryClothTagListResponse.Payload::clothImageUrl, + HistoryClothTagListResponse.Payload::name, + HistoryClothTagListResponse.Payload::brand, + HistoryClothTagListResponse.Payload::locationX, + HistoryClothTagListResponse.Payload::locationY) + .containsExactlyInAnyOrder( + tuple( + 1L, + "testClothImageUrl1", + "testClothName1", + "testBrand1", + 0.5, + 0.7), + tuple( + 2L, + "testClothImageUrl2", + "testClothName2", + "testBrand2", + 0.3, + 0.4)); + } + + @Test + void 존재하지_않는_기록_이미지_ID를_입력하면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.getHistoryClothTags(999L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND.getMessage()); + } + } + + @Nested + class 월별_기록을_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + member2.changeVisibility(); + + Member member3 = + Member.createMember( + "testEmail3", + "testClokeyId3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2, member3)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2025, 1, 1), "testContent1", member1, situation1); + History history2 = + History.createHistory( + LocalDate.of(2025, 1, 15), "testContent2", member1, situation1); + historyRepository.saveAll(List.of(history1, history2)); + + HistoryImage image1 = HistoryImage.createHistoryImage("testImageUrl1", history1); + HistoryImage image2 = HistoryImage.createHistoryImage("testImageUrl2", history2); + historyImageRepository.saveAll(List.of(image1, image2)); + } + + @Test + void 유효한_요청이면_월별_기록을_반환한다() { + // when + MonthlyHistoryResponse response = historyService.getMonthlyHistory(1L, 2025, 1); + + // then + assertThat(response.payloads()).hasSize(2); + assertThat(response.payloads()) + .extracting( + MonthlyHistoryResponse.Payload::historyId, + MonthlyHistoryResponse.Payload::firstImageUrl) + .containsExactly(tuple(1L, "testImageUrl1"), tuple(2L, "testImageUrl2")); + } + + @Test + void 존재하지_않는_회원_ID를_입력하면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.getMonthlyHistory(999L, 2025, 1)) + .isInstanceOf(BaseCustomException.class) + .hasMessage( + org.clokey.domain.member.exception.MemberErrorCode.MEMBER_NOT_FOUND + .getMessage()); + } + + @Test + void 비공개_회원이고_본인이_아닌_경우_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.getMonthlyHistory(2L, 2025, 1)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.LIMITED_AUTHORITY.getMessage()); + } + + @Test + void 차단한_회원의_월별_기록을_조회하면_예외가_발생한다() { + // given + Member blocker = + transactionUtil.getResult(() -> memberRepository.findById(1L).orElseThrow()); + Member blocked = + transactionUtil.getResult(() -> memberRepository.findById(3L).orElseThrow()); + Block block = Block.createBlock(blocker, blocked); + blockRepository.save(block); + + // when & then + assertThatThrownBy(() -> historyService.getMonthlyHistory(3L, 2025, 1)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.BLOCKED_AUTHORITY.getMessage()); + } + + @Test + void 차단당한_회원의_월별_기록을_조회하면_예외가_발생한다() { + // given + Member blocker = + transactionUtil.getResult(() -> memberRepository.findById(3L).orElseThrow()); + Member blocked = + transactionUtil.getResult(() -> memberRepository.findById(1L).orElseThrow()); + Block block = Block.createBlock(blocker, blocked); + blockRepository.save(block); + + // when & then + assertThatThrownBy(() -> historyService.getMonthlyHistory(3L, 2025, 1)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.BLOCKED_AUTHORITY.getMessage()); + } + } + + @Nested + class 기록_소유_여부를_확인할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history = + History.createHistory(LocalDate.now(), "testContent", member1, situation1); + historyRepository.save(history); + } + + @Test + void 유효한_요청이면_기록_소유_여부를_반환한다() { + // when + HistoryOwnershipCheckResponse response = historyService.checkHistoryOwnership(1L); + + // then + assertThat(response.isOwner()).isTrue(); + } + + @Test + void 존재하지_않는_기록_ID를_입력하면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.checkHistoryOwnership(999L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.HISTORY_NOT_FOUND.getMessage()); + } + + @Test + void 다른_사용자의_기록을_확인하면_false를_반환한다() { + // given + Member otherMember = + transactionUtil.getResult(() -> memberRepository.findById(2L).orElseThrow()); + given(memberUtil.getCurrentMember()).willReturn(otherMember); + + // when + HistoryOwnershipCheckResponse response = historyService.checkHistoryOwnership(1L); + + // then + assertThat(response.isOwner()).isFalse(); + } + } + + @Nested + class 일별_기록을_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + member2.changeVisibility(); + + Member member3 = + Member.createMember( + "testEmail3", + "testClokeyId3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2, member3)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + Style style1 = Style.createStyle("testStyle1"); + Style style2 = Style.createStyle("testStyle2"); + styleRepository.saveAll(List.of(style1, style2)); + + History history1 = + History.createHistory(LocalDate.now(), "testContent1", member1, situation1); + History history2 = + History.createHistory(LocalDate.now(), "testContent2", member2, situation1); + History history3 = + History.createHistory(LocalDate.now(), "testContent3", member3, situation1); + historyRepository.saveAll(List.of(history1, history2, history3)); + + HistoryImage image1 = HistoryImage.createHistoryImage("testImageUrl1", history1); + HistoryImage image2 = HistoryImage.createHistoryImage("testImageUrl2", history1); + HistoryImage image3 = HistoryImage.createHistoryImage("testImageUrl3", history3); + historyImageRepository.saveAll(List.of(image1, image2, image3)); + + historyStyleRepository.bulkInsertHistoryStyles( + List.of( + HistoryStyle.createHistoryStyle(history1, style1), + HistoryStyle.createHistoryStyle(history1, style2))); + } + + @Test + void 유효한_요청이면_일별_기록을_반환한다() { + // when + DailyHistoryResponse response = historyService.getDailyHistory(1L); + + // then + assertThat(response) + .extracting( + DailyHistoryResponse::memberId, + DailyHistoryResponse::historyDate, + DailyHistoryResponse::situationId, + DailyHistoryResponse::situationName, + DailyHistoryResponse::likeCount, + DailyHistoryResponse::commentCount) + .containsExactly(1L, LocalDate.now(), 1L, "testSituation1", 0L, 0L); + + assertThat(response.images()).hasSize(2); + assertThat(response.images()) + .extracting( + DailyHistoryResponse.ImagePayload::imageId, + DailyHistoryResponse.ImagePayload::imageUrl) + .containsExactlyInAnyOrder( + tuple(1L, "testImageUrl1"), tuple(2L, "testImageUrl2")); + + assertThat(response.styles()).hasSize(2); + assertThat(response.styles()) + .extracting( + DailyHistoryResponse.StylePayload::styleId, + DailyHistoryResponse.StylePayload::styleName) + .containsExactlyInAnyOrder(tuple(1L, "testStyle1"), tuple(2L, "testStyle2")); + } + + @Test + void 존재하지_않는_기록_ID를_입력하면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.getDailyHistory(999L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.HISTORY_NOT_FOUND.getMessage()); + } + + @Test + void 비공개_회원의_기록이고_본인이_아닌_경우_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> historyService.getDailyHistory(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.LIMITED_AUTHORITY.getMessage()); + } + + @Test + void 차단한_회원의_기록을_조회하면_예외가_발생한다() { + // given + Member blocker = + transactionUtil.getResult(() -> memberRepository.findById(1L).orElseThrow()); + Member blocked = + transactionUtil.getResult(() -> memberRepository.findById(3L).orElseThrow()); + Block block = Block.createBlock(blocker, blocked); + blockRepository.save(block); + + // when & then + assertThatThrownBy(() -> historyService.getDailyHistory(3L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.BLOCKED_AUTHORITY.getMessage()); + } + + @Test + void 차단당한_회원의_기록을_조회하면_예외가_발생한다() { + // given + Member blocker = + transactionUtil.getResult(() -> memberRepository.findById(3L).orElseThrow()); + Member blocked = + transactionUtil.getResult(() -> memberRepository.findById(1L).orElseThrow()); + Block block = Block.createBlock(blocker, blocked); + blockRepository.save(block); + + // when & then + assertThatThrownBy(() -> historyService.getDailyHistory(3L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.BLOCKED_AUTHORITY.getMessage()); + } + } } diff --git a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java new file mode 100644 index 00000000..82ebe94e --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java @@ -0,0 +1,133 @@ +package org.clokey.domain.like.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(LikeController.class) +@AutoConfigureMockMvc(addFilters = false) +public class LikeControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private LikeService likeService; + + @Nested + class 좋아요_요청_시 { + @Test + void 유효한_요청이면_성공코드를_반환한다() throws Exception { + // given + long historyId = 1L; + + willDoNothing().given(likeService).toggleLike(historyId); + + // when + ResultActions perform = + mockMvc.perform( + post("/likes") + .param("historyId", String.valueOf(historyId)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON204")) + .andExpect(jsonPath("$.message").value("요청 성공 및 반환값 없음")); + } + } + + @Nested + class 좋아요한_기록_조회_시 { + @Test + void 유효한_요청이면_좋아요한_기록을_반환한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, true); + + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); + + ResultActions perform = + mockMvc.perform( + get("/likes/histories") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(true)); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_응답한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, false); + + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); + + // when + ResultActions perform = + mockMvc.perform( + get("/likes/histories") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(false)); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java new file mode 100644 index 00000000..4cad615f --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -0,0 +1,199 @@ +package org.clokey.domain.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.TransactionUtil; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.FollowRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.Situation; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Block; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class LikeServiceTest extends IntegrationTest { + + @Autowired private LikeService likeService; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private HistoryImageRepository historyImageRepository; + @Autowired private FollowRepository followRepository; + @Autowired private BlockRepository blockRepository; + + @Autowired private TransactionUtil transactionUtil; + @MockitoBean private MemberUtil memberUtil; + + @Nested + class 기록에_좋아요를_할_때 { + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content1", + memberRepository.findById(2L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + historyRepository.save(history1); + } + + @Test + void 좋아요를_누르면_좋아요를_추가한다() { + // when + likeService.toggleLike(1L); + + // then + assertThat(memberLikeRepository.findByMemberIdAndHistoryId(1L, 1L).isPresent()) + .isTrue(); + } + + @Test + void 기록에_이미_좋아요가_있으면_좋아요를_취소한다() { + memberLikeRepository.save( + MemberLike.createMemberLike( + memberRepository.findById(1L).orElseThrow(), + historyRepository.findById(1L).orElseThrow())); + + // when + likeService.toggleLike(1L); + + // then + assertThat(memberLikeRepository.findByMemberIdAndHistoryId(1L, 1L).isPresent()) + .isFalse(); + } + + @Test + void 차단된_회원이면_좋아요를_추가하지_않는다() { + // given + blockRepository.save( + Block.createBlock( + memberRepository.findById(2L).orElseThrow(), + memberRepository.findById(1L).orElseThrow())); + + // when + likeService.toggleLike(1L); + + // then + assertThat(memberLikeRepository.findByMemberIdAndHistoryId(1L, 1L).isPresent()) + .isFalse(); + } + } + + @Nested + class 좋아요한_기록을_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content1", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + History history2 = + History.createHistory( + LocalDate.of(2024, 12, 26), + "content2", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + historyRepository.saveAll(List.of(history1, history2)); + + HistoryImage historyImage1 = + HistoryImage.createHistoryImage("http://image1.url", history1); + HistoryImage historyImage2 = + HistoryImage.createHistoryImage("http://image2.url", history2); + historyImageRepository.saveAll(List.of(historyImage1, historyImage2)); + } + + @Test + void 좋아요한_기록이_있으면_기록을_반환한다() { + // given + memberLikeRepository.saveAll( + List.of( + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(1L).orElseThrow()), + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(2L).orElseThrow()))); + + // when + SliceResponse response = + likeService.getLikedHistories(null, 10); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.isLast()).isTrue(); + + assertThat(response.content()) + .extracting("id", "imageUrl") + .containsExactly( + tuple(2L, "http://image2.url"), tuple(1L, "http://image1.url")); + } + + @Test + void 좋아요한_기록이_없으면_빈_리스트를_반환한다() { + // when + SliceResponse response = + likeService.getLikedHistories(null, 10); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.isLast()).isTrue(); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/report/controller/ReportControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/report/controller/ReportControllerTest.java index 57637b96..cb77e80f 100644 --- a/clokey-api/src/test/java/org/clokey/domain/report/controller/ReportControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/report/controller/ReportControllerTest.java @@ -1,12 +1,14 @@ package org.clokey.domain.report.controller; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import org.clokey.domain.report.dto.request.ReportCreateRequest; import org.clokey.domain.report.dto.response.ReportCreateResponse; +import org.clokey.domain.report.dto.response.ReportedCheckResponse; import org.clokey.domain.report.service.ReportService; import org.clokey.report.enums.ReportReason; import org.clokey.report.enums.TargetType; @@ -149,4 +151,27 @@ class 신고_생성_요청_시 { .value("신고 사유는 비워둘 수 없습니다.")); } } + + @Nested + class 접수된_미확인_신고_확인_요청_시 { + + @Test + void 유효한_요청이면_최신_UNCHECKED_상태의_신고_존재_여부를_반환한다() throws Exception { + // given + ReportedCheckResponse response = new ReportedCheckResponse(true, TargetType.COMMENT); + given(reportService.checkReportReceived()).willReturn(response); + + // when + ResultActions perform = mockMvc.perform(get("/reports/received")); + + // then + perform.andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess").value(true)) + .andExpect(MockMvcResultMatchers.jsonPath("$.code").value("COMMON200")) + .andExpect(MockMvcResultMatchers.jsonPath("$.result.isReported").value(true)) + .andExpect( + MockMvcResultMatchers.jsonPath("$.result.targetType") + .value(TargetType.COMMENT.name())); + } + } } diff --git a/clokey-api/src/test/java/org/clokey/domain/report/service/ReportServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/report/service/ReportServiceImplTest.java index e35fc80d..0f882dd7 100644 --- a/clokey-api/src/test/java/org/clokey/domain/report/service/ReportServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/report/service/ReportServiceImplTest.java @@ -5,6 +5,7 @@ import static org.mockito.BDDMockito.given; import java.time.LocalDate; +import java.util.List; import org.clokey.IntegrationTest; import org.clokey.comment.entitiy.Comment; import org.clokey.domain.comment.exception.CommentErrorCode; @@ -14,6 +15,7 @@ import org.clokey.domain.history.repository.SituationRepository; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.domain.report.dto.request.ReportCreateRequest; +import org.clokey.domain.report.dto.response.ReportedCheckResponse; import org.clokey.domain.report.exception.ReportErrorCode; import org.clokey.domain.report.repository.ReportRepository; import org.clokey.exception.BaseCustomException; @@ -87,11 +89,13 @@ void setUp() { .extracting( "targetId", "reporter.id", + "reported.id", "targetType", "reportReason", "reportStatus", "content") .containsExactly( + 1L, 1L, 1L, TargetType.HISTORY, @@ -182,4 +186,66 @@ void setUp() { .hasMessage(ReportErrorCode.REPORT_DUPLICATED.getMessage()); } } + + @Nested + class 접수된_미확인_신고_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + Member member2 = + Member.createMember( + "testEmail2", + "testClokeyId2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2)); + given(memberUtil.getCurrentMember()).willReturn(member2); + + Situation situation = Situation.createSituation("testSituation"); + situationRepository.save(situation); + + History history1 = + History.createHistory( + LocalDate.of(2026, 1, 1), "testContent1", member2, situation); + historyRepository.save(history1); + + Report report = + Report.createReport( + 1L, + member1, + member2, + TargetType.HISTORY, + ReportReason.VIOLENT, + "Test Report"); + reportRepository.save(report); + } + + @Test + void 유효한_요청이면_미확인_신고_여부를_반환한다() { + // when & then + ReportedCheckResponse response = reportService.checkReportReceived(); + + assertThat(response.isReported()).isTrue(); + assertThat(response.targetType()).isEqualTo(TargetType.HISTORY); + } + + @Test + void 접수된_신고가_없으면_false를_반환한다() { + // given + Member member1 = memberRepository.findById(1L).orElse(null); + given(memberUtil.getCurrentMember()).willReturn(member1); + + // when & then + ReportedCheckResponse response = reportService.checkReportReceived(); + + assertThat(response.isReported()).isFalse(); + assertThat(response.targetType()).isEqualTo(null); + } + } } diff --git a/clokey-domain/src/main/java/org/clokey/history/entity/History.java b/clokey-domain/src/main/java/org/clokey/history/entity/History.java index 2ddbbbe0..3ec7d362 100644 --- a/clokey-domain/src/main/java/org/clokey/history/entity/History.java +++ b/clokey-domain/src/main/java/org/clokey/history/entity/History.java @@ -80,4 +80,12 @@ public void updateHistory(String content, Situation situation) { this.content = content; this.situation = situation; } + + public void ban() { + this.banned = true; + } + + public void unban() { + this.banned = false; + } } diff --git a/clokey-domain/src/main/java/org/clokey/report/entity/Report.java b/clokey-domain/src/main/java/org/clokey/report/entity/Report.java index 1699406d..1f47342c 100644 --- a/clokey-domain/src/main/java/org/clokey/report/entity/Report.java +++ b/clokey-domain/src/main/java/org/clokey/report/entity/Report.java @@ -24,10 +24,15 @@ public class Report extends BaseEntity { @NotNull private Long targetId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") + @JoinColumn(name = "reporter_member_id") @NotNull private Member reporter; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_member_id") + @NotNull + private Member reported; + @Enumerated(EnumType.STRING) @NotNull private TargetType targetType; @@ -46,12 +51,14 @@ public class Report extends BaseEntity { private Report( Long targetId, Member reporter, + Member reported, TargetType targetType, ReportReason reportReason, String content, ReportStatus reportStatus) { this.targetId = targetId; this.reporter = reporter; + this.reported = reported; this.targetType = targetType; this.reportReason = reportReason; this.content = content; @@ -61,6 +68,7 @@ private Report( public static Report createReport( Long targetId, Member reporter, + Member reported, TargetType targetType, ReportReason reportReason, String content) { @@ -68,6 +76,7 @@ public static Report createReport( Report.builder() .targetId(targetId) .reporter(reporter) + .reported(reported) .targetType(targetType) .reportReason(reportReason) .content(content) diff --git a/clokey-domain/src/main/resources/db/migration/V1__init.sql b/clokey-domain/src/main/resources/db/migration/V1__init.sql index f6a2f5e6..c26ef725 100644 --- a/clokey-domain/src/main/resources/db/migration/V1__init.sql +++ b/clokey-domain/src/main/resources/db/migration/V1__init.sql @@ -274,7 +274,9 @@ CREATE TABLE report ( id BIGINT AUTO_INCREMENT PRIMARY KEY, target_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, + + reporter_member_id BIGINT NOT NULL, + reported_member_id BIGINT NOT NULL, report_reason VARCHAR(255) NOT NULL CHECK ( report_reason IN ( @@ -297,7 +299,8 @@ CREATE TABLE report ( created_at DATETIME(6) NOT NULL, updated_at DATETIME(6) NOT NULL, - CONSTRAINT fk_report_member FOREIGN KEY (member_id) REFERENCES member(id) + CONSTRAINT fk_report_reporter FOREIGN KEY (reporter_member_id) REFERENCES member(id), + CONSTRAINT fk_report_reported FOREIGN KEY (reported_member_id) REFERENCES member(id) ); CREATE TABLE member_term (