From e652eed2b901504383a12d5518e9e978dac6bd85 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Fri, 15 Aug 2025 18:40:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20CheerTag=20=EC=9D=98=20=EC=9D=BC?= =?UTF-8?q?=EA=B8=89=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/eatda/domain/cheer/CheerTags.java | 60 ++++++++++++++ .../eatda/domain/cheer/CheerTagsTest.java | 78 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/main/java/eatda/domain/cheer/CheerTags.java create mode 100644 src/test/java/eatda/domain/cheer/CheerTagsTest.java diff --git a/src/main/java/eatda/domain/cheer/CheerTags.java b/src/main/java/eatda/domain/cheer/CheerTags.java new file mode 100644 index 00000000..299e66b4 --- /dev/null +++ b/src/main/java/eatda/domain/cheer/CheerTags.java @@ -0,0 +1,60 @@ +package eatda.domain.cheer; + +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CheerTags { + + private static final int MAX_CHEER_TAGS_PER_TYPE = 2; + + @OneToMany(mappedBy = "cheer", cascade = CascadeType.ALL, orphanRemoval = true) + private List values = new ArrayList<>(); + + public CheerTags(Cheer cheer, List cheerTagsNames) { + validate(cheerTagsNames); + this.values = cheerTagsNames.stream() + .map(name -> new CheerTag(cheer, name)) + .collect(Collectors.toList()); + } + + private void validate(List cheerTagNames) { + if (isDuplicated(cheerTagNames)) { + throw new BusinessException(BusinessErrorCode.CHEER_TAGS_DUPLICATED); + } + if (maxCountByType(cheerTagNames) > MAX_CHEER_TAGS_PER_TYPE) { + throw new BusinessException(BusinessErrorCode.EXCEED_CHEER_TAGS_PER_TYPE); + } + } + + private boolean isDuplicated(List cheerTagNames) { + long distinctCount = cheerTagNames.stream() + .distinct() + .count(); + return distinctCount != cheerTagNames.size(); + } + + private long maxCountByType(List cheerTagNames) { + return cheerTagNames.stream() + .collect(Collectors.groupingBy(CheerTagName::getType, Collectors.counting())) + .values() + .stream() + .max(Long::compareTo) + .orElse(0L); + } + + public List getNames() { + return values.stream() + .map(CheerTag::getName) + .toList(); + } +} diff --git a/src/test/java/eatda/domain/cheer/CheerTagsTest.java b/src/test/java/eatda/domain/cheer/CheerTagsTest.java new file mode 100644 index 00000000..79cfbdd4 --- /dev/null +++ b/src/test/java/eatda/domain/cheer/CheerTagsTest.java @@ -0,0 +1,78 @@ +package eatda.domain.cheer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import eatda.domain.ImageKey; +import eatda.domain.member.Member; +import eatda.domain.store.District; +import eatda.domain.store.Store; +import eatda.domain.store.StoreCategory; +import eatda.exception.BusinessErrorCode; +import eatda.exception.BusinessException; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CheerTagsTest { + + private static final Member DEFAULT_MEMBER = new Member("socialId", "email@kakao.com", "nickname"); + private static final Store DEFAULT_STORE = Store.builder() + .kakaoId("123456789") + .category(StoreCategory.OTHER) + .phoneNumber("010-1234-5678") + .name("가게 이름") + .placeUrl("https://place.kakao.com/123456789") + .roadAddress("") + .lotNumberAddress("서울특별시 강남구 역삼동 123-45") + .district(District.GANGNAM) + .latitude(37.5665) + .longitude(126.978) + .build(); + private static final Cheer DEFAULT_CHEER = new Cheer(DEFAULT_MEMBER, DEFAULT_STORE, "Great store!", + new ImageKey("imageKey")); + + @Nested + class Validate { + + @Test + void 각_카테고리별_태그는_최대_개수가_정해져있다() { + List tagNames = List.of( + CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, + CheerTagName.GROUP_RESERVATION, CheerTagName.LARGE_PARKING); + + assertThatCode(() -> new CheerTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); + } + + @Test + void 태그_이름은_비어있을_수_있다() { + List tagNames = Collections.emptyList(); + + assertThatCode(() -> new CheerTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); + } + + @Test + void 카테고리별_태그는_최대_개수를_초과할_수_없다() { + List tagNames = List.of( + CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, CheerTagName.GOOD_FOR_DATING); + + BusinessException exception = assertThrows(BusinessException.class, + () -> new CheerTags(DEFAULT_CHEER, tagNames)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.EXCEED_CHEER_TAGS_PER_TYPE); + } + + @Test + void 태그_이름은_중복될_수_없다() { + List tagNames = List.of(CheerTagName.OLD_STORE_MOOD, CheerTagName.OLD_STORE_MOOD); + + BusinessException exception = assertThrows(BusinessException.class, + () -> new CheerTags(DEFAULT_CHEER, tagNames)); + + assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.CHEER_TAGS_DUPLICATED); + } + } + +} From bb70160786f37b091b5b107510f2397428a6ae18 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Fri, 15 Aug 2025 18:45:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Cheer=20=EC=97=90=EC=84=9C=20CheerT?= =?UTF-8?q?ags=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/domain/cheer/Cheer.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/eatda/domain/cheer/Cheer.java b/src/main/java/eatda/domain/cheer/Cheer.java index 4257be36..c25592b4 100644 --- a/src/main/java/eatda/domain/cheer/Cheer.java +++ b/src/main/java/eatda/domain/cheer/Cheer.java @@ -16,6 +16,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.util.Collections; +import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -44,6 +46,9 @@ public class Cheer extends AuditingEntity { @Embedded private ImageKey imageKey; + @Embedded + private CheerTags cheerTags; + @Column(name = "is_admin", nullable = false) private boolean isAdmin; @@ -53,7 +58,6 @@ public Cheer(Member member, Store store, String description, ImageKey imageKey) this.store = store; this.description = description; this.imageKey = imageKey; - this.isAdmin = false; } @@ -67,4 +71,15 @@ private void validateDescription(String description) { throw new BusinessException(BusinessErrorCode.INVALID_CHEER_DESCRIPTION); } } + + public void setCheerTags(List cheerTagNames) { + this.cheerTags = new CheerTags(this, cheerTagNames); + } + + public List getCheerTagNames() { + if (cheerTags == null) { + return Collections.emptyList(); + } + return cheerTags.getNames(); + } } From 326dec0007aa206befc8d0250e4ebcb3c4d76611 Mon Sep 17 00:00:00 2001 From: leegwichan Date: Fri, 15 Aug 2025 18:49:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=9D=91=EC=9B=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatda/controller/cheer/CheerResponse.java | 11 +--- .../eatda/domain/cheer/CheerTagNames.java | 49 ----------------- .../eatda/service/cheer/CheerService.java | 10 ++-- .../eatda/domain/cheer/CheerTagNamesTest.java | 54 ------------------- .../eatda/service/cheer/CheerServiceTest.java | 21 ++++++++ 5 files changed, 27 insertions(+), 118 deletions(-) delete mode 100644 src/main/java/eatda/domain/cheer/CheerTagNames.java delete mode 100644 src/test/java/eatda/domain/cheer/CheerTagNamesTest.java diff --git a/src/main/java/eatda/controller/cheer/CheerResponse.java b/src/main/java/eatda/controller/cheer/CheerResponse.java index 85dca2b2..45f6c6ee 100644 --- a/src/main/java/eatda/controller/cheer/CheerResponse.java +++ b/src/main/java/eatda/controller/cheer/CheerResponse.java @@ -1,7 +1,6 @@ package eatda.controller.cheer; import eatda.domain.cheer.Cheer; -import eatda.domain.cheer.CheerTag; import eatda.domain.cheer.CheerTagName; import eatda.domain.store.Store; import java.util.List; @@ -14,19 +13,13 @@ public record CheerResponse( List tags ) { - public CheerResponse(Cheer cheer, List cheerTags, Store store, String imageUrl) { + public CheerResponse(Cheer cheer, Store store, String imageUrl) { this( store.getId(), cheer.getId(), imageUrl, cheer.getDescription(), - toTagNames(cheerTags) + cheer.getCheerTagNames() ); } - - private static List toTagNames(List cheerTags) { - return cheerTags.stream() - .map(CheerTag::getName) - .toList(); - } } diff --git a/src/main/java/eatda/domain/cheer/CheerTagNames.java b/src/main/java/eatda/domain/cheer/CheerTagNames.java deleted file mode 100644 index 89d34246..00000000 --- a/src/main/java/eatda/domain/cheer/CheerTagNames.java +++ /dev/null @@ -1,49 +0,0 @@ -package eatda.domain.cheer; - -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.util.List; -import java.util.stream.Collectors; - -public class CheerTagNames { - - private static final int MAX_CHEER_TAGS_PER_TYPE = 2; - - private final List cheerTagNames; - - public CheerTagNames(List cheerTagNames) { - validate(cheerTagNames); - this.cheerTagNames = List.copyOf(cheerTagNames); - } - - private void validate(List cheerTagNames) { - if (isDuplicated(cheerTagNames)) { - throw new BusinessException(BusinessErrorCode.CHEER_TAGS_DUPLICATED); - } - if (maxCountByType(cheerTagNames) > MAX_CHEER_TAGS_PER_TYPE) { - throw new BusinessException(BusinessErrorCode.EXCEED_CHEER_TAGS_PER_TYPE); - } - } - - private boolean isDuplicated(List cheerTagNames) { - long distinctCount = cheerTagNames.stream() - .distinct() - .count(); - return distinctCount != cheerTagNames.size(); - } - - private long maxCountByType(List cheerTagNames) { - return cheerTagNames.stream() - .collect(Collectors.groupingBy(CheerTagName::getType, Collectors.counting())) - .values() - .stream() - .max(Long::compareTo) - .orElse(0L); - } - - public List toCheerTags(Cheer cheer) { - return cheerTagNames.stream() - .map(name -> new CheerTag(cheer, name)) - .toList(); - } -} diff --git a/src/main/java/eatda/service/cheer/CheerService.java b/src/main/java/eatda/service/cheer/CheerService.java index 89c06e3e..3741ee05 100644 --- a/src/main/java/eatda/service/cheer/CheerService.java +++ b/src/main/java/eatda/service/cheer/CheerService.java @@ -8,8 +8,6 @@ import eatda.controller.cheer.CheersResponse; import eatda.domain.ImageKey; import eatda.domain.cheer.Cheer; -import eatda.domain.cheer.CheerTag; -import eatda.domain.cheer.CheerTagNames; import eatda.domain.member.Member; import eatda.domain.store.Store; import eatda.domain.store.StoreSearchResult; @@ -43,15 +41,15 @@ public CheerResponse registerCheer(CheerRegisterRequest request, StoreSearchResult result, ImageKey imageKey, long memberId) { - CheerTagNames cheerTagNames = new CheerTagNames(request.tags()); Member member = memberRepository.getById(memberId); validateRegisterCheer(member, request.storeKakaoId()); Store store = storeRepository.findByKakaoId(result.kakaoId()) .orElseGet(() -> storeRepository.save(result.toStore())); // TODO 상점 조회/저장 동시성 이슈 해결 - Cheer cheer = cheerRepository.save(new Cheer(member, store, request.description(), imageKey)); - List cheerTags = cheerTagRepository.saveAll(cheerTagNames.toCheerTags(cheer)); - return new CheerResponse(cheer, cheerTags, store, imageStorage.getPreSignedUrl(imageKey)); + Cheer cheer = new Cheer(member, store, request.description(), imageKey); + cheer.setCheerTags(request.tags()); + Cheer savedCheer = cheerRepository.save(cheer); + return new CheerResponse(savedCheer, store, imageStorage.getPreSignedUrl(imageKey)); } private void validateRegisterCheer(Member member, String storeKakaoId) { diff --git a/src/test/java/eatda/domain/cheer/CheerTagNamesTest.java b/src/test/java/eatda/domain/cheer/CheerTagNamesTest.java deleted file mode 100644 index 22100eee..00000000 --- a/src/test/java/eatda/domain/cheer/CheerTagNamesTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package eatda.domain.cheer; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import eatda.exception.BusinessErrorCode; -import eatda.exception.BusinessException; -import java.util.Collections; -import java.util.List; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class CheerTagNamesTest { - - @Nested - class Validate { - - @Test - void 각_카테고리별_태그는_최대_개수가_정해져있다() { - List tagNames = List.of( - CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, - CheerTagName.GROUP_RESERVATION, CheerTagName.LARGE_PARKING); - - assertThatCode(() -> new CheerTagNames(tagNames)).doesNotThrowAnyException(); - } - - @Test - void 태그_이름은_비어있을_수_있다() { - List tagNames = Collections.emptyList(); - - assertThatCode(() -> new CheerTagNames(tagNames)).doesNotThrowAnyException(); - } - - @Test - void 카테고리별_태그는_최대_개수를_초과할_수_없다() { - List tagNames = List.of( - CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, CheerTagName.GOOD_FOR_DATING); - - BusinessException exception = assertThrows(BusinessException.class, () -> new CheerTagNames(tagNames)); - - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.EXCEED_CHEER_TAGS_PER_TYPE); - } - - @Test - void 태그_이름은_중복될_수_없다() { - List tagNames = List.of(CheerTagName.OLD_STORE_MOOD, CheerTagName.OLD_STORE_MOOD); - - BusinessException exception = assertThrows(BusinessException.class, () -> new CheerTagNames(tagNames)); - - assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.CHEER_TAGS_DUPLICATED); - } - } -} diff --git a/src/test/java/eatda/service/cheer/CheerServiceTest.java b/src/test/java/eatda/service/cheer/CheerServiceTest.java index 7f3aa001..1f424334 100644 --- a/src/test/java/eatda/service/cheer/CheerServiceTest.java +++ b/src/test/java/eatda/service/cheer/CheerServiceTest.java @@ -145,6 +145,27 @@ class RegisterCheer { CheerTagName.GOOD_FOR_DATING, CheerTagName.CLEAN_RESTROOM) ); } + + @Test + void 해당_응원의_응원_태그가_비어있어도_응원을_저장할_수_있다() { + Member member = memberGenerator.generate("123"); + + CheerRegisterRequest request = new CheerRegisterRequest("123", "농민백암순대 본점", "맛있어요!", List.of()); + StoreSearchResult result = new StoreSearchResult( + "123", StoreCategory.KOREAN, "02-755-5232", "농민백암순대 본점", "http://place.map.kakao.com/123", + "서울시 강남구 역삼동 123-45", "서울시 강남구 역삼동 123-45", District.GANGNAM, 37.5665, 126.9780); + ImageKey imageKey = new ImageKey("image-key"); + + CheerResponse response = cheerService.registerCheer(request, result, imageKey, member.getId()); + + Store foundStore = storeRepository.findByKakaoId("123").orElseThrow(); + assertAll( + () -> assertThat(response.storeId()).isEqualTo(foundStore.getId()), + () -> assertThat(response.cheerDescription()).isEqualTo("맛있어요!"), + () -> assertThat(response.imageUrl()).isNotBlank(), + () -> assertThat(response.tags()).isEmpty() + ); + } } @Nested From 9d95190e36daf3aae7ab50dcdde7b2557d83c33a Mon Sep 17 00:00:00 2001 From: leegwichan Date: Fri, 15 Aug 2025 23:24:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20orphanRemoval=20=EC=9D=84=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=ED=95=9C=20=ED=99=9C=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20CheerTags=20=EC=97=90=20set=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/eatda/domain/cheer/Cheer.java | 4 +++- src/main/java/eatda/domain/cheer/CheerTags.java | 16 ++++++++-------- .../java/eatda/domain/cheer/CheerTagsTest.java | 14 +++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/java/eatda/domain/cheer/Cheer.java b/src/main/java/eatda/domain/cheer/Cheer.java index c25592b4..ecc8acfd 100644 --- a/src/main/java/eatda/domain/cheer/Cheer.java +++ b/src/main/java/eatda/domain/cheer/Cheer.java @@ -58,6 +58,8 @@ public Cheer(Member member, Store store, String description, ImageKey imageKey) this.store = store; this.description = description; this.imageKey = imageKey; + this.cheerTags = new CheerTags(); + this.isAdmin = false; } @@ -73,7 +75,7 @@ private void validateDescription(String description) { } public void setCheerTags(List cheerTagNames) { - this.cheerTags = new CheerTags(this, cheerTagNames); + this.cheerTags.setTags(this, cheerTagNames); } public List getCheerTagNames() { diff --git a/src/main/java/eatda/domain/cheer/CheerTags.java b/src/main/java/eatda/domain/cheer/CheerTags.java index 299e66b4..6b0ed6e0 100644 --- a/src/main/java/eatda/domain/cheer/CheerTags.java +++ b/src/main/java/eatda/domain/cheer/CheerTags.java @@ -8,11 +8,8 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; @Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) public class CheerTags { private static final int MAX_CHEER_TAGS_PER_TYPE = 2; @@ -20,11 +17,14 @@ public class CheerTags { @OneToMany(mappedBy = "cheer", cascade = CascadeType.ALL, orphanRemoval = true) private List values = new ArrayList<>(); - public CheerTags(Cheer cheer, List cheerTagsNames) { - validate(cheerTagsNames); - this.values = cheerTagsNames.stream() - .map(name -> new CheerTag(cheer, name)) - .collect(Collectors.toList()); + public void setTags(Cheer cheer, List cheerTagNames) { + validate(cheerTagNames); + List cheerTags = cheerTagNames.stream() + .map(name -> new CheerTag(cheer, name)) // cheer is set later + .toList(); + + this.values.clear(); + this.values.addAll(cheerTags); } private void validate(List cheerTagNames) { diff --git a/src/test/java/eatda/domain/cheer/CheerTagsTest.java b/src/test/java/eatda/domain/cheer/CheerTagsTest.java index 79cfbdd4..0319f9dc 100644 --- a/src/test/java/eatda/domain/cheer/CheerTagsTest.java +++ b/src/test/java/eatda/domain/cheer/CheerTagsTest.java @@ -35,31 +35,34 @@ class CheerTagsTest { new ImageKey("imageKey")); @Nested - class Validate { + class SetTags { @Test void 각_카테고리별_태그는_최대_개수가_정해져있다() { List tagNames = List.of( CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, CheerTagName.GROUP_RESERVATION, CheerTagName.LARGE_PARKING); + CheerTags cheerTags = new CheerTags(); - assertThatCode(() -> new CheerTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); + assertThatCode(() -> cheerTags.setTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); } @Test void 태그_이름은_비어있을_수_있다() { List tagNames = Collections.emptyList(); + CheerTags cheerTags = new CheerTags(); - assertThatCode(() -> new CheerTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); + assertThatCode(() -> cheerTags.setTags(DEFAULT_CHEER, tagNames)).doesNotThrowAnyException(); } @Test void 카테고리별_태그는_최대_개수를_초과할_수_없다() { List tagNames = List.of( CheerTagName.OLD_STORE_MOOD, CheerTagName.ENERGETIC, CheerTagName.GOOD_FOR_DATING); + CheerTags cheerTags = new CheerTags(); BusinessException exception = assertThrows(BusinessException.class, - () -> new CheerTags(DEFAULT_CHEER, tagNames)); + () -> cheerTags.setTags(DEFAULT_CHEER, tagNames)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.EXCEED_CHEER_TAGS_PER_TYPE); } @@ -67,9 +70,10 @@ class Validate { @Test void 태그_이름은_중복될_수_없다() { List tagNames = List.of(CheerTagName.OLD_STORE_MOOD, CheerTagName.OLD_STORE_MOOD); + CheerTags cheerTags = new CheerTags(); BusinessException exception = assertThrows(BusinessException.class, - () -> new CheerTags(DEFAULT_CHEER, tagNames)); + () -> cheerTags.setTags(DEFAULT_CHEER, tagNames)); assertThat(exception.getErrorCode()).isEqualTo(BusinessErrorCode.CHEER_TAGS_DUPLICATED); }