From 795eff306793b6fdba4dbcb9b5465f60124cf132 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:29:09 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/V24__create_semo_feed_emojis.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/resources/db/migration/V24__create_semo_feed_emojis.sql diff --git a/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql b/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql new file mode 100644 index 0000000..7d692ad --- /dev/null +++ b/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS semo_feed_emojis +( + id BIGSERIAL PRIMARY KEY, + semo_feed_id BIGINT NOT NULL REFERENCES semo_feeds (id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (id), + emoji_type VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uk_semo_feed_emoji_feed_user_type UNIQUE (semo_feed_id, user_id, emoji_type), + CONSTRAINT ck_semo_feed_emojis_type CHECK (emoji_type IN ('FIRE', 'HEART', 'CONGRATS', 'LAUGH')) +); + +CREATE INDEX IF NOT EXISTS idx_semo_feed_emojis_feed + ON semo_feed_emojis (semo_feed_id); + +CREATE INDEX IF NOT EXISTS idx_semo_feed_emojis_user + ON semo_feed_emojis (user_id); From 51e20c64212148c18eb208fa4bcfa7c079598b15 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:29:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EB=B0=98=EC=9D=91=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/ErrorStatus.java | 1 + .../api/common/status/SuccessStatus.java | 1 + .../controller/SemoFeedController.java | 21 +++- .../docs/SemoFeedControllerDocs.java | 32 ++++++ .../semofeed/dto/SemoFeedEmojiRequest.java | 10 ++ .../dto/SemoFeedEmojiToggleResponse.java | 10 ++ .../domain/semofeed/dto/SemoFeedResponse.java | 33 ++++++- .../domain/semofeed/entity/SemoFeedEmoji.java | 56 +++++++++++ .../semofeed/enums/SemoFeedEmojiType.java | 8 ++ .../repository/SemoFeedEmojiRepository.java | 42 ++++++++ .../repository/SemoFeedRepository.java | 7 +- .../service/SemoFeedEmojiService.java | 88 +++++++++++++++++ .../semofeed/service/SemoFeedService.java | 97 ++++++++++++++++++- 13 files changed, 397 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java create mode 100644 src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java create mode 100644 src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java create mode 100644 src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java create mode 100644 src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java create mode 100644 src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 7bbeaa8..eb7e13b 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -144,6 +144,7 @@ public enum ErrorStatus implements BaseStatus { * SemoFeed (세모피드) */ SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다."), + SEMOFEED_EMOJI_SELF_NOT_ALLOWED(HttpStatus.FORBIDDEN, "SF_403_2", "본인의 세모피드에는 이모지를 남길 수 없습니다."), SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index a55c23f..6040440 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -75,6 +75,7 @@ public enum SuccessStatus implements BaseStatus { SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."), SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."), SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."), + SEMOFEED_EMOJI_TOGGLE_SUCCESS(HttpStatus.OK, "SF_200_5", "세모피드 이모지 처리에 성공했습니다."), SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."), /** diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java index 0155fb8..cc8843e 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java @@ -4,8 +4,12 @@ import com.semosan.api.common.response.PageResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.semofeed.controller.docs.SemoFeedControllerDocs; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; +import com.semosan.api.domain.semofeed.service.SemoFeedEmojiService; import com.semosan.api.domain.semofeed.service.SemoFeedService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -22,6 +26,7 @@ public class SemoFeedController implements SemoFeedControllerDocs { private final SemoFeedService semoFeedService; + private final SemoFeedEmojiService semoFeedEmojiService; @PostMapping @Override @@ -36,6 +41,7 @@ public ResponseEntity> create( @GetMapping @Override public ResponseEntity>> listPublic( + @AuthenticationPrincipal Long userId, @PageableDefault(size = 100) Pageable pageable ) { if (pageable.getPageSize() > 100) { @@ -43,7 +49,20 @@ public ResponseEntity>> listPublic( } return ApiResponse.success( SuccessStatus.SEMOFEED_LIST_SUCCESS, - PageResponse.from(semoFeedService.listPublic(pageable)) + PageResponse.from(semoFeedService.listPublic(pageable, userId)) + ); + } + + @PostMapping("/{semoFeedId}/emojis") + @Override + public ResponseEntity> toggleEmoji( + @AuthenticationPrincipal Long userId, + @PathVariable Long semoFeedId, + @Valid @RequestBody SemoFeedEmojiRequest request + ) { + return ApiResponse.success( + SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, + semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType()) ); } diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java index b010feb..30fbdb6 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java @@ -2,6 +2,8 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.response.PageResponse; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,6 +11,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -53,9 +56,38 @@ ResponseEntity> create( ) }) ResponseEntity>> listPublic( + @AuthenticationPrincipal Long userId, @PageableDefault(size = 100) Pageable pageable ); + @Operation( + summary = "세모피드 이모지 토글", + description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다. 본인 세모피드에는 반응할 수 없습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "세모피드 이모지 처리 성공", + content = @Content(schema = @Schema(implementation = SemoFeedEmojiToggleResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "본인의 세모피드에는 이모지를 남길 수 없음 (SF_403_2)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "세모피드를 찾을 수 없음 (SF_404_1)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> toggleEmoji( + @AuthenticationPrincipal Long userId, + @Parameter(description = "세모피드 ID", required = true) + @PathVariable Long semoFeedId, + @Valid @RequestBody SemoFeedEmojiRequest request + ); + @Operation( summary = "내 세모피드 목록 조회", description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다." diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java new file mode 100644 index 0000000..bb23644 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java @@ -0,0 +1,10 @@ +package com.semosan.api.domain.semofeed.dto; + +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import jakarta.validation.constraints.NotNull; + +public record SemoFeedEmojiRequest( + @NotNull(message = "emojiType 은 필수입니다.") + SemoFeedEmojiType emojiType +) { +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java new file mode 100644 index 0000000..f2aafaa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java @@ -0,0 +1,10 @@ +package com.semosan.api.domain.semofeed.dto; + +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; + +public record SemoFeedEmojiToggleResponse( + SemoFeedEmojiType emojiType, + boolean reacted, + long count +) { +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java index 51b5031..c584fc3 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java @@ -1,17 +1,46 @@ package com.semosan.api.domain.semofeed.dto; import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; + +import java.util.Map; public record SemoFeedResponse( Long id, + Long userId, + String profileUrl, + String nickname, String imageUrl, - boolean isPublic + boolean isPublic, + Map emojiCounts, + Map reactedByMe, + boolean mine ) { public static SemoFeedResponse from(SemoFeed semoFeed) { + return of( + semoFeed, + Map.of(), + Map.of(), + false + ); + } + + public static SemoFeedResponse of( + SemoFeed semoFeed, + Map emojiCounts, + Map reactedByMe, + boolean mine + ) { return new SemoFeedResponse( semoFeed.getId(), + semoFeed.getUser().getId(), + semoFeed.getUser().getProfileUrl(), + semoFeed.getUser().displayName(), semoFeed.getImageUrl(), - semoFeed.isPublic() + semoFeed.isPublic(), + emojiCounts, + reactedByMe, + mine ); } } diff --git a/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java b/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java new file mode 100644 index 0000000..cb91f96 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java @@ -0,0 +1,56 @@ +package com.semosan.api.domain.semofeed.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table( + name = "semo_feed_emojis", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_semo_feed_emoji_feed_user_type", + columnNames = {"semo_feed_id", "user_id", "emoji_type"} + ) + }, + indexes = { + @Index(name = "idx_semo_feed_emojis_feed", columnList = "semo_feed_id"), + @Index(name = "idx_semo_feed_emojis_user", columnList = "user_id") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SemoFeedEmoji extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "semo_feed_id", nullable = false) + private SemoFeed semoFeed; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "emoji_type", nullable = false, length = 20) + private SemoFeedEmojiType emojiType; + + public static SemoFeedEmoji create(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) { + return SemoFeedEmoji.builder() + .semoFeed(semoFeed) + .user(user) + .emojiType(emojiType) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java b/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java new file mode 100644 index 0000000..8b457da --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java @@ -0,0 +1,8 @@ +package com.semosan.api.domain.semofeed.enums; + +public enum SemoFeedEmojiType { + FIRE, + HEART, + CONGRATS, + LAUGH +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java new file mode 100644 index 0000000..eb8da0b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java @@ -0,0 +1,42 @@ +package com.semosan.api.domain.semofeed.repository; + +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SemoFeedEmojiRepository extends JpaRepository { + + Optional findBySemoFeedAndUserAndEmojiType( + SemoFeed semoFeed, + User user, + SemoFeedEmojiType emojiType + ); + + long countBySemoFeedAndEmojiType(SemoFeed semoFeed, SemoFeedEmojiType emojiType); + + @Query(""" + SELECT e.semoFeed.id, e.emojiType, COUNT(e) + FROM SemoFeedEmoji e + WHERE e.semoFeed.id IN :semoFeedIds + GROUP BY e.semoFeed.id, e.emojiType + """) + List countBySemoFeedIdsGrouped(@Param("semoFeedIds") List semoFeedIds); + + @Query(""" + SELECT e.semoFeed.id, e.emojiType + FROM SemoFeedEmoji e + WHERE e.semoFeed.id IN :semoFeedIds + AND e.user.id = :userId + """) + List findReactedTypesBySemoFeedIdsAndUserId( + @Param("semoFeedIds") List semoFeedIds, + @Param("userId") Long userId + ); +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java index 25015fe..7d42b92 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java +++ b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java @@ -10,9 +10,12 @@ public interface SemoFeedRepository extends JpaRepository { - @Query("SELECT s FROM SemoFeed s WHERE s.isPublic = true ORDER BY s.createdAt DESC") + @Query( + value = "SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.isPublic = true ORDER BY s.createdAt DESC", + countQuery = "SELECT COUNT(s) FROM SemoFeed s WHERE s.isPublic = true" + ) Page findPublic(Pageable pageable); - @Query("SELECT s FROM SemoFeed s WHERE s.user.id = :userId ORDER BY s.createdAt DESC") + @Query("SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.user.id = :userId ORDER BY s.createdAt DESC") List findByUserId(Long userId); } diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java new file mode 100644 index 0000000..55378b3 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -0,0 +1,88 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SemoFeedEmojiService { + + private final SemoFeedEmojiRepository semoFeedEmojiRepository; + private final SemoFeedRepository semoFeedRepository; + private final UserRepository userRepository; + + @Transactional(noRollbackFor = DataIntegrityViolationException.class) + public SemoFeedEmojiToggleResponse toggleWithCount( + Long userId, + Long semoFeedId, + SemoFeedEmojiType emojiType + ) { + SemoFeed semoFeed = findSemoFeedOrThrow(semoFeedId); + User user = findUserOrThrow(userId); + validateNotOwner(semoFeed, userId); + + boolean reacted = toggle(semoFeed, user, emojiType); + long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); + + return new SemoFeedEmojiToggleResponse(emojiType, reacted, count); + } + + private boolean toggle(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) { + Optional existing = semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + user, + emojiType + ); + + if (existing.isPresent()) { + semoFeedEmojiRepository.delete(existing.get()); + return false; + } + + try { + semoFeedEmojiRepository.save(SemoFeedEmoji.create(semoFeed, user, emojiType)); + return true; + } catch (DataIntegrityViolationException e) { + log.warn( + "SemoFeedEmoji 동시 요청 감지: semoFeedId={}, userId={}, emojiType={}", + semoFeed.getId(), + user.getId(), + emojiType + ); + return true; + } + } + + private SemoFeed findSemoFeedOrThrow(Long semoFeedId) { + return semoFeedRepository.findById(semoFeedId) + .orElseThrow(() -> new GeneralException(ErrorStatus.SEMOFEED_NOT_FOUND)); + } + + private User findUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + } + + private void validateNotOwner(SemoFeed semoFeed, Long userId) { + if (semoFeed.isOwnedBy(userId)) { + throw new GeneralException(ErrorStatus.SEMOFEED_EMOJI_SELF_NOT_ALLOWED); + } + } +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java index 8239cbb..6db334a 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java @@ -4,6 +4,8 @@ import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.repository.UserRepository; @@ -14,6 +16,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.EnumMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -21,6 +27,7 @@ public class SemoFeedService { private final SemoFeedRepository semoFeedRepository; + private final SemoFeedEmojiRepository semoFeedEmojiRepository; private final UserRepository userRepository; @Transactional @@ -32,16 +39,23 @@ public SemoFeedResponse create(Long userId, String imageUrl) { } public Page listPublic(Pageable pageable) { - return semoFeedRepository.findPublic(pageable) - .map(SemoFeedResponse::from); + Page semoFeeds = semoFeedRepository.findPublic(pageable); + return toResponsePage(semoFeeds, null); } public List listMine(Long userId) { - return semoFeedRepository.findByUserId(userId).stream() - .map(SemoFeedResponse::from) + List semoFeeds = semoFeedRepository.findByUserId(userId); + SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds, userId); + return semoFeeds.stream() + .map(assembler::toResponse) .toList(); } + public Page listPublic(Pageable pageable, Long userId) { + Page semoFeeds = semoFeedRepository.findPublic(pageable); + return toResponsePage(semoFeeds, userId); + } + @Transactional public boolean togglePublic(Long userId, Long semoFeedId) { SemoFeed semoFeed = findOwned(userId, semoFeedId); @@ -63,4 +77,79 @@ private SemoFeed findOwned(Long userId, Long semoFeedId) { } return semoFeed; } + + private Page toResponsePage(Page semoFeeds, Long userId) { + SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds.getContent(), userId); + return semoFeeds.map(assembler::toResponse); + } + + private SemoFeedResponseAssembler createResponseAssembler(List semoFeeds, Long userId) { + List semoFeedIds = semoFeeds.stream() + .map(SemoFeed::getId) + .toList(); + + Map> emojiCounts = findEmojiCounts(semoFeedIds); + Map> reactedTypes = findReactedTypes(semoFeedIds, userId); + + return new SemoFeedResponseAssembler(userId, emojiCounts, reactedTypes); + } + + private Map> findEmojiCounts(List semoFeedIds) { + if (semoFeedIds.isEmpty()) { + return Map.of(); + } + + return semoFeedEmojiRepository.countBySemoFeedIdsGrouped(semoFeedIds).stream() + .collect(Collectors.groupingBy( + row -> (Long) row[0], + Collectors.toMap( + row -> (SemoFeedEmojiType) row[1], + row -> (Long) row[2] + ) + )); + } + + private Map> findReactedTypes(List semoFeedIds, Long userId) { + if (semoFeedIds.isEmpty() || userId == null) { + return Map.of(); + } + + return semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(semoFeedIds, userId).stream() + .collect(Collectors.groupingBy( + row -> (Long) row[0], + Collectors.mapping(row -> (SemoFeedEmojiType) row[1], Collectors.toSet()) + )); + } + + private record SemoFeedResponseAssembler( + Long userId, + Map> emojiCounts, + Map> reactedTypes + ) { + + private SemoFeedResponse toResponse(SemoFeed semoFeed) { + return SemoFeedResponse.of( + semoFeed, + completeEmojiCounts(emojiCounts.get(semoFeed.getId())), + completeReactedByMe(reactedTypes.get(semoFeed.getId())), + userId != null && semoFeed.isOwnedBy(userId) + ); + } + + private Map completeEmojiCounts(Map counts) { + Map completeCounts = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + completeCounts.put(emojiType, counts == null ? 0L : counts.getOrDefault(emojiType, 0L)); + } + return completeCounts; + } + + private Map completeReactedByMe(Set reactedTypes) { + Map completeReactedTypes = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + completeReactedTypes.put(emojiType, reactedTypes != null && reactedTypes.contains(emojiType)); + } + return completeReactedTypes; + } + } } From 3fe6bbb03770c2307549a312ab22290ea8516556 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:29:35 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EB=B0=98=EC=9D=91=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SemoFeedEmojiServiceTest.java | 124 ++++++++++++++++++ .../semofeed/service/SemoFeedServiceTest.java | 108 +++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java create mode 100644 src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java new file mode 100644 index 0000000..3f9aca7 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java @@ -0,0 +1,124 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SemoFeedEmojiServiceTest { + + @Mock + private SemoFeedEmojiRepository semoFeedEmojiRepository; + + @Mock + private SemoFeedRepository semoFeedRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SemoFeedEmojiService semoFeedEmojiService; + + @Test + void toggleWithCountCreatesEmojiReaction() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + + when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); + when(userRepository.findById(2L)).thenReturn(Optional.of(reactor)); + when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + reactor, + SemoFeedEmojiType.FIRE + )).thenReturn(Optional.empty()); + when(semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, SemoFeedEmojiType.FIRE)).thenReturn(1L); + + SemoFeedEmojiToggleResponse result = semoFeedEmojiService.toggleWithCount( + 2L, + 10L, + SemoFeedEmojiType.FIRE + ); + + assertThat(result.emojiType()).isEqualTo(SemoFeedEmojiType.FIRE); + assertThat(result.reacted()).isTrue(); + assertThat(result.count()).isEqualTo(1L); + verify(semoFeedEmojiRepository).save(any(SemoFeedEmoji.class)); + } + + @Test + void toggleWithCountDeletesExistingEmojiReaction() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + SemoFeedEmoji existing = SemoFeedEmoji.create(semoFeed, reactor, SemoFeedEmojiType.LAUGH); + + when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); + when(userRepository.findById(2L)).thenReturn(Optional.of(reactor)); + when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + reactor, + SemoFeedEmojiType.LAUGH + )).thenReturn(Optional.of(existing)); + when(semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, SemoFeedEmojiType.LAUGH)).thenReturn(0L); + + SemoFeedEmojiToggleResponse result = semoFeedEmojiService.toggleWithCount( + 2L, + 10L, + SemoFeedEmojiType.LAUGH + ); + + assertThat(result.reacted()).isFalse(); + assertThat(result.count()).isZero(); + verify(semoFeedEmojiRepository).delete(existing); + } + + @Test + void toggleWithCountRejectsOwnSemoFeed() { + User author = user(1L, "author"); + SemoFeed semoFeed = semoFeed(10L, author); + + when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); + when(userRepository.findById(1L)).thenReturn(Optional.of(author)); + + assertThatThrownBy(() -> semoFeedEmojiService.toggleWithCount(1L, 10L, SemoFeedEmojiType.HEART)) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.SEMOFEED_EMOJI_SELF_NOT_ALLOWED); + } + + private User user(Long id, String nickname) { + User user = User.createTestUser(nickname, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "nickname", nickname); + return user; + } + + private SemoFeed semoFeed(Long id, User user) { + SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png"); + ReflectionTestUtils.setField(semoFeed, "id", id); + return semoFeed; + } +} diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java new file mode 100644 index 0000000..494eaba --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java @@ -0,0 +1,108 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SemoFeedServiceTest { + + @Mock + private SemoFeedRepository semoFeedRepository; + + @Mock + private SemoFeedEmojiRepository semoFeedEmojiRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SemoFeedService semoFeedService; + + @Test + void listPublicBuildsAuthorEmojiCountsAndReactedByMe() { + User author = user(1L, "author", "https://example.com/profile.png"); + User me = user(2L, "me", null); + SemoFeed semoFeed = semoFeed(10L, author); + PageRequest pageable = PageRequest.of(0, 100); + + when(semoFeedRepository.findPublic(pageable)).thenReturn(new PageImpl<>(List.of(semoFeed), pageable, 1)); + when(semoFeedEmojiRepository.countBySemoFeedIdsGrouped(List.of(10L))).thenReturn(List.of( + new Object[]{10L, SemoFeedEmojiType.FIRE, 14L}, + new Object[]{10L, SemoFeedEmojiType.LAUGH, 3L} + )); + when(semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId())) + .thenReturn(List.of(new Object[]{10L, SemoFeedEmojiType.FIRE})); + + Page result = semoFeedService.listPublic(pageable, me.getId()); + + SemoFeedResponse response = result.getContent().getFirst(); + assertThat(response.userId()).isEqualTo(author.getId()); + assertThat(response.profileUrl()).isEqualTo("https://example.com/profile.png"); + assertThat(response.nickname()).isEqualTo("author"); + assertThat(response.emojiCounts()) + .containsEntry(SemoFeedEmojiType.FIRE, 14L) + .containsEntry(SemoFeedEmojiType.HEART, 0L) + .containsEntry(SemoFeedEmojiType.CONGRATS, 0L) + .containsEntry(SemoFeedEmojiType.LAUGH, 3L); + assertThat(response.reactedByMe()) + .containsEntry(SemoFeedEmojiType.FIRE, true) + .containsEntry(SemoFeedEmojiType.HEART, false) + .containsEntry(SemoFeedEmojiType.CONGRATS, false) + .containsEntry(SemoFeedEmojiType.LAUGH, false); + assertThat(response.mine()).isFalse(); + + verify(semoFeedEmojiRepository).countBySemoFeedIdsGrouped(List.of(10L)); + verify(semoFeedEmojiRepository).findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId()); + } + + @Test + void listPublicMarksMineWhenFeedAuthorIsMe() { + User me = user(1L, "me", null); + SemoFeed semoFeed = semoFeed(10L, me); + PageRequest pageable = PageRequest.of(0, 100); + + when(semoFeedRepository.findPublic(pageable)).thenReturn(new PageImpl<>(List.of(semoFeed), pageable, 1)); + when(semoFeedEmojiRepository.countBySemoFeedIdsGrouped(List.of(10L))).thenReturn(List.of()); + when(semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId())) + .thenReturn(List.of()); + + Page result = semoFeedService.listPublic(pageable, me.getId()); + + assertThat(result.getContent().getFirst().mine()).isTrue(); + } + + private User user(Long id, String nickname, String profileUrl) { + User user = User.createTestUser(nickname, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "nickname", nickname); + ReflectionTestUtils.setField(user, "profileUrl", profileUrl); + return user; + } + + private SemoFeed semoFeed(Long id, User user) { + SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png"); + ReflectionTestUtils.setField(semoFeed, "id", id); + return semoFeed; + } +} From 4a542884b410a7431a3515d28ab6efb2cebb8a8a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:31:38 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EB=B3=B8=EC=9D=B8=20=EC=84=B8?= =?UTF-8?q?=EB=AA=A8=ED=94=BC=EB=93=9C=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semosan/api/common/status/ErrorStatus.java | 1 - .../controller/docs/SemoFeedControllerDocs.java | 7 +------ .../semofeed/service/SemoFeedEmojiService.java | 6 ------ .../service/SemoFeedEmojiServiceTest.java | 17 ----------------- 4 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index eb7e13b..7bbeaa8 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -144,7 +144,6 @@ public enum ErrorStatus implements BaseStatus { * SemoFeed (세모피드) */ SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다."), - SEMOFEED_EMOJI_SELF_NOT_ALLOWED(HttpStatus.FORBIDDEN, "SF_403_2", "본인의 세모피드에는 이모지를 남길 수 없습니다."), SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java index 30fbdb6..60fb9ec 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java @@ -62,7 +62,7 @@ ResponseEntity>> listPublic( @Operation( summary = "세모피드 이모지 토글", - description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다. 본인 세모피드에는 반응할 수 없습니다." + description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -70,11 +70,6 @@ ResponseEntity>> listPublic( description = "세모피드 이모지 처리 성공", content = @Content(schema = @Schema(implementation = SemoFeedEmojiToggleResponse.class)) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "403", - description = "본인의 세모피드에는 이모지를 남길 수 없음 (SF_403_2)", - content = @Content(schema = @Schema(implementation = ApiResponse.class)) - ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "세모피드를 찾을 수 없음 (SF_404_1)", diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java index 55378b3..3494285 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -36,7 +36,6 @@ public SemoFeedEmojiToggleResponse toggleWithCount( ) { SemoFeed semoFeed = findSemoFeedOrThrow(semoFeedId); User user = findUserOrThrow(userId); - validateNotOwner(semoFeed, userId); boolean reacted = toggle(semoFeed, user, emojiType); long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); @@ -80,9 +79,4 @@ private User findUserOrThrow(Long userId) { .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); } - private void validateNotOwner(SemoFeed semoFeed, Long userId) { - if (semoFeed.isOwnedBy(userId)) { - throw new GeneralException(ErrorStatus.SEMOFEED_EMOJI_SELF_NOT_ALLOWED); - } - } } diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java index 3f9aca7..797b442 100644 --- a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java @@ -1,7 +1,5 @@ package com.semosan.api.domain.semofeed.service; -import com.semosan.api.common.exception.GeneralException; -import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; import com.semosan.api.domain.semofeed.entity.SemoFeed; import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; @@ -21,7 +19,6 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -95,20 +92,6 @@ void toggleWithCountDeletesExistingEmojiReaction() { verify(semoFeedEmojiRepository).delete(existing); } - @Test - void toggleWithCountRejectsOwnSemoFeed() { - User author = user(1L, "author"); - SemoFeed semoFeed = semoFeed(10L, author); - - when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); - when(userRepository.findById(1L)).thenReturn(Optional.of(author)); - - assertThatThrownBy(() -> semoFeedEmojiService.toggleWithCount(1L, 10L, SemoFeedEmojiType.HEART)) - .isInstanceOf(GeneralException.class) - .extracting("errorStatus") - .isEqualTo(ErrorStatus.SEMOFEED_EMOJI_SELF_NOT_ALLOWED); - } - private User user(Long id, String nickname) { User user = User.createTestUser(nickname, DeviceType.IOS); ReflectionTestUtils.setField(user, "id", id); From a912a99b13328400172f5f309b2dc45a0eb2ea96 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:39:04 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=84=B8=EB=AA=A8=ED=94=BC=EB=93=9C?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/semofeed/dto/SemoFeedResponse.java | 23 +++++++++++++-- .../semofeed/service/SemoFeedService.java | 5 ---- .../semofeed/service/SemoFeedServiceTest.java | 29 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java index c584fc3..7772cde 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java @@ -3,6 +3,7 @@ import com.semosan.api.domain.semofeed.entity.SemoFeed; import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import java.util.EnumMap; import java.util.Map; public record SemoFeedResponse( @@ -19,9 +20,9 @@ public record SemoFeedResponse( public static SemoFeedResponse from(SemoFeed semoFeed) { return of( semoFeed, - Map.of(), - Map.of(), - false + defaultEmojiCounts(), + defaultReactedByMe(), + true ); } @@ -43,4 +44,20 @@ public static SemoFeedResponse of( mine ); } + + private static Map defaultEmojiCounts() { + Map emojiCounts = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + emojiCounts.put(emojiType, 0L); + } + return emojiCounts; + } + + private static Map defaultReactedByMe() { + Map reactedByMe = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + reactedByMe.put(emojiType, false); + } + return reactedByMe; + } } diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java index 6db334a..d9cb821 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java @@ -38,11 +38,6 @@ public SemoFeedResponse create(Long userId, String imageUrl) { return SemoFeedResponse.from(semoFeedRepository.save(semoFeed)); } - public Page listPublic(Pageable pageable) { - Page semoFeeds = semoFeedRepository.findPublic(pageable); - return toResponsePage(semoFeeds, null); - } - public List listMine(Long userId) { List semoFeeds = semoFeedRepository.findByUserId(userId); SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds, userId); diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java index 494eaba..fc624d5 100644 --- a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java @@ -19,8 +19,10 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,6 +41,33 @@ class SemoFeedServiceTest { @InjectMocks private SemoFeedService semoFeedService; + @Test + void createReturnsDefaultEmojiFields() { + User user = user(1L, "author", "https://example.com/profile.png"); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(semoFeedRepository.save(any(SemoFeed.class))).thenAnswer(invocation -> { + SemoFeed semoFeed = invocation.getArgument(0); + ReflectionTestUtils.setField(semoFeed, "id", 10L); + return semoFeed; + }); + + SemoFeedResponse result = semoFeedService.create(1L, "https://example.com/semofeed.png"); + + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.mine()).isTrue(); + assertThat(result.emojiCounts()) + .containsEntry(SemoFeedEmojiType.FIRE, 0L) + .containsEntry(SemoFeedEmojiType.HEART, 0L) + .containsEntry(SemoFeedEmojiType.CONGRATS, 0L) + .containsEntry(SemoFeedEmojiType.LAUGH, 0L); + assertThat(result.reactedByMe()) + .containsEntry(SemoFeedEmojiType.FIRE, false) + .containsEntry(SemoFeedEmojiType.HEART, false) + .containsEntry(SemoFeedEmojiType.CONGRATS, false) + .containsEntry(SemoFeedEmojiType.LAUGH, false); + } + @Test void listPublicBuildsAuthorEmojiCountsAndReactedByMe() { User author = user(1L, "author", "https://example.com/profile.png"); From 250bfa3b326c1fd6f60bdc790fe1dcc8519b4687 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:49:15 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=EC=84=B8=EB=AA=A8=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semofeed/controller/SemoFeedController.java | 17 ++++++++--------- .../domain/semofeed/dto/SemoFeedResponse.java | 2 ++ .../semofeed/service/SemoFeedEmojiService.java | 4 ++++ .../semofeed/service/SemoFeedService.java | 8 ++++++++ 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java index cc8843e..ea9b0d2 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java @@ -40,6 +40,7 @@ public ResponseEntity> create( @GetMapping @Override + // 공개 세모피드 목록을 작성자 정보와 이모지 상태까지 포함해 조회합니다. public ResponseEntity>> listPublic( @AuthenticationPrincipal Long userId, @PageableDefault(size = 100) Pageable pageable @@ -47,23 +48,20 @@ public ResponseEntity>> listPublic( if (pageable.getPageSize() > 100) { pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort()); } - return ApiResponse.success( - SuccessStatus.SEMOFEED_LIST_SUCCESS, - PageResponse.from(semoFeedService.listPublic(pageable, userId)) - ); + PageResponse response = PageResponse.from(semoFeedService.listPublic(pageable, userId)); + return ApiResponse.success(SuccessStatus.SEMOFEED_LIST_SUCCESS, response); } @PostMapping("/{semoFeedId}/emojis") @Override + // 세모피드 이모지를 타입별로 등록하거나 취소합니다. public ResponseEntity> toggleEmoji( @AuthenticationPrincipal Long userId, @PathVariable Long semoFeedId, @Valid @RequestBody SemoFeedEmojiRequest request ) { - return ApiResponse.success( - SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, - semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType()) - ); + SemoFeedEmojiToggleResponse response = semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType()); + return ApiResponse.success(SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, response); } @GetMapping("/me") @@ -71,7 +69,8 @@ public ResponseEntity> toggleEmoji( public ResponseEntity>> listMine( @AuthenticationPrincipal Long userId ) { - return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, semoFeedService.listMine(userId)); + List response = semoFeedService.listMine(userId); + return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, response); } @PatchMapping("/{semoFeedId}/public") diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java index 7772cde..39be861 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java @@ -45,6 +45,7 @@ public static SemoFeedResponse of( ); } + // 새로 생성된 세모피드 응답에 기본 이모지 개수를 채웁니다. private static Map defaultEmojiCounts() { Map emojiCounts = new EnumMap<>(SemoFeedEmojiType.class); for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { @@ -53,6 +54,7 @@ private static Map defaultEmojiCounts() { return emojiCounts; } + // 새로 생성된 세모피드 응답에 기본 내 반응 상태를 채웁니다. private static Map defaultReactedByMe() { Map reactedByMe = new EnumMap<>(SemoFeedEmojiType.class); for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java index 3494285..af2a4d0 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -29,6 +29,7 @@ public class SemoFeedEmojiService { private final UserRepository userRepository; @Transactional(noRollbackFor = DataIntegrityViolationException.class) + // 이모지 반응을 토글하고 변경 후 해당 타입의 개수를 반환합니다. public SemoFeedEmojiToggleResponse toggleWithCount( Long userId, Long semoFeedId, @@ -43,6 +44,7 @@ public SemoFeedEmojiToggleResponse toggleWithCount( return new SemoFeedEmojiToggleResponse(emojiType, reacted, count); } + // 기존 반응이 있으면 취소하고, 없으면 새 반응을 저장합니다. private boolean toggle(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) { Optional existing = semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( semoFeed, @@ -69,11 +71,13 @@ private boolean toggle(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType } } + // 세모피드가 없으면 도메인 예외를 던집니다. private SemoFeed findSemoFeedOrThrow(Long semoFeedId) { return semoFeedRepository.findById(semoFeedId) .orElseThrow(() -> new GeneralException(ErrorStatus.SEMOFEED_NOT_FOUND)); } + // 사용자가 없으면 도메인 예외를 던집니다. private User findUserOrThrow(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java index d9cb821..3910ec4 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java @@ -46,6 +46,7 @@ public List listMine(Long userId) { .toList(); } + // 공개 세모피드 목록을 N+1 없이 응답 DTO로 조립합니다. public Page listPublic(Pageable pageable, Long userId) { Page semoFeeds = semoFeedRepository.findPublic(pageable); return toResponsePage(semoFeeds, userId); @@ -73,11 +74,13 @@ private SemoFeed findOwned(Long userId, Long semoFeedId) { return semoFeed; } + // 페이지 메타데이터를 유지하면서 각 세모피드를 응답 DTO로 변환합니다. private Page toResponsePage(Page semoFeeds, Long userId) { SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds.getContent(), userId); return semoFeeds.map(assembler::toResponse); } + // 피드별 이모지 집계와 내 반응 정보를 한 번씩 조회해 조립기를 만듭니다. private SemoFeedResponseAssembler createResponseAssembler(List semoFeeds, Long userId) { List semoFeedIds = semoFeeds.stream() .map(SemoFeed::getId) @@ -89,6 +92,7 @@ private SemoFeedResponseAssembler createResponseAssembler(List semoFee return new SemoFeedResponseAssembler(userId, emojiCounts, reactedTypes); } + // 피드 ID 목록 기준으로 이모지 타입별 개수를 한 번에 조회합니다. private Map> findEmojiCounts(List semoFeedIds) { if (semoFeedIds.isEmpty()) { return Map.of(); @@ -104,6 +108,7 @@ private Map> findEmojiCounts(List semoF )); } + // 피드 ID 목록 기준으로 로그인 사용자가 누른 이모지 타입을 한 번에 조회합니다. private Map> findReactedTypes(List semoFeedIds, Long userId) { if (semoFeedIds.isEmpty() || userId == null) { return Map.of(); @@ -122,6 +127,7 @@ private record SemoFeedResponseAssembler( Map> reactedTypes ) { + // 세모피드 엔티티와 집계 결과를 최종 응답 DTO로 변환합니다. private SemoFeedResponse toResponse(SemoFeed semoFeed) { return SemoFeedResponse.of( semoFeed, @@ -131,6 +137,7 @@ private SemoFeedResponse toResponse(SemoFeed semoFeed) { ); } + // 누락된 이모지 타입도 0으로 채워 응답 계약을 일정하게 유지합니다. private Map completeEmojiCounts(Map counts) { Map completeCounts = new EnumMap<>(SemoFeedEmojiType.class); for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { @@ -139,6 +146,7 @@ private Map completeEmojiCounts(Map completeReactedByMe(Set reactedTypes) { Map completeReactedTypes = new EnumMap<>(SemoFeedEmojiType.class); for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { From 70a1769a822ac817610a194dbe6128af79fba8db Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:50:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=EC=BB=A4=EB=B0=8B=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0b76ab6..7c97f6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,11 @@ - `./gradlew build` - If full tests fail because local infrastructure such as PostgreSQL or Redis is unavailable, report that clearly and distinguish it from failures caused by the code change. +## Git +- Commit messages must use the format `type: 한글 메시지`. +- Keep the type in English, such as `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, and write the description in Korean. +- Split database migration changes into a separate commit from application code changes. + ## Editing Rules - Never revert or overwrite user changes unless explicitly requested. - Keep changes narrowly scoped to the requested task. From 8aa39eadff0f720e29a62681f6dc01f1dffb061e Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 28 May 2026 13:57:38 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=84=B8=EB=AA=A8=ED=94=BC=EB=93=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=99=80=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/SemoFeedEmojiService.java | 31 ++++--------------- .../api/domain/user/service/UserReader.java | 3 ++ .../service/SemoFeedEmojiServiceTest.java | 8 ++--- .../domain/user/service/UserReaderTest.java | 9 ++++++ 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java index af2a4d0..00f1cb2 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -9,16 +9,13 @@ import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; -import com.semosan.api.domain.user.repository.UserRepository; +import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; -@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -26,9 +23,9 @@ public class SemoFeedEmojiService { private final SemoFeedEmojiRepository semoFeedEmojiRepository; private final SemoFeedRepository semoFeedRepository; - private final UserRepository userRepository; + private final UserReader userReader; - @Transactional(noRollbackFor = DataIntegrityViolationException.class) + @Transactional // 이모지 반응을 토글하고 변경 후 해당 타입의 개수를 반환합니다. public SemoFeedEmojiToggleResponse toggleWithCount( Long userId, @@ -36,7 +33,7 @@ public SemoFeedEmojiToggleResponse toggleWithCount( SemoFeedEmojiType emojiType ) { SemoFeed semoFeed = findSemoFeedOrThrow(semoFeedId); - User user = findUserOrThrow(userId); + User user = userReader.findActiveUserById(userId); boolean reacted = toggle(semoFeed, user, emojiType); long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); @@ -57,18 +54,8 @@ private boolean toggle(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType return false; } - try { - semoFeedEmojiRepository.save(SemoFeedEmoji.create(semoFeed, user, emojiType)); - return true; - } catch (DataIntegrityViolationException e) { - log.warn( - "SemoFeedEmoji 동시 요청 감지: semoFeedId={}, userId={}, emojiType={}", - semoFeed.getId(), - user.getId(), - emojiType - ); - return true; - } + semoFeedEmojiRepository.save(SemoFeedEmoji.create(semoFeed, user, emojiType)); + return true; } // 세모피드가 없으면 도메인 예외를 던집니다. @@ -77,10 +64,4 @@ private SemoFeed findSemoFeedOrThrow(Long semoFeedId) { .orElseThrow(() -> new GeneralException(ErrorStatus.SEMOFEED_NOT_FOUND)); } - // 사용자가 없으면 도메인 예외를 던집니다. - private User findUserOrThrow(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - } - } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserReader.java b/src/main/java/com/semosan/api/domain/user/service/UserReader.java index 48e0a37..626123d 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserReader.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserReader.java @@ -20,6 +20,9 @@ public class UserReader { @Transactional(readOnly = true) public User findActiveUserById(Long userId) { + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_NOT_FOUND); + } return userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); } diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java index 797b442..fda920b 100644 --- a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java @@ -8,7 +8,7 @@ import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.enums.user.DeviceType; -import com.semosan.api.domain.user.repository.UserRepository; +import com.semosan.api.domain.user.service.UserReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -33,7 +33,7 @@ class SemoFeedEmojiServiceTest { private SemoFeedRepository semoFeedRepository; @Mock - private UserRepository userRepository; + private UserReader userReader; @InjectMocks private SemoFeedEmojiService semoFeedEmojiService; @@ -45,7 +45,7 @@ void toggleWithCountCreatesEmojiReaction() { SemoFeed semoFeed = semoFeed(10L, author); when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); - when(userRepository.findById(2L)).thenReturn(Optional.of(reactor)); + when(userReader.findActiveUserById(2L)).thenReturn(reactor); when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( semoFeed, reactor, @@ -73,7 +73,7 @@ void toggleWithCountDeletesExistingEmojiReaction() { SemoFeedEmoji existing = SemoFeedEmoji.create(semoFeed, reactor, SemoFeedEmojiType.LAUGH); when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); - when(userRepository.findById(2L)).thenReturn(Optional.of(reactor)); + when(userReader.findActiveUserById(2L)).thenReturn(reactor); when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( semoFeed, reactor, diff --git a/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java b/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java index 6ec3e41..23a65f5 100644 --- a/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java +++ b/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java @@ -35,6 +35,15 @@ class UserReaderTest { @Mock private UserOnboardingRepository userOnboardingRepository; + @Test + void findActiveUserByIdThrowsWhenUserIdIsNull() { + assertThatThrownBy(() -> new UserReader(userRepository, userOnboardingRepository) + .findActiveUserById(null)) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.USER_NOT_FOUND); + } + @Test void findCompletedOnboardingByUserIdReturnsOnboardingWhenUserIsCompleted() { User user = user();