From dba44b90a33174821d738d0c2294fc580c7e2820 Mon Sep 17 00:00:00 2001 From: Dmori <83327857+Dimo-2562@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:31:22 +0900 Subject: [PATCH] =?UTF-8?q?[feature/#741]=20=EA=B2=8C=EC=9E=84=ED=8C=90=20?= =?UTF-8?q?=EB=AA=85=EB=8B=A8=20=EC=85=94=ED=8B=80=EC=BD=95=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=20=EC=97=AC=EB=B6=80=20=EB=B3=80=EA=B2=BD=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 셔틀콕 제출 여부 상태 변경 도메인 로직 구현 * refactor: GameBoardMemberCommandServiceTest의 테스트들을 메서드별로 Nested 구조로 변환 * feat: REST API 구현 * feat: 참여 상태 변경과 마찬가지로 락 획득으로 구현 --- .../domain/game/domain/GameBoardMember.java | 4 + .../controller/GameBoardMemberController.java | 16 + .../controller/api/GameBoardMemberApi.java | 17 + .../presentation/dto/GameBoardMemberDTO.java | 6 + .../mapper/GameBoardMemberMapper.java | 9 + .../GameBoardMemberCommandService.java | 17 + ...ardMemberShuttlecockSubmissionCommand.java | 8 + .../game/domain/GameBoardMemberTest.java | 12 + ...kSubmissionConcurrencyIntegrationTest.java | 186 ++++++++++ ...rShuttlecockSubmissionIntegrationTest.java | 224 ++++++++++++ .../GameBoardMemberCommandServiceTest.java | 338 +++++++++++------- 11 files changed, 708 insertions(+), 129 deletions(-) create mode 100644 src/main/java/umc/cockple/demo/domain/game/service/command/model/GameBoardMemberShuttlecockSubmissionCommand.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest.java create mode 100644 src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionIntegrationTest.java diff --git a/src/main/java/umc/cockple/demo/domain/game/domain/GameBoardMember.java b/src/main/java/umc/cockple/demo/domain/game/domain/GameBoardMember.java index 5b4fb334e..772ebfbf1 100644 --- a/src/main/java/umc/cockple/demo/domain/game/domain/GameBoardMember.java +++ b/src/main/java/umc/cockple/demo/domain/game/domain/GameBoardMember.java @@ -119,6 +119,10 @@ public void changeParticipation(boolean participating) { this.participating = participating; } + public void changeShuttlecockSubmission(boolean shuttlecockSubmitted) { + this.shuttlecockSubmitted = shuttlecockSubmitted; + } + /** * member FK의 ON DELETE SET NULL과 guest FK의 ON DELETE CASCADE 때문에 * MySQL CHECK 제약으로 같은 규칙을 중복 선언할 수 없어 애플리케이션 경계에서 검증한다. diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameBoardMemberController.java b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameBoardMemberController.java index f9d7cea68..ef8cec017 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameBoardMemberController.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/GameBoardMemberController.java @@ -9,6 +9,7 @@ import umc.cockple.demo.domain.game.service.command.GameBoardMemberCommandService; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberCreateCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberParticipationCommand; +import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberShuttlecockSubmissionCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberUpdateCommand; import umc.cockple.demo.domain.game.service.query.GameBoardMemberQueryService; import umc.cockple.demo.domain.game.service.query.model.GameBoardMemberSearchQuery; @@ -41,6 +42,21 @@ public ResponseEntity> changeParticipation( return BaseResponse.of(CommonSuccessCode.OK); } + @Override + public ResponseEntity> changeShuttlecockSubmission( + Long gameBoardId, + Long gameBoardMemberId, + GameBoardMemberDTO.ShuttlecockSubmissionRequest request) { + Long memberId = SecurityUtil.getCurrentMemberId(); + GameBoardMemberShuttlecockSubmissionCommand command = + gameBoardMemberMapper.toShuttlecockSubmissionCommand( + gameBoardId, gameBoardMemberId, request); + + gameBoardMemberCommandService.changeShuttlecockSubmission(memberId, command); + + return BaseResponse.of(CommonSuccessCode.OK); + } + @Override public ResponseEntity> updateMember( Long gameBoardId, diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameBoardMemberApi.java b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameBoardMemberApi.java index 17eb6999e..e9d4954d2 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameBoardMemberApi.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/controller/api/GameBoardMemberApi.java @@ -38,6 +38,23 @@ ResponseEntity> changeParticipation( @Valid @RequestBody GameBoardMemberDTO.ParticipationRequest request ); + @PatchMapping("/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}/shuttlecock-submission") + @Operation(summary = "게임판 명단 셔틀콕 제출 상태 변경", description = """ + 게임 진행자가 명단의 셔틀콕 제출 여부를 변경합니다. + + - `shuttlecockSubmitted`는 필수 Boolean입니다. + - 현재 값과 같은 요청은 성공하는 멱등 동작입니다. + """) + @ApiResponse(responseCode = "200", description = "변경 성공") + @ApiResponse(responseCode = "400", description = "입력값 오류") + @ApiResponse(responseCode = "403", description = "게임판 관리 권한 없음") + @ApiResponse(responseCode = "404", description = "게임판 또는 명단을 찾을 수 없음") + ResponseEntity> changeShuttlecockSubmission( + @PathVariable Long gameBoardId, + @PathVariable Long gameBoardMemberId, + @Valid @RequestBody GameBoardMemberDTO.ShuttlecockSubmissionRequest request + ); + @PatchMapping("/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}") @Operation(summary = "게임판 명단 정보 수정", description = """ 게임 진행자가 명단의 이름, 성별, 급수, 연령대를 수정합니다. diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameBoardMemberDTO.java b/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameBoardMemberDTO.java index 5f9e2f1d7..dcef9daa3 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameBoardMemberDTO.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/dto/GameBoardMemberDTO.java @@ -43,6 +43,12 @@ public record ParticipationRequest( ) { } + public record ShuttlecockSubmissionRequest( + @NotNull(message = "셔틀콕 제출 상태는 필수입니다.") + Boolean shuttlecockSubmitted + ) { + } + public record Response( int totalCount, List gameBoardMembers diff --git a/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameBoardMemberMapper.java b/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameBoardMemberMapper.java index 0374dcd8c..21c4bca2a 100644 --- a/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameBoardMemberMapper.java +++ b/src/main/java/umc/cockple/demo/domain/game/presentation/mapper/GameBoardMemberMapper.java @@ -5,6 +5,7 @@ import umc.cockple.demo.domain.game.presentation.dto.GameBoardMemberDTO; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberCreateCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberParticipationCommand; +import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberShuttlecockSubmissionCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberUpdateCommand; import umc.cockple.demo.domain.game.service.query.model.GameBoardMemberSearchQuery; import umc.cockple.demo.domain.game.service.query.result.GameBoardMemberResult; @@ -57,6 +58,14 @@ public GameBoardMemberParticipationCommand toParticipationCommand( gameBoardId, gameBoardMemberId, request.participating()); } + public GameBoardMemberShuttlecockSubmissionCommand toShuttlecockSubmissionCommand( + Long gameBoardId, + Long gameBoardMemberId, + GameBoardMemberDTO.ShuttlecockSubmissionRequest request) { + return new GameBoardMemberShuttlecockSubmissionCommand( + gameBoardId, gameBoardMemberId, request.shuttlecockSubmitted()); + } + public GameBoardMemberSearchQuery toSearchQuery( List levels, String gender, Boolean shuttlecockSubmitted) { List levelEnums = levels == null diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandService.java b/src/main/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandService.java index 5f6443319..f172937f3 100644 --- a/src/main/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandService.java +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandService.java @@ -13,6 +13,7 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberCreateCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberParticipationCommand; +import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberShuttlecockSubmissionCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberUpdateCommand; import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; @@ -78,4 +79,20 @@ public void changeParticipation(Long memberId, GameBoardMemberParticipationComma gameBoardMember.changeParticipation(command.participating()); eventPublisher.publishEvent(GameBoardMembersChangedEvent.membersAndBoard(command.gameBoardId(), memberId)); } + + public void changeShuttlecockSubmission( + Long memberId, GameBoardMemberShuttlecockSubmissionCommand command) { + gameBoardReader.readForUpdate(command.gameBoardId()); + gameBoardAccessValidator.validateGameHost(command.gameBoardId(), memberId); + GameBoardMember gameBoardMember = gameBoardMemberReader.read( + command.gameBoardId(), command.gameBoardMemberId()); + + if (gameBoardMember.getShuttlecockSubmitted() == command.shuttlecockSubmitted()) { + return; + } + + gameBoardMember.changeShuttlecockSubmission(command.shuttlecockSubmitted()); + eventPublisher.publishEvent( + GameBoardMembersChangedEvent.membersOnly(command.gameBoardId(), memberId)); + } } diff --git a/src/main/java/umc/cockple/demo/domain/game/service/command/model/GameBoardMemberShuttlecockSubmissionCommand.java b/src/main/java/umc/cockple/demo/domain/game/service/command/model/GameBoardMemberShuttlecockSubmissionCommand.java new file mode 100644 index 000000000..0994186c6 --- /dev/null +++ b/src/main/java/umc/cockple/demo/domain/game/service/command/model/GameBoardMemberShuttlecockSubmissionCommand.java @@ -0,0 +1,8 @@ +package umc.cockple.demo.domain.game.service.command.model; + +public record GameBoardMemberShuttlecockSubmissionCommand( + Long gameBoardId, + Long gameBoardMemberId, + boolean shuttlecockSubmitted +) { +} diff --git a/src/test/java/umc/cockple/demo/domain/game/domain/GameBoardMemberTest.java b/src/test/java/umc/cockple/demo/domain/game/domain/GameBoardMemberTest.java index 047f02fed..50f63a042 100644 --- a/src/test/java/umc/cockple/demo/domain/game/domain/GameBoardMemberTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/domain/GameBoardMemberTest.java @@ -90,6 +90,18 @@ void changeParticipation_updatesIdempotently() { assertThat(gameBoardMember.getParticipating()).isFalse(); } + @Test + @DisplayName("셔틀콕 제출 상태를 변경하며 같은 값 요청은 멱등적으로 처리한다") + void changeShuttlecockSubmission_updatesIdempotently() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.TWENTIES); + + gameBoardMember.changeShuttlecockSubmission(true); + gameBoardMember.changeShuttlecockSubmission(true); + + assertThat(gameBoardMember.getShuttlecockSubmitted()).isTrue(); + } + @Test @DisplayName("회원과 게스트 원본을 동시에 연결할 수 없다") void validateSourceReference_rejectsMemberAndGuestTogether() { diff --git a/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest.java new file mode 100644 index 000000000..e9670088c --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest.java @@ -0,0 +1,186 @@ +package umc.cockple.demo.domain.game.integration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.events.GameBoardMembersChangedEvent; +import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; +import umc.cockple.demo.domain.game.service.command.GameBoardMemberCommandService; +import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberShuttlecockSubmissionCommand; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest.EventRecorderConfig.class) +@DisplayName("게임판 명단 셔틀콕 제출 상태 변경 동시성 통합 테스트") +class GameBoardMemberShuttlecockSubmissionConcurrencyIntegrationTest extends IntegrationTestBase { + + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private GameBoardMemberCommandService gameBoardMemberCommandService; + @Autowired private GameBoardMemberRepository gameBoardMemberRepository; + @Autowired private ExerciseRepository exerciseRepository; + @Autowired private PartyRepository partyRepository; + @Autowired private PartyAddrRepository partyAddrRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private EventRecorder eventRecorder; + + private TransactionTemplate transactionTemplate; + private Long gameHostId; + private Long gameBoardId; + private Long gameBoardMemberId; + + @BeforeEach + void setUp() { + transactionTemplate = new TransactionTemplate(transactionManager); + eventRecorder.reset(); + + Member gameHost = memberRepository.save(MemberFixture.createMemberWithName( + "게임 진행자", "진행자", Gender.FEMALE, Level.A, 76001L)); + PartyAddr partyAddr = partyAddrRepository.save( + PartyFixture.createPartyAddr("서울특별시", "강남구")); + Party party = partyRepository.save( + PartyFixture.createParty("셔틀콕 제출 동시성 테스트", gameHost.getId(), partyAddr)); + Exercise exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31))); + GameBoardMember gameBoardMember = gameBoardMemberRepository.save(GameBoardMember.builder() + .gameBoard(exercise.getGameBoard()) + .name("선수") + .gender(Gender.MALE) + .level(Level.D) + .shuttlecockSubmitted(false) + .participating(true) + .gameCount(0) + .build()); + + gameHostId = gameHost.getId(); + gameBoardId = exercise.getGameBoard().getId(); + gameBoardMemberId = gameBoardMember.getId(); + } + + @AfterEach + void tearDown() { + gameBoardMemberRepository.deleteAll(); + exerciseRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("동일한 제출 요청은 앞선 트랜잭션 커밋을 기다린 뒤 변경 이벤트를 한 번만 발행한다") + void concurrentSameValueRequestsPublishOneChangeEvent() throws Exception { + CountDownLatch firstChanged = new CountDownLatch(1); + CountDownLatch allowFirstCommit = new CountDownLatch(1); + CountDownLatch secondAttempted = new CountDownLatch(1); + CountDownLatch secondFinished = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + GameBoardMemberShuttlecockSubmissionCommand command = + new GameBoardMemberShuttlecockSubmissionCommand( + gameBoardId, gameBoardMemberId, true); + + try { + Future firstRequest = executor.submit(() -> + transactionTemplate.executeWithoutResult(status -> { + gameBoardMemberCommandService.changeShuttlecockSubmission(gameHostId, command); + firstChanged.countDown(); + await(allowFirstCommit); + })); + + assertThat(firstChanged.await(5, TimeUnit.SECONDS)).isTrue(); + + Future secondRequest = executor.submit(() -> { + secondAttempted.countDown(); + try { + gameBoardMemberCommandService.changeShuttlecockSubmission(gameHostId, command); + } finally { + secondFinished.countDown(); + } + }); + + assertThat(secondAttempted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(secondFinished.await(300, TimeUnit.MILLISECONDS)).isFalse(); + + allowFirstCommit.countDown(); + firstRequest.get(5, TimeUnit.SECONDS); + secondRequest.get(5, TimeUnit.SECONDS); + } finally { + allowFirstCommit.countDown(); + executor.shutdownNow(); + } + + assertThat(gameBoardMemberRepository.findById(gameBoardMemberId).orElseThrow() + .getShuttlecockSubmitted()).isTrue(); + assertThat(eventRecorder.membersOnlyEventCount()).isOne(); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("동시성 테스트 대기 시간이 초과되었습니다."); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("동시성 테스트 대기가 중단되었습니다.", e); + } + } + + @TestConfiguration(proxyBeanMethods = false) + static class EventRecorderConfig { + + @Bean + EventRecorder eventRecorder() { + return new EventRecorder(); + } + } + + static class EventRecorder { + + private final AtomicInteger membersOnlyEventCount = new AtomicInteger(); + + @EventListener + public void record(GameBoardMembersChangedEvent event) { + if (!event.includeBoardSnapshot()) { + membersOnlyEventCount.incrementAndGet(); + } + } + + int membersOnlyEventCount() { + return membersOnlyEventCount.get(); + } + + void reset() { + membersOnlyEventCount.set(0); + } + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionIntegrationTest.java b/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionIntegrationTest.java new file mode 100644 index 000000000..1fd818fa7 --- /dev/null +++ b/src/test/java/umc/cockple/demo/domain/game/integration/GameBoardMemberShuttlecockSubmissionIntegrationTest.java @@ -0,0 +1,224 @@ +package umc.cockple.demo.domain.game.integration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import umc.cockple.demo.domain.exercise.domain.Exercise; +import umc.cockple.demo.domain.exercise.repository.ExerciseRepository; +import umc.cockple.demo.domain.game.domain.GameBoardMember; +import umc.cockple.demo.domain.game.exception.GameErrorCode; +import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; +import umc.cockple.demo.domain.member.domain.Member; +import umc.cockple.demo.domain.member.repository.MemberRepository; +import umc.cockple.demo.domain.party.domain.Party; +import umc.cockple.demo.domain.party.domain.PartyAddr; +import umc.cockple.demo.domain.party.repository.PartyAddrRepository; +import umc.cockple.demo.domain.party.repository.PartyRepository; +import umc.cockple.demo.global.enums.Gender; +import umc.cockple.demo.global.enums.Level; +import umc.cockple.demo.support.IntegrationTestBase; +import umc.cockple.demo.support.SecurityContextHelper; +import umc.cockple.demo.support.fixture.ExerciseFixture; +import umc.cockple.demo.support.fixture.MemberFixture; +import umc.cockple.demo.support.fixture.PartyFixture; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("게임판 명단 셔틀콕 제출 상태 변경 통합 테스트") +class GameBoardMemberShuttlecockSubmissionIntegrationTest extends IntegrationTestBase { + + @Autowired private MockMvc mockMvc; + @Autowired private MemberRepository memberRepository; + @Autowired private PartyAddrRepository partyAddrRepository; + @Autowired private PartyRepository partyRepository; + @Autowired private ExerciseRepository exerciseRepository; + @Autowired private GameBoardMemberRepository gameBoardMemberRepository; + + private Member gameHost; + private Member otherMember; + private Party party; + private Exercise exercise; + private GameBoardMember gameBoardMember; + + @BeforeEach + void setUp() { + gameHost = memberRepository.save(MemberFixture.createMemberWithName( + "게임 진행자", "진행자", Gender.FEMALE, Level.A, 75001L)); + otherMember = memberRepository.save(MemberFixture.createMemberWithName( + "일반 회원", "일반", Gender.MALE, Level.B, 75002L)); + + PartyAddr partyAddr = partyAddrRepository.save( + PartyFixture.createPartyAddr("서울특별시", "강남구")); + party = partyRepository.save( + PartyFixture.createParty("셔틀콕 제출 테스트 모임", gameHost.getId(), partyAddr)); + exercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 31))); + + gameBoardMember = gameBoardMemberRepository.save(GameBoardMember.builder() + .gameBoard(exercise.getGameBoard()) + .name("선수") + .gender(Gender.MALE) + .level(Level.C) + .shuttlecockSubmitted(false) + .participating(true) + .gameCount(0) + .build()); + } + + @AfterEach + void tearDown() { + SecurityContextHelper.clearAuthentication(); + gameBoardMemberRepository.deleteAll(); + exerciseRepository.deleteAll(); + partyRepository.deleteAll(); + partyAddrRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("셔틀콕 제출 상태를 양방향으로 변경하고 명단 조회와 필터에 반영한다") + void changesBothDirectionsAndUpdatesMemberQuery() throws Exception { + authenticate(gameHost); + + changeSubmission(gameBoardMember, true) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + + assertThat(readGameBoardMember().getShuttlecockSubmitted()).isTrue(); + mockMvc.perform(get("/api/game-boards/{gameBoardId}/gameBoardMembers", + exercise.getGameBoard().getId()) + .param("shuttlecockSubmitted", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.gameBoardMembers.length()").value(1)) + .andExpect(jsonPath("$.data.gameBoardMembers[0].gameBoardMemberId") + .value(gameBoardMember.getId())) + .andExpect(jsonPath("$.data.gameBoardMembers[0].shuttlecockSubmitted").value(true)); + + changeSubmission(gameBoardMember, false) + .andExpect(status().isOk()); + + assertThat(readGameBoardMember().getShuttlecockSubmitted()).isFalse(); + } + + @Test + @DisplayName("현재 값과 같은 제출 상태 요청은 성공한다") + void sameValueSucceeds() throws Exception { + authenticate(gameHost); + + changeSubmission(gameBoardMember, false) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + + assertThat(readGameBoardMember().getShuttlecockSubmitted()).isFalse(); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("다른 게임판 소속 명단의 제출 상태는 변경할 수 없다") + void rejectsMemberFromOtherGameBoard() throws Exception { + Exercise otherExercise = exerciseRepository.save( + ExerciseFixture.createExerciseWithAddr(party, LocalDate.of(2099, 12, 30))); + GameBoardMember otherBoardMember = gameBoardMemberRepository.save(GameBoardMember.builder() + .gameBoard(otherExercise.getGameBoard()) + .name("다른 선수") + .gender(Gender.FEMALE) + .level(Level.D) + .shuttlecockSubmitted(false) + .participating(true) + .gameCount(0) + .build()); + authenticate(gameHost); + + mockMvc.perform(patch( + "/api/game-boards/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}/shuttlecock-submission", + exercise.getGameBoard().getId(), otherBoardMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"shuttlecockSubmitted\":true}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code") + .value(GameErrorCode.GAME_BOARD_MEMBER_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("게임 진행자가 아니면 제출 상태를 변경할 수 없다") + void deniesNonGameHost() throws Exception { + authenticate(otherMember); + + changeSubmission(gameBoardMember, true) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(GameErrorCode.GAME_BOARD_ACCESS_DENIED.getCode())); + + assertThat(readGameBoardMember().getShuttlecockSubmitted()).isFalse(); + } + + @Test + @DisplayName("게임판을 찾을 수 없으면 404를 반환한다") + void rejectsUnknownGameBoard() throws Exception { + authenticate(gameHost); + + mockMvc.perform(patch( + "/api/game-boards/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}/shuttlecock-submission", + Long.MAX_VALUE, gameBoardMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"shuttlecockSubmitted\":true}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(GameErrorCode.GAME_BOARD_NOT_FOUND.getCode())); + } + + @Test + @DisplayName("제출 상태가 없거나 null이면 400을 반환한다") + void requiresShuttlecockSubmittedValue() throws Exception { + authenticate(gameHost); + + for (String request : List.of("{}", "{\"shuttlecockSubmitted\":null}")) { + mockMvc.perform(patch( + "/api/game-boards/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}/shuttlecock-submission", + exercise.getGameBoard().getId(), gameBoardMember.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + } + + assertThat(readGameBoardMember().getShuttlecockSubmitted()).isFalse(); + } + } + + private ResultActions changeSubmission( + GameBoardMember member, boolean shuttlecockSubmitted) throws Exception { + return mockMvc.perform(patch( + "/api/game-boards/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}/shuttlecock-submission", + exercise.getGameBoard().getId(), member.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"shuttlecockSubmitted\":" + shuttlecockSubmitted + "}")); + } + + private void authenticate(Member member) { + SecurityContextHelper.setAuthentication(member.getId(), member.getNickname()); + } + + private GameBoardMember readGameBoardMember() { + return gameBoardMemberRepository.findById(gameBoardMember.getId()).orElseThrow(); + } +} diff --git a/src/test/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandServiceTest.java b/src/test/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandServiceTest.java index c7945424d..c906a7106 100644 --- a/src/test/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandServiceTest.java +++ b/src/test/java/umc/cockple/demo/domain/game/service/command/GameBoardMemberCommandServiceTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -18,6 +19,7 @@ import umc.cockple.demo.domain.game.repository.GameBoardMemberRepository; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberCreateCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberParticipationCommand; +import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberShuttlecockSubmissionCommand; import umc.cockple.demo.domain.game.service.command.model.GameBoardMemberUpdateCommand; import umc.cockple.demo.domain.game.service.support.reader.GameBoardMemberReader; import umc.cockple.demo.domain.game.service.support.reader.GameBoardReader; @@ -62,143 +64,221 @@ void setUp() { GAME_BOARD_ID, "김선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); } - @Test - @DisplayName("게임 진행자가 수동 명단을 기본값과 함께 생성하고 변경 이벤트를 발행한다") - void createMember_createsManualMemberAndPublishesEvent() { - GameBoardMember savedMember = GameBoardMember.builder().id(GAME_BOARD_MEMBER_ID).build(); - given(gameBoardReader.read(GAME_BOARD_ID)).willReturn(gameBoard); - given(gameBoardMemberRepository.save(any(GameBoardMember.class))).willReturn(savedMember); - ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(GameBoardMember.class); - - Long result = gameBoardMemberCommandService.createMember(MEMBER_ID, command); - - assertThat(result).isEqualTo(GAME_BOARD_MEMBER_ID); - then(gameBoardAccessValidator).should().validateGameHost(GAME_BOARD_ID, MEMBER_ID); - then(gameBoardMemberRepository).should().save(memberCaptor.capture()); - GameBoardMember createdMember = memberCaptor.getValue(); - assertThat(createdMember.getGameBoard()).isSameAs(gameBoard); - assertThat(createdMember.getMember()).isNull(); - assertThat(createdMember.getGuest()).isNull(); - assertThat(createdMember.getName()).isEqualTo("김선수"); - assertThat(createdMember.getGender()).isEqualTo(Gender.MALE); - assertThat(createdMember.getLevel()).isEqualTo(Level.D); - assertThat(createdMember.getAgeGroup()).isEqualTo(AgeGroup.THIRTIES); - assertThat(createdMember.getShuttlecockSubmitted()).isFalse(); - assertThat(createdMember.getParticipating()).isTrue(); - assertThat(createdMember.getGameCount()).isZero(); - then(eventPublisher).should() - .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); - } + @Nested + @DisplayName("createMember") + class CreateMember { - @Test - @DisplayName("게임 진행자가 아니면 명단을 생성하거나 이벤트를 발행하지 않는다") - void createMember_deniesNonGameHost() { - willThrow(new GameException(GameErrorCode.GAME_BOARD_ACCESS_DENIED)) - .given(gameBoardAccessValidator).validateGameHost(GAME_BOARD_ID, MEMBER_ID); - - assertThatThrownBy(() -> gameBoardMemberCommandService.createMember(MEMBER_ID, command)) - .isInstanceOfSatisfying(GameException.class, exception -> - assertThat(exception.getCode()).isEqualTo(GameErrorCode.GAME_BOARD_ACCESS_DENIED)); - then(gameBoardReader).should(never()).read(any()); - then(gameBoardMemberRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publishEvent(any()); - } + @Test + @DisplayName("게임 진행자가 수동 명단을 기본값과 함께 생성하고 변경 이벤트를 발행한다") + void createsManualMemberAndPublishesEvent() { + GameBoardMember savedMember = GameBoardMember.builder().id(GAME_BOARD_MEMBER_ID).build(); + given(gameBoardReader.read(GAME_BOARD_ID)).willReturn(gameBoard); + given(gameBoardMemberRepository.save(any(GameBoardMember.class))).willReturn(savedMember); + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(GameBoardMember.class); - @Test - @DisplayName("게임 진행자가 명단 정보를 수정하고 연령대를 제거한 뒤 변경 이벤트를 발행한다") - void updateMember_updatesInfoAndPublishesEvent() { - GameBoardMember gameBoardMember = GameBoardMember.create( - "수정 전", Gender.MALE, Level.D, AgeGroup.THIRTIES); - GameBoardMemberUpdateCommand updateCommand = new GameBoardMemberUpdateCommand( - GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, "수정 후", Gender.FEMALE, Level.A, null); - given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) - .willReturn(gameBoardMember); - - gameBoardMemberCommandService.updateMember(MEMBER_ID, updateCommand); - - then(gameBoardAccessValidator).should().validateGameHost(GAME_BOARD_ID, MEMBER_ID); - assertThat(gameBoardMember.getName()).isEqualTo("수정 후"); - assertThat(gameBoardMember.getGender()).isEqualTo(Gender.FEMALE); - assertThat(gameBoardMember.getLevel()).isEqualTo(Level.A); - assertThat(gameBoardMember.getAgeGroup()).isNull(); - then(eventPublisher).should() - .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); - } + Long result = gameBoardMemberCommandService.createMember(MEMBER_ID, command); + + assertThat(result).isEqualTo(GAME_BOARD_MEMBER_ID); + then(gameBoardAccessValidator).should().validateGameHost(GAME_BOARD_ID, MEMBER_ID); + then(gameBoardMemberRepository).should().save(memberCaptor.capture()); + GameBoardMember createdMember = memberCaptor.getValue(); + assertThat(createdMember.getGameBoard()).isSameAs(gameBoard); + assertThat(createdMember.getMember()).isNull(); + assertThat(createdMember.getGuest()).isNull(); + assertThat(createdMember.getName()).isEqualTo("김선수"); + assertThat(createdMember.getGender()).isEqualTo(Gender.MALE); + assertThat(createdMember.getLevel()).isEqualTo(Level.D); + assertThat(createdMember.getAgeGroup()).isEqualTo(AgeGroup.THIRTIES); + assertThat(createdMember.getShuttlecockSubmitted()).isFalse(); + assertThat(createdMember.getParticipating()).isTrue(); + assertThat(createdMember.getGameCount()).isZero(); + then(eventPublisher).should() + .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); + } - @Test - @DisplayName("활성 게임에 포함되지 않은 선수를 참여 해제하고 이벤트를 발행한다") - void changeParticipation_deactivatesIdleMemberAndPublishesEvent() { - GameBoardMember gameBoardMember = GameBoardMember.create( - "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); - GameBoardMemberParticipationCommand participationCommand = - new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); - given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) - .willReturn(gameBoardMember); - given(gameReader.existsByGameBoardMemberAndStatuses( - eq(GAME_BOARD_MEMBER_ID), any())) - .willReturn(false); - - gameBoardMemberCommandService.changeParticipation(MEMBER_ID, participationCommand); - - assertThat(gameBoardMember.getParticipating()).isFalse(); - then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); - then(eventPublisher).should() - .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); + @Test + @DisplayName("게임 진행자가 아니면 명단을 생성하거나 이벤트를 발행하지 않는다") + void deniesNonGameHost() { + willThrow(new GameException(GameErrorCode.GAME_BOARD_ACCESS_DENIED)) + .given(gameBoardAccessValidator).validateGameHost(GAME_BOARD_ID, MEMBER_ID); + + assertThatThrownBy(() -> gameBoardMemberCommandService.createMember(MEMBER_ID, command)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GameErrorCode.GAME_BOARD_ACCESS_DENIED)); + then(gameBoardReader).should(never()).read(any()); + then(gameBoardMemberRepository).should(never()).save(any()); + then(eventPublisher).should(never()).publishEvent(any()); + } } - @Test - @DisplayName("게임판 락 획득 후 게임 진행자가 아니면 명단을 조회하지 않는다") - void changeParticipation_deniesNonGameHostAfterLock() { - GameBoardMemberParticipationCommand participationCommand = - new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); - willThrow(new GameException(GameErrorCode.GAME_BOARD_ACCESS_DENIED)) - .given(gameBoardAccessValidator).validateGameHost(GAME_BOARD_ID, MEMBER_ID); - - assertThatThrownBy(() -> gameBoardMemberCommandService.changeParticipation( - MEMBER_ID, participationCommand)) - .isInstanceOfSatisfying(GameException.class, exception -> - assertThat(exception.getCode()).isEqualTo(GameErrorCode.GAME_BOARD_ACCESS_DENIED)); - - then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); - then(gameBoardMemberReader).shouldHaveNoInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); + @Nested + @DisplayName("updateMember") + class UpdateMember { + + @Test + @DisplayName("게임 진행자가 명단 정보를 수정하고 연령대를 제거한 뒤 변경 이벤트를 발행한다") + void updatesInfoAndPublishesEvent() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "수정 전", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberUpdateCommand updateCommand = new GameBoardMemberUpdateCommand( + GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, "수정 후", Gender.FEMALE, Level.A, null); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + + gameBoardMemberCommandService.updateMember(MEMBER_ID, updateCommand); + + then(gameBoardAccessValidator).should().validateGameHost(GAME_BOARD_ID, MEMBER_ID); + assertThat(gameBoardMember.getName()).isEqualTo("수정 후"); + assertThat(gameBoardMember.getGender()).isEqualTo(Gender.FEMALE); + assertThat(gameBoardMember.getLevel()).isEqualTo(Level.A); + assertThat(gameBoardMember.getAgeGroup()).isNull(); + then(eventPublisher).should() + .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); + } } - @Test - @DisplayName("활성 게임 선수는 참여 해제할 수 없다") - void changeParticipation_rejectsActiveMemberDeactivation() { - GameBoardMember gameBoardMember = GameBoardMember.create( - "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); - GameBoardMemberParticipationCommand participationCommand = - new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); - given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) - .willReturn(gameBoardMember); - given(gameReader.existsByGameBoardMemberAndStatuses( - eq(GAME_BOARD_MEMBER_ID), any())) - .willReturn(true); - - assertThatThrownBy(() -> gameBoardMemberCommandService.changeParticipation(MEMBER_ID, participationCommand)) - .isInstanceOfSatisfying(GameException.class, exception -> - assertThat(exception.getCode()) - .isEqualTo(GameErrorCode.ACTIVE_GAME_MEMBER_CANNOT_BE_INACTIVE)); - assertThat(gameBoardMember.getParticipating()).isTrue(); - then(eventPublisher).should(never()).publishEvent(any()); + @Nested + @DisplayName("changeParticipation") + class ChangeParticipation { + + @Test + @DisplayName("활성 게임에 포함되지 않은 선수를 참여 해제하고 이벤트를 발행한다") + void deactivatesIdleMemberAndPublishesEvent() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberParticipationCommand participationCommand = + new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + given(gameReader.existsByGameBoardMemberAndStatuses( + eq(GAME_BOARD_MEMBER_ID), any())) + .willReturn(false); + + gameBoardMemberCommandService.changeParticipation(MEMBER_ID, participationCommand); + + assertThat(gameBoardMember.getParticipating()).isFalse(); + then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); + then(eventPublisher).should() + .publishEvent(GameBoardMembersChangedEvent.membersAndBoard(GAME_BOARD_ID, MEMBER_ID)); + } + + @Test + @DisplayName("게임판 락 획득 후 게임 진행자가 아니면 명단을 조회하지 않는다") + void deniesNonGameHostAfterLock() { + GameBoardMemberParticipationCommand participationCommand = + new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); + willThrow(new GameException(GameErrorCode.GAME_BOARD_ACCESS_DENIED)) + .given(gameBoardAccessValidator).validateGameHost(GAME_BOARD_ID, MEMBER_ID); + + assertThatThrownBy(() -> gameBoardMemberCommandService.changeParticipation( + MEMBER_ID, participationCommand)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GameErrorCode.GAME_BOARD_ACCESS_DENIED)); + + then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); + then(gameBoardMemberReader).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("활성 게임 선수는 참여 해제할 수 없다") + void rejectsActiveMemberDeactivation() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberParticipationCommand participationCommand = + new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + given(gameReader.existsByGameBoardMemberAndStatuses( + eq(GAME_BOARD_MEMBER_ID), any())) + .willReturn(true); + + assertThatThrownBy(() -> gameBoardMemberCommandService.changeParticipation( + MEMBER_ID, participationCommand)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()) + .isEqualTo(GameErrorCode.ACTIVE_GAME_MEMBER_CANNOT_BE_INACTIVE)); + assertThat(gameBoardMember.getParticipating()).isTrue(); + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("현재 값과 같은 참여 상태 요청은 조회와 이벤트 없이 성공한다") + void sameValueIsIdempotent() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberParticipationCommand participationCommand = + new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, true); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + + gameBoardMemberCommandService.changeParticipation(MEMBER_ID, participationCommand); + + assertThat(gameBoardMember.getParticipating()).isTrue(); + then(gameReader).shouldHaveNoInteractions(); + then(eventPublisher).should(never()).publishEvent(any()); + } } - @Test - @DisplayName("현재 값과 같은 참여 상태 요청은 조회와 이벤트 없이 성공한다") - void changeParticipation_sameValueIsIdempotent() { - GameBoardMember gameBoardMember = GameBoardMember.create( - "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); - GameBoardMemberParticipationCommand participationCommand = - new GameBoardMemberParticipationCommand(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, true); - given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) - .willReturn(gameBoardMember); - - gameBoardMemberCommandService.changeParticipation(MEMBER_ID, participationCommand); - - assertThat(gameBoardMember.getParticipating()).isTrue(); - then(gameReader).shouldHaveNoInteractions(); - then(eventPublisher).should(never()).publishEvent(any()); + @Nested + @DisplayName("changeShuttlecockSubmission") + class ChangeShuttlecockSubmission { + + @Test + @DisplayName("게임 진행자가 셔틀콕 제출 상태를 변경하고 명단 변경 이벤트를 발행한다") + void updatesStateAndPublishesMembersEvent() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberShuttlecockSubmissionCommand submissionCommand = + new GameBoardMemberShuttlecockSubmissionCommand( + GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, true); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + + gameBoardMemberCommandService.changeShuttlecockSubmission(MEMBER_ID, submissionCommand); + + then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); + then(gameBoardAccessValidator).should().validateGameHost(GAME_BOARD_ID, MEMBER_ID); + assertThat(gameBoardMember.getShuttlecockSubmitted()).isTrue(); + then(eventPublisher).should() + .publishEvent(GameBoardMembersChangedEvent.membersOnly(GAME_BOARD_ID, MEMBER_ID)); + } + + @Test + @DisplayName("현재 값과 같은 셔틀콕 제출 상태 요청은 이벤트 없이 성공한다") + void sameValueIsIdempotent() { + GameBoardMember gameBoardMember = GameBoardMember.create( + "선수", Gender.MALE, Level.D, AgeGroup.THIRTIES); + GameBoardMemberShuttlecockSubmissionCommand submissionCommand = + new GameBoardMemberShuttlecockSubmissionCommand( + GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, false); + given(gameBoardMemberReader.read(GAME_BOARD_ID, GAME_BOARD_MEMBER_ID)) + .willReturn(gameBoardMember); + + gameBoardMemberCommandService.changeShuttlecockSubmission(MEMBER_ID, submissionCommand); + + then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); + assertThat(gameBoardMember.getShuttlecockSubmitted()).isFalse(); + then(eventPublisher).should(never()).publishEvent(any()); + } + + @Test + @DisplayName("게임판 락 획득 후 게임 진행자가 아니면 명단을 조회하지 않는다") + void deniesNonGameHostAfterLock() { + GameBoardMemberShuttlecockSubmissionCommand submissionCommand = + new GameBoardMemberShuttlecockSubmissionCommand( + GAME_BOARD_ID, GAME_BOARD_MEMBER_ID, true); + willThrow(new GameException(GameErrorCode.GAME_BOARD_ACCESS_DENIED)) + .given(gameBoardAccessValidator).validateGameHost(GAME_BOARD_ID, MEMBER_ID); + + assertThatThrownBy(() -> gameBoardMemberCommandService.changeShuttlecockSubmission( + MEMBER_ID, submissionCommand)) + .isInstanceOfSatisfying(GameException.class, exception -> + assertThat(exception.getCode()).isEqualTo(GameErrorCode.GAME_BOARD_ACCESS_DENIED)); + + then(gameBoardReader).should().readForUpdate(GAME_BOARD_ID); + then(gameBoardMemberReader).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } } }