From 3f45e73ebee76c51950edb46fdeffef6e491519b Mon Sep 17 00:00:00 2001 From: yxxnghwan Date: Fri, 6 Oct 2023 02:15:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/application/BookmarkService.java | 16 ++++++++ .../bookmark/domain/Bookmark.java | 38 +++++++++++++++++++ .../bookmark/domain/BookmarkRepository.java | 9 +++++ .../bookmark/domain/BookmarkToggleSwitch.java | 35 +++++++++++++++++ .../bookmark/ui/BookmarkV1Controller.java | 30 +++++++++++++++ .../ui/dto/BookmarkToggleRequest.java | 16 ++++++++ .../kagongsillok/common/config/WebConfig.java | 2 +- .../exception/CanNotProceedException.java | 8 ++++ .../resolver/AccessTokenArgumentResolver.java | 3 +- .../common/resolver/LoginMember.java | 9 +++++ .../AccessTokenAuthenticationException.java | 2 +- .../common/web/dto/CommonResponse.java | 4 ++ .../member/ui/MemberV1Controller.java | 3 +- .../kagongsillok/place/domain/Place.java | 15 ++++++++ 14 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/domain/Bookmark.java create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkRepository.java create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/ui/BookmarkV1Controller.java create mode 100644 src/main/java/org/prography/kagongsillok/bookmark/ui/dto/BookmarkToggleRequest.java create mode 100644 src/main/java/org/prography/kagongsillok/common/exception/CanNotProceedException.java create mode 100644 src/main/java/org/prography/kagongsillok/common/resolver/LoginMember.java diff --git a/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java b/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java new file mode 100644 index 0000000..8799462 --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java @@ -0,0 +1,16 @@ +package org.prography.kagongsillok.bookmark.application; + +import lombok.RequiredArgsConstructor; +import org.prography.kagongsillok.bookmark.domain.BookmarkToggleSwitch; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BookmarkService { + + private final BookmarkToggleSwitch bookmarkToggleSwitch; + + public void toggle(final Long placeId, final Long memberId) { + bookmarkToggleSwitch.toggle(placeId, memberId); + } +} diff --git a/src/main/java/org/prography/kagongsillok/bookmark/domain/Bookmark.java b/src/main/java/org/prography/kagongsillok/bookmark/domain/Bookmark.java new file mode 100644 index 0000000..78e8c52 --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/domain/Bookmark.java @@ -0,0 +1,38 @@ +package org.prography.kagongsillok.bookmark.domain; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import org.prography.kagongsillok.common.entity.AbstractRootEntity; + +@Getter +@Entity +@Table(name = "bookmark", indexes = { + @Index(name = "ix__bookmark__member_id", columnList = "memberId"), + @Index(name = "ix__bookmark__place_id", columnList = "placeId") +}) +@Where(clause = "is_deleted = false") +@SQLDelete(sql = "update bookmark set is_deleted = true, updated_at = now() where id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Bookmark extends AbstractRootEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long placeId; + private Long memberId; + + public Bookmark(final Long placeId, final Long memberId) { + this.placeId = placeId; + this.memberId = memberId; + } +} diff --git a/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkRepository.java b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkRepository.java new file mode 100644 index 0000000..10a337b --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkRepository.java @@ -0,0 +1,9 @@ +package org.prography.kagongsillok.bookmark.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookmarkRepository extends JpaRepository { + + Optional findByPlaceIdAndMemberIdAndIsDeletedFalse(Long placeId, Long memberId); +} diff --git a/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java new file mode 100644 index 0000000..c34ddde --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java @@ -0,0 +1,35 @@ +package org.prography.kagongsillok.bookmark.domain; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.prography.kagongsillok.place.domain.Place; +import org.prography.kagongsillok.place.domain.PlaceRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BookmarkToggleSwitch { + + private final BookmarkRepository bookmarkRepository; + private final PlaceRepository placeRepository; + + @Transactional + public void toggle(final Long placeId, final Long memberId) { + final Optional bookmark + = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(placeId, memberId); + + bookmark.ifPresentOrElse(this::unbookmark, () -> bookmark(placeId, memberId)); + } + + private void unbookmark(final Bookmark bookmark) { + bookmark.delete(); + placeRepository.findById(bookmark.getPlaceId()) + .ifPresent(Place::decreaseBookmarkCount); + } + + private void bookmark(final Long placeId, final Long memberId) { + final Bookmark bookmark = new Bookmark(placeId, memberId); + bookmarkRepository.save(bookmark); + } +} diff --git a/src/main/java/org/prography/kagongsillok/bookmark/ui/BookmarkV1Controller.java b/src/main/java/org/prography/kagongsillok/bookmark/ui/BookmarkV1Controller.java new file mode 100644 index 0000000..01e8d31 --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/ui/BookmarkV1Controller.java @@ -0,0 +1,30 @@ +package org.prography.kagongsillok.bookmark.ui; + +import lombok.RequiredArgsConstructor; +import org.prography.kagongsillok.bookmark.application.BookmarkService; +import org.prography.kagongsillok.bookmark.ui.dto.BookmarkToggleRequest; +import org.prography.kagongsillok.common.resolver.LoginMember; +import org.prography.kagongsillok.common.resolver.dto.LoginMemberDto; +import org.prography.kagongsillok.common.web.dto.CommonResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/bookmark") +@RequiredArgsConstructor +public class BookmarkV1Controller { + + private final BookmarkService bookmarkService; + + @PostMapping("/toggle") + public ResponseEntity> toggle( + @LoginMember final LoginMemberDto loginMemberDto, + @RequestBody final BookmarkToggleRequest request + ) { + bookmarkService.toggle(request.getPlaceId(), loginMemberDto.getMemberId()); + return CommonResponse.success(); + } +} diff --git a/src/main/java/org/prography/kagongsillok/bookmark/ui/dto/BookmarkToggleRequest.java b/src/main/java/org/prography/kagongsillok/bookmark/ui/dto/BookmarkToggleRequest.java new file mode 100644 index 0000000..c2d92a2 --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/bookmark/ui/dto/BookmarkToggleRequest.java @@ -0,0 +1,16 @@ +package org.prography.kagongsillok.bookmark.ui.dto; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BookmarkToggleRequest { + + private Long placeId; + + public BookmarkToggleRequest(final Long placeId) { + this.placeId = placeId; + } +} diff --git a/src/main/java/org/prography/kagongsillok/common/config/WebConfig.java b/src/main/java/org/prography/kagongsillok/common/config/WebConfig.java index 5a0eeaf..281e887 100644 --- a/src/main/java/org/prography/kagongsillok/common/config/WebConfig.java +++ b/src/main/java/org/prography/kagongsillok/common/config/WebConfig.java @@ -12,7 +12,7 @@ @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { - private final long MAX_AGE = 3600; + private static final long MAX_AGE = 3600L; private final AccessTokenArgumentResolver accessTokenArgumentResolver; diff --git a/src/main/java/org/prography/kagongsillok/common/exception/CanNotProceedException.java b/src/main/java/org/prography/kagongsillok/common/exception/CanNotProceedException.java new file mode 100644 index 0000000..cfd105c --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/common/exception/CanNotProceedException.java @@ -0,0 +1,8 @@ +package org.prography.kagongsillok.common.exception; + +public class CanNotProceedException extends BusinessException { + + public CanNotProceedException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/prography/kagongsillok/common/resolver/AccessTokenArgumentResolver.java b/src/main/java/org/prography/kagongsillok/common/resolver/AccessTokenArgumentResolver.java index 6b70744..2c31256 100644 --- a/src/main/java/org/prography/kagongsillok/common/resolver/AccessTokenArgumentResolver.java +++ b/src/main/java/org/prography/kagongsillok/common/resolver/AccessTokenArgumentResolver.java @@ -23,7 +23,8 @@ public class AccessTokenArgumentResolver implements HandlerMethodArgumentResolve @Override public boolean supportsParameter(final MethodParameter parameter) { - return parameter.getParameterType().equals(LoginMemberDto.class); + return parameter.getParameterType().equals(LoginMemberDto.class) + && parameter.hasParameterAnnotation(LoginMember.class); } @Override diff --git a/src/main/java/org/prography/kagongsillok/common/resolver/LoginMember.java b/src/main/java/org/prography/kagongsillok/common/resolver/LoginMember.java new file mode 100644 index 0000000..251b659 --- /dev/null +++ b/src/main/java/org/prography/kagongsillok/common/resolver/LoginMember.java @@ -0,0 +1,9 @@ +package org.prography.kagongsillok.common.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +public @interface LoginMember { + +} diff --git a/src/main/java/org/prography/kagongsillok/common/resolver/exception/AccessTokenAuthenticationException.java b/src/main/java/org/prography/kagongsillok/common/resolver/exception/AccessTokenAuthenticationException.java index b1094cc..f8cfc6f 100644 --- a/src/main/java/org/prography/kagongsillok/common/resolver/exception/AccessTokenAuthenticationException.java +++ b/src/main/java/org/prography/kagongsillok/common/resolver/exception/AccessTokenAuthenticationException.java @@ -5,6 +5,6 @@ public class AccessTokenAuthenticationException extends AuthenticationException { public AccessTokenAuthenticationException() { - super(String.format("Header에 AccessToken이 존재하지 않습니다.")); + super("Header에 AccessToken이 존재하지 않습니다."); } } diff --git a/src/main/java/org/prography/kagongsillok/common/web/dto/CommonResponse.java b/src/main/java/org/prography/kagongsillok/common/web/dto/CommonResponse.java index bb57da8..214491d 100644 --- a/src/main/java/org/prography/kagongsillok/common/web/dto/CommonResponse.java +++ b/src/main/java/org/prography/kagongsillok/common/web/dto/CommonResponse.java @@ -22,6 +22,10 @@ public static ResponseEntity> success(final T data) { return ResponseEntity.ok(new CommonResponse<>(SUCCESS_MESSAGE, null, data)); } + public static ResponseEntity> success() { + return ResponseEntity.ok(new CommonResponse<>(SUCCESS_MESSAGE, null, null)); + } + public static CommonResponse error(final String message) { return new CommonResponse<>(ERROR_MESSAGE, message, null); } diff --git a/src/main/java/org/prography/kagongsillok/member/ui/MemberV1Controller.java b/src/main/java/org/prography/kagongsillok/member/ui/MemberV1Controller.java index 57e5447..9328f7f 100644 --- a/src/main/java/org/prography/kagongsillok/member/ui/MemberV1Controller.java +++ b/src/main/java/org/prography/kagongsillok/member/ui/MemberV1Controller.java @@ -1,6 +1,7 @@ package org.prography.kagongsillok.member.ui; import lombok.RequiredArgsConstructor; +import org.prography.kagongsillok.common.resolver.LoginMember; import org.prography.kagongsillok.common.resolver.dto.LoginMemberDto; import org.prography.kagongsillok.common.web.dto.CommonResponse; import org.prography.kagongsillok.member.application.MemberService; @@ -19,7 +20,7 @@ public class MemberV1Controller { private final MemberService memberService; @GetMapping - public ResponseEntity> getMember(LoginMemberDto loginMemberDto) { + public ResponseEntity> getMember(@LoginMember LoginMemberDto loginMemberDto) { final MemberDto memberDto = memberService.getMember(loginMemberDto); return CommonResponse.success(MemberResponse.from(memberDto)); } diff --git a/src/main/java/org/prography/kagongsillok/place/domain/Place.java b/src/main/java/org/prography/kagongsillok/place/domain/Place.java index d074379..dc5971d 100644 --- a/src/main/java/org/prography/kagongsillok/place/domain/Place.java +++ b/src/main/java/org/prography/kagongsillok/place/domain/Place.java @@ -15,6 +15,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; import org.prography.kagongsillok.common.entity.AbstractRootEntity; +import org.prography.kagongsillok.common.exception.CanNotProceedException; import org.prography.kagongsillok.common.utils.CustomListUtils; import org.prography.kagongsillok.common.utils.CustomStringUtils; @@ -47,6 +48,8 @@ public class Place extends AbstractRootEntity { @Embedded private BusinessHours businessHours; + private Integer bookmarkCount; + @Builder public Place( final String name, @@ -67,6 +70,7 @@ public Place( this.phone = phone; this.links = Links.of(links); this.businessHours = BusinessHours.of(businessHours); + this.bookmarkCount = 0; } public void update(final Place target) { @@ -80,6 +84,17 @@ public void update(final Place target) { this.businessHours.update(target.businessHours); } + public void increaseBookmarkCount() { + bookmarkCount++; + } + + public void decreaseBookmarkCount() { + if (bookmarkCount == 0) { + throw new CanNotProceedException("북마크 수가 0보다 낮을 수 없습니다."); + } + bookmarkCount--; + } + public List getImageIds() { return CustomStringUtils.splitToList(imageIds, ",", Long::valueOf); } From 599941fa107475d4dd71a433cdab116b10c8bbe8 Mon Sep 17 00:00:00 2001 From: yxxnghwan Date: Sat, 7 Oct 2023 03:16:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20redisson=EC=9D=84=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=9D=B4=EC=8A=88=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../bookmark/application/BookmarkService.java | 19 ++- .../bookmark/domain/BookmarkToggleSwitch.java | 2 + .../common/web/ExceptionHandlerAdvice.java | 10 +- .../application/BookmarkServiceTest.java | 156 ++++++++++++++++++ 5 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/prography/kagongsillok/bookmark/application/BookmarkServiceTest.java diff --git a/build.gradle b/build.gradle index 5f2287f..4e82d14 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.kafka:spring-kafka' implementation 'it.ozimov:embedded-redis:0.7.2' + implementation 'org.redisson:redisson-spring-boot-starter:3.21.1' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java b/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java index 8799462..c8b92b8 100644 --- a/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java +++ b/src/main/java/org/prography/kagongsillok/bookmark/application/BookmarkService.java @@ -1,16 +1,33 @@ package org.prography.kagongsillok.bookmark.application; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.prography.kagongsillok.bookmark.domain.BookmarkToggleSwitch; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class BookmarkService { + public static final String BOOKMARK_LOCK_FORMAT = "lockBookmark::placeId=%d::memberId=%d"; private final BookmarkToggleSwitch bookmarkToggleSwitch; + private final RedissonClient redissonClient; public void toggle(final Long placeId, final Long memberId) { - bookmarkToggleSwitch.toggle(placeId, memberId); + final RLock lock = redissonClient.getLock(String.format(BOOKMARK_LOCK_FORMAT, placeId, memberId)); + + try { + final boolean available = lock.tryLock(15, 1, TimeUnit.SECONDS); + if (!available) { + throw new RuntimeException("락이 오랜 시간 획득되지 않습니다. 데드락이 발생했을 수 있습니다."); + } + bookmarkToggleSwitch.toggle(placeId, memberId); + } catch (final InterruptedException e) { + throw new RuntimeException(e); + } finally { + lock.unlock(); + } } } diff --git a/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java index c34ddde..53539d0 100644 --- a/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java +++ b/src/main/java/org/prography/kagongsillok/bookmark/domain/BookmarkToggleSwitch.java @@ -31,5 +31,7 @@ private void unbookmark(final Bookmark bookmark) { private void bookmark(final Long placeId, final Long memberId) { final Bookmark bookmark = new Bookmark(placeId, memberId); bookmarkRepository.save(bookmark); + placeRepository.findById(bookmark.getPlaceId()) + .ifPresent(Place::increaseBookmarkCount); } } diff --git a/src/main/java/org/prography/kagongsillok/common/web/ExceptionHandlerAdvice.java b/src/main/java/org/prography/kagongsillok/common/web/ExceptionHandlerAdvice.java index a71ca20..e23cec3 100644 --- a/src/main/java/org/prography/kagongsillok/common/web/ExceptionHandlerAdvice.java +++ b/src/main/java/org/prography/kagongsillok/common/web/ExceptionHandlerAdvice.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.prography.kagongsillok.auth.application.exception.AuthenticationException; +import org.prography.kagongsillok.common.exception.CanNotProceedException; import org.prography.kagongsillok.common.exception.CommonSecurityException; import org.prography.kagongsillok.common.exception.InvalidParamException; import org.prography.kagongsillok.common.exception.NotFoundException; @@ -41,9 +42,16 @@ public ResponseEntity> handleNotExistException(final NotFou return ResponseEntity.status(HttpStatus.NOT_FOUND).body(CommonResponse.error(e.getMessage())); } + @ExceptionHandler(CanNotProceedException.class) + public ResponseEntity> handleCanNotProceed(final CanNotProceedException e) { + log.warn(e.getMessage()); + return ResponseEntity.badRequest().body(CommonResponse.error(e.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity> unhandledException(final Exception e) { + log.error(e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(CommonResponse.error(e.getMessage())); + .body(CommonResponse.error("알 수 없는 예외 발생")); } } diff --git a/src/test/java/org/prography/kagongsillok/bookmark/application/BookmarkServiceTest.java b/src/test/java/org/prography/kagongsillok/bookmark/application/BookmarkServiceTest.java new file mode 100644 index 0000000..d6bc312 --- /dev/null +++ b/src/test/java/org/prography/kagongsillok/bookmark/application/BookmarkServiceTest.java @@ -0,0 +1,156 @@ +package org.prography.kagongsillok.bookmark.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.prography.kagongsillok.bookmark.domain.Bookmark; +import org.prography.kagongsillok.bookmark.domain.BookmarkRepository; +import org.prography.kagongsillok.place.application.dto.PlaceCreateCommand; +import org.prography.kagongsillok.place.application.dto.PlaceCreateCommand.BusinessHourCreateCommand; +import org.prography.kagongsillok.place.application.dto.PlaceCreateCommand.LinkCreateCommand; +import org.prography.kagongsillok.place.domain.DayOfWeek; +import org.prography.kagongsillok.place.domain.LinkType; +import org.prography.kagongsillok.place.domain.Place; +import org.prography.kagongsillok.place.domain.PlaceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class BookmarkServiceTest { + + private final List linkCreateCommands = List.of( + new LinkCreateCommand(LinkType.INSTAGRAM.name(), "testInstagramUrl"), + new LinkCreateCommand(LinkType.BLOG.name(), "testBlogUrl"), + new LinkCreateCommand(LinkType.WEB.name(), "testWebUrl") + ); + private final List businessHourCreateCommands = List.of( + new BusinessHourCreateCommand( + DayOfWeek.MONDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.TUESDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.WEDNESDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.THURSDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.FRIDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.SATURDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ), + new BusinessHourCreateCommand( + DayOfWeek.SUNDAY.name(), LocalTime.of(12, 0), LocalTime.of(23, 59) + ) + ); + + @Autowired + private BookmarkService bookmarkService; + + @Autowired + private BookmarkRepository bookmarkRepository; + + @Autowired + private PlaceRepository placeRepository; + private Place testPlace; + + @BeforeEach + void setUp() { + final PlaceCreateCommand placeCreateCommand1 = PlaceCreateCommand + .builder() + .name("테스트 장소1") + .address("테스트특별시 테스트구") + .latitude(49.67) + .longitude(129.23) + .imageIds(List.of(1L, 2L, 3L)) + .phone("testPhoneNumber") + .links(linkCreateCommands) + .businessHours(businessHourCreateCommands) + .build(); + testPlace = placeRepository.save(placeCreateCommand1.toEntity()); + } + + @Test + void 장소를_북마크한다() { + bookmarkService.toggle(testPlace.getId(), 123L); + + final Optional bookmark = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(testPlace.getId(), 123L); + + assertThat(bookmark).isPresent(); + } + + @Test + void 장소_북마크를_해제한다() { + bookmarkService.toggle(testPlace.getId(), 123L); + + bookmarkService.toggle(testPlace.getId(), 123L); + + final Optional bookmark = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(testPlace.getId(), 123L); + assertThat(bookmark).isEmpty(); + } + + @Test + void 장소_북마크를_해제하고_다시_북마크_한다() { + bookmarkService.toggle(testPlace.getId(), 123L); + bookmarkService.toggle(testPlace.getId(), 123L); + + bookmarkService.toggle(testPlace.getId(), 123L); + final Optional bookmark = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(testPlace.getId(), 123L); + assertThat(bookmark).isPresent(); + } + + @Nested + class 동시성_테스트 { + + @Test + void 동시에_북마크_홀수번_토글() throws InterruptedException { + final int count = 21; + final ExecutorService executorService = Executors.newFixedThreadPool(count); + + final CountDownLatch countDownLatch = new CountDownLatch(count); + for (int i = 0; i < count; i++) { + executorService.submit(() -> { + bookmarkService.toggle(testPlace.getId(), 123L); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + final Optional bookmark = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(testPlace.getId(), 123L); + + assertThat(bookmark).isPresent(); + } + + @Test + void 동시에_북마크_짝수번_토글() throws InterruptedException { + final int count = 20; + final ExecutorService executorService = Executors.newFixedThreadPool(count); + + final CountDownLatch countDownLatch = new CountDownLatch(count); + for (int i = 0; i < count; i++) { + executorService.submit(() -> { + bookmarkService.toggle(testPlace.getId(), 123L); + countDownLatch.countDown(); + }); + } + countDownLatch.await(); + + final Optional bookmark = bookmarkRepository.findByPlaceIdAndMemberIdAndIsDeletedFalse(testPlace.getId(), 123L); + + assertThat(bookmark).isEmpty(); + } + } +}