diff --git a/build.gradle b/build.gradle index aa11f17..1c1fcdf 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' diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a45ad59..23d827a 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, progressRequestKey } 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,7 +246,9 @@ function App() { const choice = gameData?.choices?.find((candidate) => candidate.id === choiceId) if (!choice) return - const idempotencyKey = retryIdempotencyKey || createIdempotencyKey() + const epoch = requestGateRef.current.begin('progress') + if (epoch == null) return + const idempotencyKey = progressRequestKey(retryIdempotencyKey, createIdempotencyKey) setIsProgressing(true) setPendingChoiceId(choiceId) setProgressError(null) @@ -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 0000000..4b4d91e --- /dev/null +++ b/frontend/src/session/asyncRequestGate.js @@ -0,0 +1,26 @@ +// 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() + }, + } +} + +export function progressRequestKey(retryKey, createKey) { + return retryKey || createKey() +} diff --git a/frontend/src/session/asyncRequestGate.test.js b/frontend/src/session/asyncRequestGate.test.js new file mode 100644 index 0000000..3f08827 --- /dev/null +++ b/frontend/src/session/asyncRequestGate.test.js @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createAsyncRequestGate, progressRequestKey } 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) +}) + +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') +}) 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 0feb30e..4d7127f 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) { 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 4667a78..bad8259 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; @@ -29,24 +28,12 @@ public class GameStateRecovery { 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())); + 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) { 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 36e03a6..75ec325 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/GamePersistenceIntegrityTest.java b/src/test/java/com/uctale/uctale/application/game/GamePersistenceIntegrityTest.java index 1c45377..c1792cf 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( @@ -73,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(new DataIntegrityViolationException("constraint uk_game_log_session_turn violated")); + .willThrow(exception); assertThatThrownBy(() -> persistenceService.saveNextTurn(OWNER_KEY, 42L, commit)) .isInstanceOf(TurnConflictException.class); @@ -83,10 +93,49 @@ 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(new DataIntegrityViolationException("constraint uk_image_asset_session_turn violated")); + .willThrow(exception); 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() { + var exception = postgresConstraint("uk_game_log_session_turn", "23503"); + given(gameSessionRepository.findByIdAndOwnerKey(42L, OWNER_KEY)) + .willThrow(exception); + 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); + } + } 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 b1f29d5..a5d3837 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/GameStateCodecCompatibilityTest.java b/src/test/java/com/uctale/uctale/application/game/GameStateCodecCompatibilityTest.java index 386053b..09119cb 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/GameStateLegacyUpgradeTest.java b/src/test/java/com/uctale/uctale/application/game/GameStateLegacyUpgradeTest.java index 391fe8b..2ae2641 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된다") 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 13f17d2..04e60aa 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 6306d6f..65f9b3a 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한다") diff --git a/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java b/src/test/java/com/uctale/uctale/persistence/PostgresGameSessionResumeTest.java index f0593ff..3dac73b 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() { 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 0000000..52bae35 --- /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); + } +} diff --git a/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java b/src/test/java/com/uctale/uctale/persistence/PostgresM2TurnIntegrityMatrixTest.java index 911ed35..a034f66 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 {