From 7b06c3adec4311babdec2d9aa8251344d617f7dc Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 16:30:07 +0200 Subject: [PATCH] feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items DEFERRABLE INITIALLY DEFERRED allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row). CHECK (position > 0) guards against off-by-one in the append path. Both verified by JourneyItemConstraintsTest via raw pg_constraint query + jdbcTemplate inserts against a real postgres:16-alpine container. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/config/JacksonConfig.java | 7 +- ...add_journey_items_position_constraints.sql | 19 ++++ .../JourneyItemConstraintsTest.java | 99 +++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java index ca027548..5c5e0945 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/JacksonConfig.java @@ -1,8 +1,7 @@ package org.raddatz.familienarchiv.config; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.Module; import org.openapitools.jackson.nullable.JsonNullableModule; -import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,7 +9,7 @@ import org.springframework.context.annotation.Configuration; public class JacksonConfig { @Bean - public Jackson2ObjectMapperBuilderCustomizer jsonNullableModule() { - return builder -> builder.modulesToInstall(new JsonNullableModule()); + public Module jsonNullableModule() { + return new JsonNullableModule(); } } diff --git a/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql new file mode 100644 index 00000000..76a8af2f --- /dev/null +++ b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql @@ -0,0 +1,19 @@ +-- Adds the two constraints that V72 deferred: +-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED +-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row). +-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer +-- in transaction mode — correct today; a future switch to statement-level would silently +-- break deferred checking at COMMIT). +-- 2. CHECK (position > 0) — defense against off-by-one in the append path. +-- +-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this. +-- Do NOT add executeInTransaction=false or any callback that splits this migration. + +ALTER TABLE journey_items + ADD CONSTRAINT uq_journey_items_geschichte_position + UNIQUE (geschichte_id, position) + DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE journey_items + ADD CONSTRAINT chk_journey_item_position + CHECK (position > 0); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java new file mode 100644 index 00000000..a2615003 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java @@ -0,0 +1,99 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.geschichte.Geschichte; +import org.raddatz.familienarchiv.geschichte.GeschichteRepository; +import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level. + * A DataIntegrityViolationException inside a class-level @Transactional marks the tx + * rollback-only and cascades into TransactionSystemException on teardown. + * Each test inserts via jdbcTemplate and uses explicit SQL teardown. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class JourneyItemConstraintsTest { + + @MockitoBean + S3Client s3Client; + + @Autowired JdbcTemplate jdbcTemplate; + @Autowired GeschichteRepository geschichteRepository; + @Autowired DocumentRepository documentRepository; + + private UUID geschichteId; + private UUID documentId; + + @BeforeEach + void seed() { + jdbcTemplate.execute("DELETE FROM journey_items"); + Document doc = documentRepository.save(Document.builder() + .title("Constraints-Test-Doc") + .originalFilename("ct.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + documentId = doc.getId(); + Geschichte g = geschichteRepository.save(Geschichte.builder() + .title("Constraints-Test-Journey") + .status(GeschichteStatus.DRAFT) + .type(GeschichteType.JOURNEY) + .build()); + geschichteId = g.getId(); + } + + @Test + void unique_constraint_is_deferrable_initially_deferred() { + Boolean condeferrable = jdbcTemplate.queryForObject( + "SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'", + Boolean.class); + Boolean condeferred = jdbcTemplate.queryForObject( + "SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'", + Boolean.class); + assertThat(condeferrable).as("constraint must be deferrable").isTrue(); + assertThat(condeferred).as("constraint must be initially deferred").isTrue(); + } + + @Test + void position_check_rejects_nonpositive() { + UUID itemId = UUID.randomUUID(); + assertThatThrownBy(() -> + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)", + itemId, geschichteId, 0, "test")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void unique_constraint_rejects_duplicate_position_per_geschichte() { + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)", + UUID.randomUUID(), geschichteId, 10, documentId); + + assertThatThrownBy(() -> + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)", + UUID.randomUUID(), geschichteId, 10, documentId)) + .isInstanceOf(DataIntegrityViolationException.class); + } +}