feat(library): PATCH backlog saves only the sent fields

This commit is contained in:
lobo committed 2026-09-30 07:36:08 +02:00
1 parent 73cfcb58e2
commit 79d7bf17f2
8 files changed
+156 -2

No files matched your search

@@ -53,7 +53,7 @@ public class SecurityConfig {
CorsConfiguration configuration = new CorsConfiguration();
// Exact origins only: with credentials, a pattern would send the login cookie to every matching site.
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of(
"Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER, "X-Profile-Ticket"));
configuration.setAllowCredentials(true);
@@ -4,6 +4,7 @@ import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CorrectionDto;
import core_service.modules.library.api.dto.FinishedHintDismissal;
@@ -47,6 +48,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -231,6 +233,15 @@ public class LibraryController {
.body(GAME_NOT_FOUND));
}
@PatchMapping("/games/{appid}/backlog")
public ResponseEntity<?> patchBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody BacklogPatch patch) {
return backlogService.patch(principal.getIdAsLong(), appid, patch)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(LibraryController::unknownGame);
}
@PutMapping("/games/{appid}/hidden")
public ResponseEntity<?> updateHidden(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.Size;
/**
* Request and response of the backlog update. The request replaces all four fields; null clears an optional one.
* The PUT goes away in item 29; the Frontend sends a {@link BacklogPatch}.
*/
public record BacklogEntryDto(
@NotNull GameStatus status,
@@ -0,0 +1,26 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.Optional;
/**
* The backlog fields the request sends; a field it leaves out stays as it is. A field is null when it is not in the
* JSON and an empty Optional when it is null there, which clears it.
* A class, not a record: for a record Jackson gives an empty Optional for a missing field too.
*/
@Getter
@Setter
public class BacklogPatch {
private Optional<@NotNull GameStatus> status;
private Optional<@Min(1) @Max(5) Integer> rating;
private Optional<@Size(max = 2000) String> notes;
private Optional<GamePriority> priority;
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.domain.FinishedHintType;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
@@ -40,6 +41,28 @@ public class BacklogService {
});
}
/**
* Changes only the fields the patch sends. Only a sent status counts as set by the user, so a save of the notes
* alone still lets the sync move the game from BACKLOG to PLAYING.
*
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> patch(Long userId, Long appid, BacklogPatch patch) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
if (patch.getStatus() != null) {
game.setStatus(patch.getStatus().orElseThrow());
game.setStatusSetByUser(true);
}
if (patch.getRating() != null) game.setRating(patch.getRating().orElse(null));
if (patch.getNotes() != null) game.setNotes(patch.getNotes().filter(notes -> !notes.isBlank()).orElse(null));
if (patch.getPriority() != null) game.setPriority(patch.getPriority().orElse(null));
return toDto(game);
});
}
/**
* Changes only the status and keeps rating, notes and priority.
*
@@ -48,6 +48,13 @@ class ClientHeaderFilterTest extends AbstractIntegrationTest {
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
mockMvc.perform(options("/api/library/games/220/backlog")
.header("Origin", FRONTEND)
.header("Access-Control-Request-Method", "PATCH")
.header("Access-Control-Request-Headers", "x-tbp-client,content-type"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND));
mockMvc.perform(options("/api/library/sync")
.header("Origin", "https://evil.andreas-dahm.eu")
.header("Access-Control-Request-Method", "POST"))
@@ -17,11 +17,15 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -138,6 +142,87 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
.andExpect(status().isNotFound());
}
private ResultActions patchBacklog(long appid, String body) throws Exception {
return mockMvc.perform(patch("/api/library/games/" + appid + "/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private void storeAllFields() {
UserGame game = stored();
game.setRating(4);
game.setNotes("old notes");
game.setPriority(GamePriority.HIGH);
userGameRepository.save(game);
}
@Test
void patchBacklog_OnlyNotes_KeepsTheOtherFieldsAndTheAutomaticStatus() throws Exception {
storeAllFields();
patchBacklog(APPID, "{\"notes\":\"new notes\"}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.notes").value("new notes"))
.andExpect(jsonPath("$.rating").value(4));
UserGame game = stored();
assertEquals("new notes", game.getNotes());
assertEquals(4, game.getRating());
assertEquals(GamePriority.HIGH, game.getPriority());
assertEquals(GameStatus.BACKLOG, game.getStatus());
assertFalse(game.isStatusSetByUser());
}
@Test
void patchBacklog_Status_IsSetByTheUser() throws Exception {
patchBacklog(APPID, "{\"status\":\"PLAYING\"}").andExpect(status().isOk());
assertEquals(GameStatus.PLAYING, stored().getStatus());
assertTrue(stored().isStatusSetByUser());
}
@Test
void patchBacklog_NullClearsAnOptionalFieldAndBlankNotesAreNull() throws Exception {
storeAllFields();
patchBacklog(APPID, "{\"rating\":null,\"priority\":null,\"notes\":\" \"}").andExpect(status().isOk());
UserGame game = stored();
assertNull(game.getRating());
assertNull(game.getPriority());
assertNull(game.getNotes());
}
@ParameterizedTest
@ValueSource(strings = {
"{\"status\":null}",
"{\"status\":\"WISHLIST\"}",
"{\"rating\":0}",
"{\"rating\":6}",
"{\"rating\":4.7}",
"{\"priority\":\"URGENT\"}"})
void patchBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
storeAllFields();
patchBacklog(APPID, body).andExpect(status().isBadRequest());
UserGame game = stored();
assertEquals(GameStatus.BACKLOG, game.getStatus());
assertEquals(4, game.getRating());
assertEquals(GamePriority.HIGH, game.getPriority());
}
@Test
void patchBacklog_NotesTooLong_Is400() throws Exception {
patchBacklog(APPID, "{\"notes\":\"" + "x".repeat(2001) + "\"}").andExpect(status().isBadRequest());
}
@Test
void patchBacklog_GameNotOwned_Is404() throws Exception {
patchBacklog(999L, "{\"notes\":\"x\"}")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void updateStatus_KeepsRatingNotesAndPriority() throws Exception {
UserGame game = stored();