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.
This commit is contained in:
lobo committed 2026-09-24 11:04:02 +02:00
1 parent 0ac93394d9
commit 3351ef75f0
4 files changed
+57

No files matched your search

@@ -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
+3
View File
@@ -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:
@@ -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);
@@ -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();
}
}
}