Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 제약으로 같은 규칙을 중복 선언할 수 없어 애플리케이션 경계에서 검증한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,21 @@ public ResponseEntity<BaseResponse<Void>> changeParticipation(
return BaseResponse.of(CommonSuccessCode.OK);
}

@Override
public ResponseEntity<BaseResponse<Void>> 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<BaseResponse<Void>> updateMember(
Long gameBoardId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ ResponseEntity<BaseResponse<Void>> 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<BaseResponse<Void>> changeShuttlecockSubmission(
@PathVariable Long gameBoardId,
@PathVariable Long gameBoardMemberId,
@Valid @RequestBody GameBoardMemberDTO.ShuttlecockSubmissionRequest request
);

@PatchMapping("/{gameBoardId}/gameBoardMembers/{gameBoardMemberId}")
@Operation(summary = "게임판 명단 정보 수정", description = """
게임 진행자가 명단의 이름, 성별, 급수, 연령대를 수정합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ public record ParticipationRequest(
) {
}

public record ShuttlecockSubmissionRequest(
@NotNull(message = "셔틀콕 제출 상태는 필수입니다.")
Boolean shuttlecockSubmitted
) {
}

public record Response(
int totalCount,
List<MemberInfo> gameBoardMembers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> levels, String gender, Boolean shuttlecockSubmitted) {
List<Level> levelEnums = levels == null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package umc.cockple.demo.domain.game.service.command.model;

public record GameBoardMemberShuttlecockSubmissionCommand(
Long gameBoardId,
Long gameBoardMemberId,
boolean shuttlecockSubmitted
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading