From 3b39adeb9558cb2121a6a7919281c3b8f87a2a8a Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:42:58 +0900 Subject: [PATCH 01/13] =?UTF-8?q?fix(persistence):=20SQLState=EC=99=80=20c?= =?UTF-8?q?onstraint=20=EA=B8=B0=EB=B0=98=20=ED=84=B4=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=20=EC=8B=9D=EB=B3=84=20=EB=B0=8F=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GamePersistenceService.java | 104 ++++++------------ 1 file changed, 34 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/uctale/uctale/application/game/GamePersistenceService.java b/src/main/java/com/uctale/uctale/application/game/GamePersistenceService.java index 0feb30ee..4d7127fd 100644 --- a/src/main/java/com/uctale/uctale/application/game/GamePersistenceService.java +++ b/src/main/java/com/uctale/uctale/application/game/GamePersistenceService.java @@ -17,7 +17,7 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import tools.jackson.databind.ObjectMapper; +import org.postgresql.util.PSQLException; @Service public class GamePersistenceService { @@ -45,70 +45,19 @@ public GamePersistenceService(GameSessionRepository gameSessionRepository, GameL GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec, RelationshipAuditCodec relationshipAuditCodec, StoryMemoryAuditCodec storyMemoryAuditCodec) { - this.gameSessionRepository = gameSessionRepository; - this.gameLogRepository = gameLogRepository; - this.gameStateSnapshotRepository = gameStateSnapshotRepository; - this.imageAssetRepository = imageAssetRepository; - this.gameMutationRequestRepository = gameMutationRequestRepository; - this.gameStateCodec = gameStateCodec; - this.gameStateRecovery = gameStateRecovery; - this.inventoryAuditCodec = inventoryAuditCodec; - this.vitalsAuditCodec = vitalsAuditCodec; - this.combatAuditCodec = combatAuditCodec; - this.questAuditCodec = questAuditCodec; - this.relationshipAuditCodec = relationshipAuditCodec; - this.storyMemoryAuditCodec = storyMemoryAuditCodec; - } - - public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository, - GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository, - GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec, - GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, - CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec) { - this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository, - gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec, - combatAuditCodec, questAuditCodec, new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - - public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository, - GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository, - GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec, - GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, - CombatAuditCodec combatAuditCodec) { - this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository, - gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec, - combatAuditCodec, new QuestAuditCodec(new ObjectMapper()), new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - - public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository, - GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository, - GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec, - GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec) { - this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository, - gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, vitalsAuditCodec, - new CombatAuditCodec(new ObjectMapper()), new QuestAuditCodec(new ObjectMapper()), - new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - - public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository, - GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository, - GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec, - GameStateRecovery gameStateRecovery, InventoryAuditCodec inventoryAuditCodec) { - this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository, - gameMutationRequestRepository, gameStateCodec, gameStateRecovery, inventoryAuditCodec, - new VitalsAuditCodec(new ObjectMapper()), new CombatAuditCodec(new ObjectMapper()), - new QuestAuditCodec(new ObjectMapper()), new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - - public GamePersistenceService(GameSessionRepository gameSessionRepository, GameLogRepository gameLogRepository, - GameStateSnapshotRepository gameStateSnapshotRepository, ImageAssetRepository imageAssetRepository, - GameMutationRequestRepository gameMutationRequestRepository, GameStateCodec gameStateCodec, - GameStateRecovery gameStateRecovery) { - this(gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, imageAssetRepository, - gameMutationRequestRepository, gameStateCodec, gameStateRecovery, - new InventoryAuditCodec(new ObjectMapper()), new VitalsAuditCodec(new ObjectMapper()), - new CombatAuditCodec(new ObjectMapper()), new QuestAuditCodec(new ObjectMapper()), - new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); + this.gameSessionRepository = java.util.Objects.requireNonNull(gameSessionRepository, "gameSessionRepository"); + this.gameLogRepository = java.util.Objects.requireNonNull(gameLogRepository, "gameLogRepository"); + this.gameStateSnapshotRepository = java.util.Objects.requireNonNull(gameStateSnapshotRepository, "gameStateSnapshotRepository"); + this.imageAssetRepository = java.util.Objects.requireNonNull(imageAssetRepository, "imageAssetRepository"); + this.gameMutationRequestRepository = java.util.Objects.requireNonNull(gameMutationRequestRepository, "gameMutationRequestRepository"); + this.gameStateCodec = java.util.Objects.requireNonNull(gameStateCodec, "gameStateCodec"); + this.gameStateRecovery = java.util.Objects.requireNonNull(gameStateRecovery, "gameStateRecovery"); + this.inventoryAuditCodec = java.util.Objects.requireNonNull(inventoryAuditCodec, "inventoryAuditCodec"); + this.vitalsAuditCodec = java.util.Objects.requireNonNull(vitalsAuditCodec, "vitalsAuditCodec"); + this.combatAuditCodec = java.util.Objects.requireNonNull(combatAuditCodec, "combatAuditCodec"); + this.questAuditCodec = java.util.Objects.requireNonNull(questAuditCodec, "questAuditCodec"); + this.relationshipAuditCodec = java.util.Objects.requireNonNull(relationshipAuditCodec, "relationshipAuditCodec"); + this.storyMemoryAuditCodec = java.util.Objects.requireNonNull(storyMemoryAuditCodec, "storyMemoryAuditCodec"); } @Transactional @@ -245,11 +194,26 @@ private GameSession findOwnedSession(String ownerKey, Long sessionId) { } private boolean isTurnUniqueConstraintViolation(DataIntegrityViolationException exception) { - Throwable cause = exception.getMostSpecificCause(); - String message = cause == null ? exception.getMessage() : cause.getMessage(); - if (message == null) return false; - String normalized = message.toLowerCase(); - return normalized.contains(GAME_LOG_TURN_UNIQUE_CONSTRAINT) || normalized.contains(IMAGE_ASSET_TURN_UNIQUE_CONSTRAINT); + for (Throwable cause = exception; cause != null; cause = cause.getCause()) { + if (cause instanceof PSQLException postgres + && "23505".equals(postgres.getSQLState()) + && postgres.getServerErrorMessage() != null + && isTurnConstraint(postgres.getServerErrorMessage().getConstraint())) { + return true; + } + if (cause instanceof org.hibernate.exception.ConstraintViolationException hibernate + && hibernate.getSQLException() != null + && "23505".equals(hibernate.getSQLException().getSQLState()) + && isTurnConstraint(hibernate.getConstraintName())) { + return true; + } + } + return false; + } + + private boolean isTurnConstraint(String constraintName) { + return GAME_LOG_TURN_UNIQUE_CONSTRAINT.equals(constraintName) + || IMAGE_ASSET_TURN_UNIQUE_CONSTRAINT.equals(constraintName); } private GameState loadOrRecoverState(GameSession session) { From 5691ce6283217dbdc9bee41a5bbe72f94dfab05e Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:44:02 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor(game):=20GameStateRecovery=20?= =?UTF-8?q?=EC=83=9D=EB=9E=B5=20=EC=9D=98=EC=A1=B4=EC=84=B1=EA=B3=BC=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20Mapper=20=EC=83=9D=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/game/GameStateRecovery.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java b/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java index 4667a789..316bb325 100644 --- a/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java +++ b/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java @@ -11,7 +11,6 @@ import com.uctale.uctale.domain.game.VitalsRules; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import tools.jackson.databind.ObjectMapper; import java.util.List; @@ -26,29 +25,6 @@ public class GameStateRecovery { private final StoryMemoryAuditCodec storyMemoryAuditCodec; @Autowired - public GameStateRecovery(InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, - CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec, RelationshipAuditCodec relationshipAuditCodec, - StoryMemoryAuditCodec storyMemoryAuditCodec) { - this.inventoryAuditCodec = inventoryAuditCodec; - this.vitalsAuditCodec = vitalsAuditCodec; - this.combatAuditCodec = combatAuditCodec; - this.questAuditCodec = questAuditCodec; - this.relationshipAuditCodec = relationshipAuditCodec; - this.storyMemoryAuditCodec = storyMemoryAuditCodec; - } - - public GameStateRecovery(InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, - CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec) { - this(inventoryAuditCodec, vitalsAuditCodec, combatAuditCodec, questAuditCodec, - new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - - public GameStateRecovery(InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, - CombatAuditCodec combatAuditCodec) { - this(inventoryAuditCodec, vitalsAuditCodec, combatAuditCodec, new QuestAuditCodec(new ObjectMapper()), - new RelationshipAuditCodec(new ObjectMapper()), new StoryMemoryAuditCodec(new ObjectMapper())); - } - public GameState recover(GameSession session, List logs) { if (logs.isEmpty()) throw new IllegalStateException("GameState를 복구할 게임 로그가 없습니다."); GameLog opening = logs.getFirst(); From 17c964fa0842e90f5f24ae697fda496091519cab Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:44:25 +0900 Subject: [PATCH 03/13] =?UTF-8?q?test(persistence):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=ED=99=94=EB=90=9C=20unique=20=EC=9C=84=EB=B0=98=EA=B3=BC=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GamePersistenceIntegrityTest.java | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java b/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java index 1c45377f..2d2d680b 100644 --- a/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java +++ b/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java @@ -13,9 +13,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import org.postgresql.util.PSQLException; +import org.postgresql.util.ServerErrorMessage; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class GamePersistenceIntegrityTest { @@ -30,6 +33,11 @@ class GamePersistenceIntegrityTest { @Mock private GameStateCodec gameStateCodec; @Mock private GameStateRecovery gameStateRecovery; @Mock private InventoryAuditCodec inventoryAuditCodec; + @Mock private VitalsAuditCodec vitalsAuditCodec; + @Mock private CombatAuditCodec combatAuditCodec; + @Mock private QuestAuditCodec questAuditCodec; + @Mock private RelationshipAuditCodec relationshipAuditCodec; + @Mock private StoryMemoryAuditCodec storyMemoryAuditCodec; private GamePersistenceService persistenceService; private GameTurnCommit commit; @@ -44,7 +52,8 @@ void setUp() { gameMutationRequestRepository, gameStateCodec, gameStateRecovery, - inventoryAuditCodec + inventoryAuditCodec, vitalsAuditCodec, combatAuditCodec, questAuditCodec, + relationshipAuditCodec, storyMemoryAuditCodec ); GameState previousState = GameState.initial("세계관", "캐릭터", "첫 이야기"); commit = new GameTurnCommit( @@ -74,7 +83,7 @@ void saveNextTurn_DoesNotMapEveryIntegrityFailureToTurnConflict() { @DisplayName("game log 턴 unique constraint 위반은 턴 충돌로 분류한다") void saveNextTurn_MapsGameLogTurnUniqueConstraintToConflict() { given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) - .willThrow(new DataIntegrityViolationException("constraint uk_game_log_session_turn violated")); + .willThrow(postgresConstraint("uk_game_log_session_turn", "23505")); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(TurnConflictException.class); @@ -84,9 +93,46 @@ void saveNextTurn_MapsGameLogTurnUniqueConstraintToConflict() { @DisplayName("image asset 턴 unique constraint 위반도 턴 충돌로 분류한다") void saveNextTurn_MapsImageAssetTurnUniqueConstraintToConflict() { given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) - .willThrow(new DataIntegrityViolationException("constraint uk_image_asset_session_turn violated")); + .willThrow(postgresConstraint("uk_image_asset_session_turn", "23505")); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(TurnConflictException.class); } + @Test + void messageAloneCannotClassifyTurnConflict() { + given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) + .willThrow(new DataIntegrityViolationException("uk_game_log_session_turn")); + assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) + .isInstanceOf(PersistenceOperationException.class); + } + + @Test + void requiresUniqueSqlState() { + given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) + .willThrow(postgresConstraint("uk_game_log_session_turn", "23503")); + assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) + .isInstanceOf(PersistenceOperationException.class); + } + + @Test + void rejectsMissingRequiredDependencies() { + assertThatThrownBy(() -> new GamePersistenceService( + gameSessionRepository, gameLogRepository, gameStateSnapshotRepository, + imageAssetRepository, gameMutationRequestRepository, gameStateCodec, gameStateRecovery, + inventoryAuditCodec, vitalsAuditCodec, combatAuditCodec, questAuditCodec, + relationshipAuditCodec, null)) + .isInstanceOf(NullPointerException.class).hasMessageContaining("storyMemoryAuditCodec"); + } + + private DataIntegrityViolationException postgresConstraint(String name, String state) { + PSQLException postgres = mock(PSQLException.class); + ServerErrorMessage detail = mock(ServerErrorMessage.class); + given(postgres.getSQLState()).willReturn(state); + if ("23505".equals(state)) { + given(postgres.getServerErrorMessage()).willReturn(detail); + given(detail.getConstraint()).willReturn(name); + } + return new DataIntegrityViolationException("database integrity error", postgres); + } + } From 9eab8b90c628903f4fa976f3ba8e5bb6b7374aeb Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:45:44 +0900 Subject: [PATCH 04/13] =?UTF-8?q?test(game):=20Spring=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20codec=20=EB=B9=88=20=EA=B8=B0=EB=B0=98=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../uctale/application/game/ChoiceCodecTest.java | 12 ++++-------- .../game/GameStateCodecCompatibilityTest.java | 6 ++++-- .../application/game/InventoryAuditCodecTest.java | 6 ++++-- .../application/game/VitalsAuditCodecTest.java | 6 ++++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/uctale/uctale/application/game/ChoiceCodecTest.java b/src/test/java/com/uctale/uctale/application/game/ChoiceCodecTest.java index 36e03a6b..75ec3257 100644 --- a/src/test/java/com/uctale/uctale/application/game/ChoiceCodecTest.java +++ b/src/test/java/com/uctale/uctale/application/game/ChoiceCodecTest.java @@ -5,10 +5,10 @@ import com.uctale.uctale.domain.action.PlayerAction; import com.uctale.uctale.dto.GameChoice; import com.uctale.uctale.dto.GameProgressRequest; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import java.util.Map; @@ -16,14 +16,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringBootTest class ChoiceCodecTest { - private ChoiceCodec codec; - - @BeforeEach - void setUp() { - codec = new ChoiceCodec(new ObjectMapper()); - } + @Autowired private ChoiceCodec codec; @Test @DisplayName("서버가 발급한 Skill Check action은 token type sourceTurn arguments가 모두 일치할 때만 PlayerAction으로 변환된다") diff --git a/src/test/java/com/uctale/uctale/application/game/GameStateCodecCompatibilityTest.java b/src/test/java/com/uctale/uctale/application/game/GameStateCodecCompatibilityTest.java index 386053bc..09119cb3 100644 --- a/src/test/java/com/uctale/uctale/application/game/GameStateCodecCompatibilityTest.java +++ b/src/test/java/com/uctale/uctale/application/game/GameStateCodecCompatibilityTest.java @@ -2,13 +2,15 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringBootTest class GameStateCodecCompatibilityTest { - private final GameStateCodec codec = new GameStateCodec(new ObjectMapper(), new GameStateUpgrader()); + @Autowired private GameStateCodec codec; @Test @DisplayName("현재 schema v10에서 stats가 누락된 손상 snapshot은 기본값으로 숨기지 않는다") diff --git a/src/test/java/com/uctale/uctale/application/game/InventoryAuditCodecTest.java b/src/test/java/com/uctale/uctale/application/game/InventoryAuditCodecTest.java index 13f17d20..04e60aa7 100644 --- a/src/test/java/com/uctale/uctale/application/game/InventoryAuditCodecTest.java +++ b/src/test/java/com/uctale/uctale/application/game/InventoryAuditCodecTest.java @@ -7,15 +7,17 @@ import com.uctale.uctale.domain.game.OwnedItem; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringBootTest class InventoryAuditCodecTest { - private final InventoryAuditCodec codec = new InventoryAuditCodec(new ObjectMapper()); + @Autowired private InventoryAuditCodec codec; @Test @DisplayName("inventory state change만 GameLog audit JSON으로 round-trip한다") diff --git a/src/test/java/com/uctale/uctale/application/game/VitalsAuditCodecTest.java b/src/test/java/com/uctale/uctale/application/game/VitalsAuditCodecTest.java index 6306d6f6..65f9b3ad 100644 --- a/src/test/java/com/uctale/uctale/application/game/VitalsAuditCodecTest.java +++ b/src/test/java/com/uctale/uctale/application/game/VitalsAuditCodecTest.java @@ -5,15 +5,17 @@ import com.uctale.uctale.domain.game.StatusExpiryTrigger; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringBootTest class VitalsAuditCodecTest { - private final VitalsAuditCodec codec = new VitalsAuditCodec(new ObjectMapper()); + @Autowired private VitalsAuditCodec codec; @Test @DisplayName("vitals/status state change는 GameLog audit JSON으로 손실 없이 round trip한다") From 654f18d86b204520ac3de3c0aad214199164ec8b Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:46:47 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix(frontend):=20=EC=A4=91=EB=B3=B5=20pro?= =?UTF-8?q?gress=EC=99=80=20resume=20=EC=B0=A8=EB=8B=A8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=AC=EC=9D=B8=EC=A6=9D=20=EC=9D=B4=ED=9B=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 39 +++++++++++++++---- frontend/src/session/asyncRequestGate.js | 22 +++++++++++ frontend/src/session/asyncRequestGate.test.js | 33 ++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 frontend/src/session/asyncRequestGate.js create mode 100644 frontend/src/session/asyncRequestGate.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a45ad593..f19dfbe0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import { import { getApiErrorCode, getApiErrorMessage, isAccessAuthError } from './api/apiError' import { createResumeMeta } from './session/sessionRecovery.js' import { resolveMutationGame } from './session/mutationReplay.js' +import { createAsyncRequestGate } from './session/asyncRequestGate.js' import AccessScreen from './screens/AccessScreen' import GamePlayScreen from './screens/GamePlayScreen' import GameSetupScreen from './screens/GameSetupScreen' @@ -52,6 +53,7 @@ function App() { const [sessionsNextCursor, setSessionsNextCursor] = useState(null) const [sessionsHasMore, setSessionsHasMore] = useState(false) const sessionRequestRef = useRef(0) + const requestGateRef = useRef(createAsyncRequestGate()) const [resumingSessionId, setResumingSessionId] = useState(null) const [setupFieldErrors, setSetupFieldErrors] = useState({}) @@ -70,6 +72,12 @@ function App() { if (!isAccessAuthError(error)) return false setAuthMessage(getApiErrorMessage(error)) setAuthMessageKind('api') + requestGateRef.current.invalidate() + sessionRequestRef.current += 1 + setSessionsLoading(false) + setResumingSessionId(null) + setIsProgressing(false) + setIsStarting(false) setAuthState('login') setGameData(null) setResumeMeta(null) @@ -79,12 +87,13 @@ function App() { const loadSessions = async (append = false) => { if (append && (!sessionsHasMore || sessionsLoading)) return const request = ++sessionRequestRef.current + const epoch = requestGateRef.current.epoch() const cursor = append ? sessionsNextCursor : null setSessionsLoading(true) setSessionsError('') try { const data = await listGameSessions(cursor) - if (request !== sessionRequestRef.current) return + if (request !== sessionRequestRef.current || !requestGateRef.current.isCurrent(epoch)) return setSessions((current) => append ? [...current, ...data.items.filter((item) => !current.some((existing) => existing.sessionId === item.sessionId))] : data.items) @@ -96,7 +105,7 @@ function App() { setSessionsError(getApiErrorMessage(error, '저장된 이야기를 불러오지 못했습니다.')) } } finally { - if (request === sessionRequestRef.current) setSessionsLoading(false) + if (request === sessionRequestRef.current && requestGateRef.current.isCurrent(epoch)) setSessionsLoading(false) } } @@ -174,12 +183,14 @@ function App() { const idempotencyKey = initIdempotencyKeyRef.current || createIdempotencyKey() initIdempotencyKeyRef.current = idempotencyKey + const epoch = requestGateRef.current.epoch() setIsStarting(true) setSetupError('') try { const data = await initGame(world, character, idempotencyKey) const resolved = await resolveMutationGame(data, resumeGameSession) + if (!requestGateRef.current.isCurrent(epoch)) return initIdempotencyKeyRef.current = null setGameData(resolved.game) setResumeMeta(resolved.meta) @@ -187,6 +198,7 @@ function App() { setProgressError(null) setPendingChoiceId(null) } catch (error) { + if (!requestGateRef.current.isCurrent(epoch)) return if (!requireReauthentication(error)) { console.error(error) setSetupError(getApiErrorMessage( @@ -195,16 +207,18 @@ function App() { )) } } finally { - setIsStarting(false) + if (requestGateRef.current.isCurrent(epoch)) setIsStarting(false) } } const handleResume = async (targetSessionId) => { - if (resumingSessionId != null) return + const epoch = requestGateRef.current.begin('resume') + if (epoch == null) return setResumingSessionId(targetSessionId) setSessionsError('') try { const resumed = await resumeGameSession(targetSessionId) + if (!requestGateRef.current.isCurrent(epoch)) return if (!resumed.game) { setSessionsError(resumed.statusMessage || '이 세션은 안전하게 재개할 수 없습니다.') await loadSessions() @@ -216,11 +230,13 @@ function App() { setProgressError(null) setPendingChoiceId(null) } catch (error) { + if (!requestGateRef.current.isCurrent(epoch)) return if (!requireReauthentication(error)) { setSessionsError(getApiErrorMessage(error, '세션을 재개하지 못했습니다.')) } } finally { - setResumingSessionId(null) + requestGateRef.current.end('resume', epoch) + if (requestGateRef.current.isCurrent(epoch)) setResumingSessionId(null) } } @@ -230,6 +246,8 @@ function App() { const choice = gameData?.choices?.find((candidate) => candidate.id === choiceId) if (!choice) return + const epoch = requestGateRef.current.begin('progress') + if (epoch == null) return const idempotencyKey = retryIdempotencyKey || createIdempotencyKey() setIsProgressing(true) setPendingChoiceId(choiceId) @@ -238,11 +256,13 @@ function App() { try { const nextData = await progressGame(sessionId, choice, turnNumber, idempotencyKey) const resolved = await resolveMutationGame(nextData, resumeGameSession) + if (!requestGateRef.current.isCurrent(epoch)) return setGameData(resolved.game) setResumeMeta(resolved.meta) setIsTypingComplete(false) setProgressError(null) } catch (error) { + if (!requestGateRef.current.isCurrent(epoch)) return if (!requireReauthentication(error)) { console.error(error) const errorCode = getApiErrorCode(error) @@ -250,6 +270,7 @@ function App() { if (isRetryableProgressError(error) || errorCode === 'NARRATIVE_PROVIDER_RECOVERY_WAIT') { try { const resumed = await resumeGameSession(sessionId) + if (!requestGateRef.current.isCurrent(epoch)) return if (resumed?.game) { resumedMeta = createResumeMeta(resumed) setResumeMeta(resumedMeta) @@ -266,6 +287,7 @@ function App() { } } } catch (resumeError) { + if (!requestGateRef.current.isCurrent(epoch)) return if (!requireReauthentication(resumeError)) { console.error(resumeError) } @@ -283,8 +305,11 @@ function App() { }) } } finally { - setPendingChoiceId(null) - setIsProgressing(false) + requestGateRef.current.end('progress', epoch) + if (requestGateRef.current.isCurrent(epoch)) { + setPendingChoiceId(null) + setIsProgressing(false) + } } } diff --git a/frontend/src/session/asyncRequestGate.js b/frontend/src/session/asyncRequestGate.js new file mode 100644 index 00000000..488fa559 --- /dev/null +++ b/frontend/src/session/asyncRequestGate.js @@ -0,0 +1,22 @@ +// Authentication changes invalidate every outstanding session / mutation response. +export function createAsyncRequestGate() { + let generation = 0 + const active = new Set() + + return { + epoch: () => generation, + isCurrent: (epoch) => generation === epoch, + begin: (operation) => { + if (active.has(operation)) return null + active.add(operation) + return generation + }, + end: (operation, epoch) => { + if (generation === epoch) active.delete(operation) + }, + invalidate: () => { + generation += 1 + active.clear() + }, + } +} diff --git a/frontend/src/session/asyncRequestGate.test.js b/frontend/src/session/asyncRequestGate.test.js new file mode 100644 index 00000000..c97c6f34 --- /dev/null +++ b/frontend/src/session/asyncRequestGate.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createAsyncRequestGate } from './asyncRequestGate.js' + +test('duplicate progress uses one active operation even before React state commits', () => { + const gate = createAsyncRequestGate() + const first = gate.begin('progress') + assert.equal(first, 0) + assert.equal(gate.begin('progress'), null) + gate.end('progress', first) + assert.equal(gate.begin('progress'), first) +}) + +test('duplicate resume requests are suppressed until the first completes', () => { + const gate = createAsyncRequestGate() + const first = gate.begin('resume') + assert.equal(gate.begin('resume'), null) + gate.end('resume', first) + assert.equal(gate.begin('resume'), first) +}) + +test('reauthentication invalidates stale async responses and releases locks', () => { + const gate = createAsyncRequestGate() + const stale = gate.begin('progress') + gate.invalidate() + const current = gate.begin('progress') + assert.equal(gate.isCurrent(stale), false) + assert.equal(gate.isCurrent(current), true) + gate.end('progress', stale) + assert.equal(gate.begin('progress'), null) + gate.end('progress', current) + assert.equal(gate.begin('progress'), current) +}) From f3dd485396009cb2db5d9ece0c13ee4f144084d1 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:47:11 +0900 Subject: [PATCH 06/13] =?UTF-8?q?test(session):=20PostgreSQL=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20lease=20=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PostgresGameSessionResumeTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java b/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java index f0593ff6..3dac73ba 100644 --- a/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java +++ b/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java @@ -118,6 +118,24 @@ void expiredLeaseAndProviderCooldown_AreReportedAsDistinctRecoveryStates() { assertThat(recoverable.canProgress()).isTrue(); } + @Test + @DisplayName("유효한 PostgreSQL lease는 PROCESSING이며 마지막 완료 턴만 노출한다") + void activeLease_BlocksProgress() { + var session = persistenceService.saveOpening( + OWNER_KEY, "세계", "인물", "마지막 완료 이야기", + choiceCodec.serialize(List.of(new GameChoice(1, "진행한다"))), null + ); + mutationRequestService.begin(OWNER_KEY, GameMutationRequestService.PROGRESS, + "resume-active-lease-001", session.getId(), 1, "a".repeat(64)); + + var resumed = queryService.resumeSession(OWNER_KEY, session.getId()); + assertThat(resumed.status()).isEqualTo("PROCESSING"); + assertThat(resumed.retryable()).isFalse(); + assertThat(resumed.canProgress()).isFalse(); + assertThat(resumed.game().storyText()).isEqualTo("마지막 완료 이야기"); + assertThat(queryService.listSessions(OWNER_KEY).getFirst().status()).isEqualTo("PROCESSING"); + } + @Test @DisplayName("지원하지 않는 snapshot은 기본값으로 덮지 않고 UNRECOVERABLE로 격리한다") void unsupportedSnapshot_IsNotSilentlyRecovered() { From 30cfad75157b4475b32e90824124cab2c6397bd4 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:47:37 +0900 Subject: [PATCH 07/13] =?UTF-8?q?test(postgres):=20=EC=8B=A4=EC=A0=9C=20im?= =?UTF-8?q?age=20turn=20unique=20=EC=B6=A9=EB=8F=8C=EC=9D=84=20TURN=5FCONF?= =?UTF-8?q?LICT=EB=A1=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PostgresGameTurnConflictTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/test/java/com/uctale/uctale/persistence/PostgresGameTurnConflictTest.java diff --git a/src/test/java/com/uctale/uctale/persistence/PostgresGameTurnConflictTest.java b/src/test/java/com/uctale/uctale/persistence/PostgresGameTurnConflictTest.java new file mode 100644 index 00000000..52bae351 --- /dev/null +++ b/src/test/java/com/uctale/uctale/persistence/PostgresGameTurnConflictTest.java @@ -0,0 +1,59 @@ +package com.uctale.uctale.persistence; + +import com.uctale.uctale.application.game.ChoiceCodec; +import com.uctale.uctale.application.game.GamePersistenceService; +import com.uctale.uctale.application.game.GameTurnCommit; +import com.uctale.uctale.application.game.TurnConflictException; +import com.uctale.uctale.application.image.ImageAssetService; +import com.uctale.uctale.domain.ImageAsset; +import com.uctale.uctale.dto.GameChoice; +import com.uctale.uctale.repository.ImageAssetRepository; +import com.uctale.uctale.support.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class PostgresGameTurnConflictTest extends PostgresIntegrationTestSupport { + private static final String OWNER = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private static final String EXISTING = "44444444-4444-4444-4444-444444444444"; + private static final String NEW_ASSET = "55555555-5555-5555-5555-555555555555"; + + @Autowired private GamePersistenceService persistenceService; + @Autowired private ImageAssetRepository imageAssetRepository; + @Autowired private ChoiceCodec choiceCodec; + @Autowired private JdbcTemplate jdbcTemplate; + + @BeforeEach + void cleanDatabase() { + jdbcTemplate.execute(""" + truncate table game_turn_reservation, game_mutation_request, image_asset, + game_state_snapshot, game_log, game_session restart identity + """); + } + + @Test + void uniqueImageTurnConflict_FromRealPostgres23505BecomesTurnConflict() { + var session = persistenceService.saveOpening(OWNER, "세계", "캐릭터", "첫 장면", + choiceCodec.serialize(List.of(new GameChoice(1, "진행"))), null); + var previous = persistenceService.loadLatestTurn(OWNER, session.getId(), 1).gameState(); + imageAssetRepository.saveAndFlush(new ImageAsset(EXISTING, session, 2, + "이미 점유한 턴", "16:9", "flux", 768, 432, 1, true, "v1")); + + var asset = new ImageAssetService.AssetReference(NEW_ASSET, + "/api/game/image-assets/" + NEW_ASSET, "다음 장면", "16:9", "flux", 768, 432, 2, true, "v1"); + var commit = new GameTurnCommit(1, 1, "진행", previous, previous.advance("진행", "둘째 장면"), + "둘째 장면", "[]", asset); + + assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER, session.getId(), commit)) + .isInstanceOf(TurnConflictException.class) + .hasCauseInstanceOf(DataIntegrityViolationException.class); + } +} From e8864be59474cb38786973331099bbaf01b46d75 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:47:58 +0900 Subject: [PATCH 08/13] =?UTF-8?q?build:=20PostgreSQL=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=ED=99=94=20=EC=98=88=EC=99=B8=20=EB=B6=84=EB=A5=98=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20JDBC=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index aa11f17b..1c1fcdfa 100644 --- a/build.gradle +++ b/build.gradle @@ -36,7 +36,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' runtimeOnly 'org.flywaydb:flyway-database-postgresql' testImplementation 'org.springframework.boot:spring-boot-starter-test' From 152a490b3ef6caf44f3f416a930584435796b34e Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:48:37 +0900 Subject: [PATCH 09/13] =?UTF-8?q?test(game):=20=EC=8B=A4=EC=A0=9C=20Spring?= =?UTF-8?q?=20Bean=20=EA=B7=B8=EB=9E=98=ED=94=84=EC=99=80=20legacy=20codec?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GamePersistenceServiceTest.java | 20 +++++++++++++++++++ .../game/GameStateLegacyUpgradeTest.java | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/uctale/uctale/application/game/GamePersistenceServiceTest.java b/src/test/java/com/uctale/uctale/application/game/GamePersistenceServiceTest.java index b1f29d5d..a5d3837f 100644 --- a/src/test/java/com/uctale/uctale/application/game/GamePersistenceServiceTest.java +++ b/src/test/java/com/uctale/uctale/application/game/GamePersistenceServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -32,6 +33,25 @@ class GamePersistenceServiceTest { @Autowired private GameLogRepository gameLogRepository; @Autowired private GameStateSnapshotRepository gameStateSnapshotRepository; @Autowired private ImageAssetRepository imageAssetRepository; + @Autowired private GameStateRecovery gameStateRecovery; + @Autowired private InventoryAuditCodec inventoryAuditCodec; + @Autowired private VitalsAuditCodec vitalsAuditCodec; + @Autowired private CombatAuditCodec combatAuditCodec; + @Autowired private QuestAuditCodec questAuditCodec; + @Autowired private RelationshipAuditCodec relationshipAuditCodec; + @Autowired private StoryMemoryAuditCodec storyMemoryAuditCodec; + + @Test + void persistenceAndRecoveryShareProductionAuditBeans() { + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "gameStateRecovery")).isSameAs(gameStateRecovery); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "inventoryAuditCodec")).isSameAs(inventoryAuditCodec); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "vitalsAuditCodec")).isSameAs(vitalsAuditCodec); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "combatAuditCodec")).isSameAs(combatAuditCodec); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "questAuditCodec")).isSameAs(questAuditCodec); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "relationshipAuditCodec")).isSameAs(relationshipAuditCodec); + assertThat(ReflectionTestUtils.getField(gamePersistenceService, "storyMemoryAuditCodec")).isSameAs(storyMemoryAuditCodec); + assertThat(ReflectionTestUtils.getField(gameStateRecovery, "storyMemoryAuditCodec")).isSameAs(storyMemoryAuditCodec); + } @Test @DisplayName("새 게임 세션은 생성 owner에게 귀속되고 동일 owner만 조회·진행할 수 있다") diff --git a/src/test/java/com/uctale/uctale/application/game/GameStateLegacyUpgradeTest.java b/src/test/java/com/uctale/uctale/application/game/GameStateLegacyUpgradeTest.java index 391fe8bd..2ae26419 100644 --- a/src/test/java/com/uctale/uctale/application/game/GameStateLegacyUpgradeTest.java +++ b/src/test/java/com/uctale/uctale/application/game/GameStateLegacyUpgradeTest.java @@ -2,6 +2,8 @@ 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.SpringBootTest; import org.springframework.core.io.ClassPathResource; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -11,9 +13,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@SpringBootTest class GameStateLegacyUpgradeTest { - private final ObjectMapper objectMapper = new ObjectMapper(); - private final GameStateUpgrader upgrader = new GameStateUpgrader(); + @Autowired private ObjectMapper objectMapper; + @Autowired private GameStateUpgrader upgrader; @Test @DisplayName("production raw legacy snapshot은 현재 v10까지 deterministic upgrade된다") From e1e3efe1a650d02ccc7d5c6bd6790a65d324fb5e Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:49:48 +0900 Subject: [PATCH 10/13] =?UTF-8?q?test(frontend):=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20idempotency=20key=20=EC=9C=A0=EC=A7=80=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 4 ++-- frontend/src/session/asyncRequestGate.js | 4 ++++ frontend/src/session/asyncRequestGate.test.js | 12 +++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f19dfbe0..23d827aa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,7 @@ import { import { getApiErrorCode, getApiErrorMessage, isAccessAuthError } from './api/apiError' import { createResumeMeta } from './session/sessionRecovery.js' import { resolveMutationGame } from './session/mutationReplay.js' -import { createAsyncRequestGate } from './session/asyncRequestGate.js' +import { createAsyncRequestGate, progressRequestKey } from './session/asyncRequestGate.js' import AccessScreen from './screens/AccessScreen' import GamePlayScreen from './screens/GamePlayScreen' import GameSetupScreen from './screens/GameSetupScreen' @@ -248,7 +248,7 @@ function App() { const epoch = requestGateRef.current.begin('progress') if (epoch == null) return - const idempotencyKey = retryIdempotencyKey || createIdempotencyKey() + const idempotencyKey = progressRequestKey(retryIdempotencyKey, createIdempotencyKey) setIsProgressing(true) setPendingChoiceId(choiceId) setProgressError(null) diff --git a/frontend/src/session/asyncRequestGate.js b/frontend/src/session/asyncRequestGate.js index 488fa559..4b4d91ed 100644 --- a/frontend/src/session/asyncRequestGate.js +++ b/frontend/src/session/asyncRequestGate.js @@ -20,3 +20,7 @@ export function createAsyncRequestGate() { }, } } + +export function progressRequestKey(retryKey, createKey) { + return retryKey || createKey() +} diff --git a/frontend/src/session/asyncRequestGate.test.js b/frontend/src/session/asyncRequestGate.test.js index c97c6f34..3f088271 100644 --- a/frontend/src/session/asyncRequestGate.test.js +++ b/frontend/src/session/asyncRequestGate.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { createAsyncRequestGate } from './asyncRequestGate.js' +import { createAsyncRequestGate, progressRequestKey } from './asyncRequestGate.js' test('duplicate progress uses one active operation even before React state commits', () => { const gate = createAsyncRequestGate() @@ -31,3 +31,13 @@ test('reauthentication invalidates stale async responses and releases locks', () gate.end('progress', current) assert.equal(gate.begin('progress'), current) }) + +test('retry sends the identical idempotency key while a new choice gets a new key', () => { + let generated = 0 + const create = () => 'key-' + ++generated + const first = progressRequestKey(null, create) + const retry = progressRequestKey(first, create) + assert.equal(retry, first) + assert.equal(generated, 1) + assert.equal(progressRequestKey(null, create), 'key-2') +}) From 88560c927f5f060eb7181f9561cf30fc00842025 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:50:53 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix(game):=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=9D=98=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=EB=A5=BC=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20null=20=EC=A3=BC=EC=9E=85=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../uctale/application/game/GameStateRecovery.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java b/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java index 316bb325..bad8259a 100644 --- a/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java +++ b/src/main/java/com/uctale/uctale/application/game/GameStateRecovery.java @@ -25,6 +25,17 @@ public class GameStateRecovery { private final StoryMemoryAuditCodec storyMemoryAuditCodec; @Autowired + public GameStateRecovery(InventoryAuditCodec inventoryAuditCodec, VitalsAuditCodec vitalsAuditCodec, + CombatAuditCodec combatAuditCodec, QuestAuditCodec questAuditCodec, RelationshipAuditCodec relationshipAuditCodec, + StoryMemoryAuditCodec storyMemoryAuditCodec) { + this.inventoryAuditCodec = java.util.Objects.requireNonNull(inventoryAuditCodec, "inventoryAuditCodec"); + this.vitalsAuditCodec = java.util.Objects.requireNonNull(vitalsAuditCodec, "vitalsAuditCodec"); + this.combatAuditCodec = java.util.Objects.requireNonNull(combatAuditCodec, "combatAuditCodec"); + this.questAuditCodec = java.util.Objects.requireNonNull(questAuditCodec, "questAuditCodec"); + this.relationshipAuditCodec = java.util.Objects.requireNonNull(relationshipAuditCodec, "relationshipAuditCodec"); + this.storyMemoryAuditCodec = java.util.Objects.requireNonNull(storyMemoryAuditCodec, "storyMemoryAuditCodec"); + } + public GameState recover(GameSession session, List logs) { if (logs.isEmpty()) throw new IllegalStateException("GameState를 복구할 게임 로그가 없습니다."); GameLog opening = logs.getFirst(); From a5dde7b886d7ede6ddb57225b51e1f8f5f8685c8 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:53:29 +0900 Subject: [PATCH 12/13] =?UTF-8?q?test(postgres):=20null=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20=EC=83=81=EC=86=8D=20fixture=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9C=84=EC=9E=84=20?= =?UTF-8?q?mock=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PostgresM2TurnIntegrityMatrixTest.java | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java b/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java index 911ed357..a034f660 100644 --- a/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java +++ b/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java @@ -53,6 +53,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; @SpringBootTest class PostgresM2TurnIntegrityMatrixTest extends PostgresIntegrationTestSupport { @@ -294,7 +298,7 @@ void expiredLeaseTakeover_AllowsProviderRetryButSingleCanonicalCommit() throws E GameProgressRequest request = issuedProgress(opening); String crashedKey = "matrix-crash-key-a"; - GameService crashingService = newGameService(new CrashBeforeCommitPersistence(persistenceService)); + GameService crashingService = newGameService(crashingPersistence()); assertThatThrownBy(() -> crashingService.progressGame( progressContext(crashedKey, opening.sessionId()), request @@ -678,30 +682,14 @@ static BeginOutcome blocked(MutationInProgressException exception) { } } - private static final class CrashBeforeCommitPersistence extends GamePersistenceService { - private final GamePersistenceService delegate; - - CrashBeforeCommitPersistence(GamePersistenceService delegate) { - super(null, null, null, null, null, null, null); - this.delegate = delegate; - } - - @Override - public LoadedTurn loadLatestTurn(String ownerKey, Long sessionId, int expectedTurn) { - return delegate.loadLatestTurn(ownerKey, sessionId, expectedTurn); - } - - @Override - public int saveNextTurn( - String ownerKey, - Long sessionId, - GameTurnCommit commit, - Long mutationRequestId, - String resultTitle, - String reservationOwner - ) { - throw new SimulatedCrash(); - } + private GamePersistenceService crashingPersistence() { + // Production 빈으로 나머지 메서드를 위임하고 commit 직전 실패만 대체한다. + // null 의존성을 넘겨 production service를 상속하던 invalid construction을 제거한다. + GamePersistenceService proxy = mock(GamePersistenceService.class, delegatesTo(persistenceService)); + doThrow(new SimulatedCrash()).when(proxy).saveNextTurn( + any(), any(), any(), any(), any(), any() + ); + return proxy; } private static final class MutableClock extends Clock { From c6381a11e0a0047ab310408dbb92f2efd0ac42d9 Mon Sep 17 00:00:00 2001 From: krestar <78223145+krestar@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:55:42 +0900 Subject: [PATCH 13/13] =?UTF-8?q?test(persistence):=20SQLState=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=83=9D=EC=84=B1=EA=B3=BC=20Mockito=20stubbing=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/game/GamePersistenceIntegrityTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java b/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java index 2d2d680b..c1792cf7 100644 --- a/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java +++ b/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java @@ -82,8 +82,9 @@ void saveNextTurn_DoesNotMapEveryIntegrityFailureToTurnConflict() { @Test @DisplayName("game log 턴 unique constraint 위반은 턴 충돌로 분류한다") void saveNextTurn_MapsGameLogTurnUniqueConstraintToConflict() { + var exception = postgresConstraint("uk_game_log_session_turn", "23505"); given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) - .willThrow(postgresConstraint("uk_game_log_session_turn", "23505")); + .willThrow(exception); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(TurnConflictException.class); @@ -92,8 +93,9 @@ void saveNextTurn_MapsGameLogTurnUniqueConstraintToConflict() { @Test @DisplayName("image asset 턴 unique constraint 위반도 턴 충돌로 분류한다") void saveNextTurn_MapsImageAssetTurnUniqueConstraintToConflict() { + var exception = postgresConstraint("uk_image_asset_session_turn", "23505"); given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) - .willThrow(postgresConstraint("uk_image_asset_session_turn", "23505")); + .willThrow(exception); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(TurnConflictException.class); @@ -108,8 +110,9 @@ void messageAloneCannotClassifyTurnConflict() { @Test void requiresUniqueSqlState() { + var exception = postgresConstraint("uk_game_log_session_turn", "23503"); given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) - .willThrow(postgresConstraint("uk_game_log_session_turn", "23503")); + .willThrow(exception); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(PersistenceOperationException.class); }