From 9ff00c1f8f733c0873129b4a3825eac3cc1f9d34 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 11:04:02 +0200 Subject: [PATCH] fix(library): keep backlog edits made during a sync, reject fractional ratings UserGame now uses @DynamicUpdate, so the sync flush writes only the columns it changed and no longer overwrites a backlog update committed meanwhile. --- .../modules/library/domain/UserGame.java | 3 ++ src/main/resources/application.yml | 3 ++ .../api/LibraryControllerBacklogTest.java | 9 ++++ .../library/repo/UserGameRepositoryTest.java | 42 +++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index e929320..53b0ac6 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -11,10 +11,13 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.DynamicUpdate; import java.time.Instant; @Entity +// The sync and the backlog update run in separate transactions on the same row; each must write only its own columns. +@DynamicUpdate @Table(name = "user_game") @IdClass(UserGameId.class) @Getter diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 292ce0f..520a286 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,9 @@ spring: name: tbp-core-service profiles: active: prod + jackson: + deserialization: + accept-float-as-int: false jpa: open-in-view: true flyway: diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java index ebb9d2d..62a74ea 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerBacklogTest.java @@ -100,6 +100,15 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest { assertEquals(GameStatus.BACKLOG, stored().getStatus()); } + @Test + void updateBacklog_FractionalRating_Is400() throws Exception { + mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PLAYING\",\"rating\":4.7}")) + .andExpect(status().isBadRequest()); + + assertNull(stored().getRating()); + } + @Test void updateBacklog_NotesTooLong_Is400() throws Exception { String notes = "x".repeat(2001); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 536adeb..08fa239 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -10,6 +10,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.util.List; @@ -28,6 +33,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private SteamGameRepository steamGameRepository; + @Autowired + private PlatformTransactionManager transactionManager; + @BeforeEach void seed() { steamGameRepository.saveAll(List.of( @@ -89,4 +97,38 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertNull(UserGameRepository.containsPattern(" ")); assertNull(UserGameRepository.containsPattern(null)); } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void syncFlushKeepsBacklogEditCommittedMeanwhile() { + UserGameId id = new UserGameId(USER_ID, 220L); + TransactionTemplate syncTx = new TransactionTemplate(transactionManager); + TransactionTemplate userTx = new TransactionTemplate(transactionManager); + userTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + try { + syncTx.executeWithoutResult(sync -> { + // LibrarySyncWorker.upsertUserGame: entity loaded with status PLAYING (300 min playtime) + UserGame syncView = userGameRepository.findById(id).orElseThrow(); + + // BacklogService.update commits in its own transaction while the sync is still running + userTx.executeWithoutResult(user -> { + UserGame edit = userGameRepository.findById(id).orElseThrow(); + edit.setStatus(GameStatus.FINISHED); + edit.setRating(5); + }); + + // the sync only touches playtime and lastSeenAt, then commits + syncView.setPlaytimeMinutes(400); + syncView.setLastSeenAt(Instant.now()); + }); + + UserGame stored = userGameRepository.findById(id).orElseThrow(); + assertEquals(400, stored.getPlaytimeMinutes()); + assertEquals(GameStatus.FINISHED, stored.getStatus()); + assertEquals(5, stored.getRating()); + } finally { + userGameRepository.deleteAll(); + steamGameRepository.deleteAll(); + } + } }