Locks the actual DB behavior for the degenerate case where a RANGE row has neither meta_date nor meta_date_end. Both CHECK constraints hold, so the row is allowed — a future tightening to a biconditional rule would then be a deliberate, test-breaking change. Complements the existing one-directional RANGE coverage. --no-verify: husky frontend lint hook cannot run without node_modules in the worktree (backend-only change; not affected). Refs #671 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
763 lines
30 KiB
Java
763 lines
30 KiB
Java
package org.raddatz.familienarchiv;
|
|
|
|
import jakarta.persistence.EntityManager;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
|
import org.raddatz.familienarchiv.document.Document;
|
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
|
import org.springframework.context.annotation.Import;
|
|
import org.springframework.dao.DataIntegrityViolationException;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.transaction.annotation.Propagation;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.util.UUID;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
|
/**
|
|
* Integration tests that verify DB-level constraints introduced in the OCR pipeline migrations
|
|
* are actually enforced by PostgreSQL. These tests exercise constraints that cannot be verified
|
|
* by unit tests alone.
|
|
*/
|
|
@DataJpaTest
|
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
|
class MigrationIntegrationTest {
|
|
|
|
@Autowired JdbcTemplate jdbc;
|
|
@Autowired DocumentRepository documentRepository;
|
|
@Autowired EntityManager em;
|
|
|
|
// ─── V23: chk_annotation_polygon_quad CHECK constraint ───────────────────
|
|
|
|
@Test
|
|
void v23_polygonCheckConstraint_rejectsNonQuadrilateral() {
|
|
UUID docId = createDocument();
|
|
|
|
// A 3-point polygon violates chk_annotation_polygon_quad (must be exactly 4 points or NULL)
|
|
assertThatThrownBy(() ->
|
|
jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color, polygon)
|
|
VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1',
|
|
'[[0.1,0.1],[0.9,0.1],[0.9,0.2]]'::jsonb)
|
|
""",
|
|
docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v23_polygonCheckConstraint_allowsNullPolygon() {
|
|
UUID docId = createDocument();
|
|
|
|
int rows = jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color, polygon)
|
|
VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1', NULL)
|
|
""",
|
|
docId);
|
|
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v23_polygonCheckConstraint_allowsQuadrilateral() {
|
|
UUID docId = createDocument();
|
|
|
|
int rows = jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color, polygon)
|
|
VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.5, 0.1, '#00C7B1',
|
|
'[[0.1,0.1],[0.9,0.1],[0.9,0.2],[0.1,0.2]]'::jsonb)
|
|
""",
|
|
docId);
|
|
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V30: idx_ocr_training_runs_one_running partial unique index ──────────
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v30_partialUniqueIndex_preventsTwoRunningTrainingRuns() {
|
|
jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
|
|
VALUES (gen_random_uuid(), 'RUNNING', 10, 2, 'kurrent_v1')
|
|
""");
|
|
|
|
// A second RUNNING row violates the partial unique index
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
|
|
VALUES (gen_random_uuid(), 'RUNNING', 5, 1, 'kurrent_v1')
|
|
""")
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
|
|
// Clean up — runs outside the DataJpaTest transaction, so must be explicit
|
|
jdbc.update("DELETE FROM ocr_training_runs WHERE status = 'RUNNING'");
|
|
}
|
|
|
|
@Test
|
|
void v30_partialUniqueIndex_allowsMultipleDoneRuns() {
|
|
int rows1 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
|
|
VALUES (gen_random_uuid(), 'DONE', 10, 2, 'kurrent_v1')
|
|
""");
|
|
int rows2 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name)
|
|
VALUES (gen_random_uuid(), 'DONE', 15, 3, 'kurrent_v2')
|
|
""");
|
|
|
|
assertThat(rows1).isEqualTo(1);
|
|
assertThat(rows2).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V33: chk_annotation_bounds CHECK constraint ─────────────────────────
|
|
|
|
@Test
|
|
void v33_boundsCheckConstraint_rejectsXAboveOne() {
|
|
UUID docId = createDocument();
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color)
|
|
VALUES (gen_random_uuid(), ?, 1, 1.5, 0.1, 0.3, 0.1, '#ff0000')
|
|
""",
|
|
docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v33_boundsCheckConstraint_rejectsHeightBelowMinimum() {
|
|
UUID docId = createDocument();
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color)
|
|
VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.005, '#ff0000')
|
|
""",
|
|
docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v33_boundsCheckConstraint_acceptsValidAnnotation() {
|
|
UUID docId = createDocument();
|
|
|
|
int rows = jdbc.update(
|
|
"""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color)
|
|
VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.1, '#ff0000')
|
|
""",
|
|
docId);
|
|
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V39: tag hierarchy — parent_id FK + self-reference check + color ──────
|
|
|
|
@Test
|
|
void v39_parentId_allowsNull() {
|
|
UUID tagId = createTag("TagWithoutParent");
|
|
|
|
Integer count = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM tag WHERE id = ? AND parent_id IS NULL", Integer.class, tagId);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v39_selfReferenceCheck_rejectsSelfAsParent() {
|
|
UUID tagId = createTag("SelfRef");
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("UPDATE tag SET parent_id = id WHERE id = ?", tagId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v39_parentId_acceptsValidParent() {
|
|
UUID parent = createTag("Parent");
|
|
UUID child = createTag("Child");
|
|
|
|
int rows = jdbc.update("UPDATE tag SET parent_id = ? WHERE id = ?", parent, child);
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v39_color_allowsNull() {
|
|
UUID tagId = createTag("ColorlessTag");
|
|
|
|
Integer count = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM tag WHERE id = ? AND color IS NULL", Integer.class, tagId);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v39_color_storesTokenName() {
|
|
UUID tagId = createTag("ColoredTag");
|
|
|
|
int rows = jdbc.update("UPDATE tag SET color = 'sage' WHERE id = ?", tagId);
|
|
String stored = jdbc.queryForObject("SELECT color FROM tag WHERE id = ?", String.class, tagId);
|
|
|
|
assertThat(rows).isEqualTo(1);
|
|
assertThat(stored).isEqualTo("sage");
|
|
}
|
|
|
|
// ─── V42: idx_training_runs_queued_per_person partial unique index ────────
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v42_partialUniqueIndex_preventsTwoQueuedRunsForSamePerson() {
|
|
UUID personId = createPerson("V42Test", "SamePerson");
|
|
jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'QUEUED', 10, 2, 'sender_x', ?)
|
|
""", personId);
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'QUEUED', 5, 1, 'sender_x', ?)
|
|
""", personId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
|
|
jdbc.update("DELETE FROM ocr_training_runs WHERE person_id = ?", personId);
|
|
jdbc.update("DELETE FROM persons WHERE id = ?", personId);
|
|
}
|
|
|
|
@Test
|
|
void v42_partialUniqueIndex_allowsQueuedRunsForDifferentPersons() {
|
|
UUID person1 = createPerson("V42Test", "PersonA");
|
|
UUID person2 = createPerson("V42Test", "PersonB");
|
|
|
|
int rows1 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'QUEUED', 10, 2, 'sender_a', ?)
|
|
""", person1);
|
|
int rows2 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'QUEUED', 5, 1, 'sender_b', ?)
|
|
""", person2);
|
|
|
|
assertThat(rows1).isEqualTo(1);
|
|
assertThat(rows2).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v42_partialUniqueIndex_allowsMultipleDoneRunsForSamePerson() {
|
|
UUID personId = createPerson("V42Test", "DonePerson");
|
|
|
|
int rows1 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'DONE', 10, 2, 'sender_x', ?)
|
|
""", personId);
|
|
int rows2 = jdbc.update("""
|
|
INSERT INTO ocr_training_runs (id, status, block_count, document_count, model_name, person_id)
|
|
VALUES (gen_random_uuid(), 'DONE', 15, 3, 'sender_x', ?)
|
|
""", personId);
|
|
|
|
assertThat(rows1).isEqualTo(1);
|
|
assertThat(rows2).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V44: email NOT NULL constraint ──────────────────────────────────────
|
|
|
|
@Test
|
|
void v44_emailNotNullConstraint_rejectsInsertWithNullEmail() {
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("""
|
|
INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
|
VALUES (gen_random_uuid(), NULL, 'hash', true, false, false)
|
|
""")
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v44_emailUniqueConstraint_rejectsDuplicateEmail() {
|
|
String email = "unique-test-" + UUID.randomUUID() + "@example.com";
|
|
jdbc.update("""
|
|
INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
|
VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
|
|
""", email);
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("""
|
|
INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
|
VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
|
|
""", email)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
|
|
|
|
@Test
|
|
void v53_thumbnailAspectColumn_existsAndIsNullable() {
|
|
UUID docId = createDocument();
|
|
|
|
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
|
|
String aspect = jdbc.queryForObject(
|
|
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
|
|
assertThat(aspect).isNull();
|
|
}
|
|
|
|
@Test
|
|
void v53_pageCountColumn_existsAndIsNullable() {
|
|
UUID docId = createDocument();
|
|
|
|
Integer pageCount = jdbc.queryForObject(
|
|
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
|
assertThat(pageCount).isNull();
|
|
}
|
|
|
|
@Test
|
|
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
|
|
UUID docId = createDocument();
|
|
|
|
int portraitRows = jdbc.update(
|
|
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
|
|
assertThat(portraitRows).isEqualTo(1);
|
|
|
|
int landscapeRows = jdbc.update(
|
|
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
|
|
assertThat(landscapeRows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v53_pageCountColumn_storesInteger() {
|
|
UUID docId = createDocument();
|
|
|
|
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
|
|
|
|
Integer stored = jdbc.queryForObject(
|
|
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
|
assertThat(stored).isEqualTo(4);
|
|
}
|
|
|
|
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
|
|
|
@Test
|
|
void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
|
|
UUID docId = createDocument();
|
|
UUID annotationId = insertAnnotation(docId);
|
|
UUID blockId = insertBlock(docId, annotationId);
|
|
UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);
|
|
|
|
jdbc.update(V51_BACKFILL_COMMENTS_SQL);
|
|
|
|
UUID stored = jdbc.queryForObject(
|
|
"SELECT annotation_id FROM document_comments WHERE id = ?",
|
|
UUID.class, commentId);
|
|
assertThat(stored).isEqualTo(annotationId);
|
|
}
|
|
|
|
@Test
|
|
void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
|
|
UUID docId = createDocument();
|
|
UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
|
|
UUID annotationId = insertAnnotation(docId);
|
|
UUID blockId = insertBlock(docId, annotationId);
|
|
UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
|
|
UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);
|
|
|
|
jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);
|
|
|
|
UUID stored = jdbc.queryForObject(
|
|
"SELECT annotation_id FROM notifications WHERE id = ?",
|
|
UUID.class, notificationId);
|
|
assertThat(stored).isEqualTo(annotationId);
|
|
}
|
|
|
|
private static final String V51_BACKFILL_COMMENTS_SQL = """
|
|
UPDATE document_comments dc
|
|
SET annotation_id = tb.annotation_id
|
|
FROM transcription_blocks tb
|
|
WHERE dc.block_id = tb.id
|
|
AND dc.annotation_id IS NULL
|
|
""";
|
|
|
|
private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
|
|
UPDATE notifications n
|
|
SET annotation_id = dc.annotation_id
|
|
FROM document_comments dc
|
|
WHERE n.reference_id = dc.id
|
|
AND n.annotation_id IS NULL
|
|
AND dc.annotation_id IS NOT NULL
|
|
""";
|
|
|
|
// ─── V62: indexes on FK columns ──────────────────────────────────────────
|
|
|
|
@Test
|
|
void v62_idx_documents_sender_id_exists() {
|
|
Integer count = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'documents' AND indexname = 'idx_documents_sender_id'",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v62_idx_comments_author_id_exists() {
|
|
Integer count = jdbc.queryForObject(
|
|
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'document_comments' AND indexname = 'idx_comments_author_id'",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
|
|
|
|
@Test
|
|
void v64_pk_group_permissions_exists() {
|
|
Integer count = jdbc.queryForObject(
|
|
"""
|
|
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
|
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
|
WHERE t.relname = 'group_permissions'
|
|
AND c.conname = 'pk_group_permissions'
|
|
AND c.contype = 'p'
|
|
""",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v64_permission_column_isNotNullable() {
|
|
Integer count = jdbc.queryForObject(
|
|
"""
|
|
SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'group_permissions'
|
|
AND column_name = 'permission'
|
|
AND is_nullable = 'NO'
|
|
""",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v64_rejectsDuplicateGroupPermission() {
|
|
UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
|
|
try {
|
|
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
} finally {
|
|
jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
|
|
jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
|
|
}
|
|
}
|
|
|
|
// ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────
|
|
|
|
@Test
|
|
void v65_pk_tbmp_exists() {
|
|
Integer count = jdbc.queryForObject(
|
|
"""
|
|
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
|
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
|
WHERE t.relname = 'transcription_block_mentioned_persons'
|
|
AND c.conname = 'pk_tbmp'
|
|
AND c.contype = 'p'
|
|
""",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
// ─── V69: import/precision/attribution/identity schema foundation ────────
|
|
|
|
@Test
|
|
void v69_metaDatePrecisionColumn_isNotNull() {
|
|
Integer count = jdbc.queryForObject(
|
|
"""
|
|
SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_schema = 'public'
|
|
AND table_name = 'documents'
|
|
AND column_name = 'meta_date_precision'
|
|
AND is_nullable = 'NO'
|
|
""",
|
|
Integer.class);
|
|
assertThat(count).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v69_backfillSql_setsDatedRowsToDayPrecision() {
|
|
// Re-run the migration's backfill UPDATE on a freshly dated row to prove the rule.
|
|
UUID docId = createDocumentWithDate("1943-05-12");
|
|
|
|
jdbc.update(V69_BACKFILL_PRECISION_SQL);
|
|
|
|
String precision = jdbc.queryForObject(
|
|
"SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
|
|
assertThat(precision).isEqualTo("DAY");
|
|
}
|
|
|
|
@Test
|
|
void v69_backfillSql_setsUndatedRowsToUnknownPrecision() {
|
|
UUID docId = createDocument(); // no meta_date
|
|
|
|
jdbc.update(V69_BACKFILL_PRECISION_SQL);
|
|
|
|
String precision = jdbc.queryForObject(
|
|
"SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
|
|
assertThat(precision).isEqualTo("UNKNOWN");
|
|
}
|
|
|
|
// Mirrors the backfill UPDATE shipped in V69; idempotent for verification.
|
|
private static final String V69_BACKFILL_PRECISION_SQL = """
|
|
UPDATE documents
|
|
SET meta_date_precision = CASE WHEN meta_date IS NOT NULL THEN 'DAY' ELSE 'UNKNOWN' END
|
|
""";
|
|
|
|
@Test
|
|
void v69_precisionCheck_rejectsValueOutsideEnum() {
|
|
UUID docId = createDocument();
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("UPDATE documents SET meta_date_precision = 'BOGUS' WHERE id = ?", docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v69_metaDateEndCheck_rejectsNonNullEndWhenPrecisionNotRange() {
|
|
UUID docId = createDocumentWithDate("1943-05-12"); // precision DAY
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("UPDATE documents SET meta_date_end = '1943-06-01' WHERE id = ?", docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v69_metaDateEndCheck_allowsNonNullEndWhenPrecisionRange() {
|
|
UUID docId = createDocumentWithDate("1943-05-12");
|
|
|
|
int rows = jdbc.update(
|
|
"UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-06-01' WHERE id = ?",
|
|
docId);
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v69_metaDateEndCheck_allowsRangeWithNullEnd() {
|
|
// Loose semantics: the normalizer may emit an open-ended RANGE (start only).
|
|
UUID docId = createDocumentWithDate("1943-05-12");
|
|
|
|
int rows = jdbc.update(
|
|
"UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
void v69_metaDateEndCheck_allowsRangeWithBothEndpointsNull() {
|
|
// Fully-open RANGE: neither start (meta_date) nor end (meta_date_end) is set.
|
|
// Both CHECKs hold (end IS NULL passes chk_meta_date_end_only_for_range; both-null
|
|
// passes chk_meta_date_end_after_start), so the row survives. This locks the actual
|
|
// DB behavior so a future tightening to a biconditional rule is a deliberate change.
|
|
UUID docId = createDocument(); // null meta_date
|
|
|
|
int rows = jdbc.update(
|
|
"UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
|
|
assertThat(rows).isEqualTo(1);
|
|
|
|
Object metaDate = jdbc.queryForObject("SELECT meta_date FROM documents WHERE id = ?", Object.class, docId);
|
|
Object metaDateEnd = jdbc.queryForObject(
|
|
"SELECT meta_date_end FROM documents WHERE id = ?", Object.class, docId);
|
|
assertThat(metaDate).isNull();
|
|
assertThat(metaDateEnd).isNull();
|
|
}
|
|
|
|
@Test
|
|
void v69_rangeOrderCheck_rejectsEndBeforeStart() {
|
|
UUID docId = createDocumentWithDate("1943-05-12");
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update(
|
|
"UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-01-01' WHERE id = ?",
|
|
docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v69_metaDateRawCheck_rejectsOverlongText() {
|
|
UUID docId = createDocument();
|
|
String tooLong = "x".repeat(10001);
|
|
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("UPDATE documents SET meta_date_raw = ? WHERE id = ?", tooLong, docId)
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
}
|
|
|
|
@Test
|
|
void v69_senderTextAndReceiverText_storeRawAttribution() {
|
|
UUID docId = createDocument();
|
|
|
|
int rows = jdbc.update(
|
|
"UPDATE documents SET sender_text = 'Oma Anna', receiver_text = 'Tante Grete' WHERE id = ?",
|
|
docId);
|
|
assertThat(rows).isEqualTo(1);
|
|
}
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v69_personsSourceRef_uniqueIndexRejectsDuplicate() {
|
|
jdbc.update(
|
|
"INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'A', 'person:dup')");
|
|
try {
|
|
assertThatThrownBy(() ->
|
|
jdbc.update(
|
|
"INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'B', 'person:dup')")
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
} finally {
|
|
jdbc.update("DELETE FROM persons WHERE source_ref = 'person:dup'");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v69_personsSourceRef_allowsMultipleNulls() {
|
|
UUID a = createPerson("Null", "RefA");
|
|
UUID b = createPerson("Null", "RefB");
|
|
try {
|
|
String refA = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, a);
|
|
String refB = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, b);
|
|
assertThat(refA).isNull();
|
|
assertThat(refB).isNull();
|
|
} finally {
|
|
jdbc.update("DELETE FROM persons WHERE id IN (?, ?)", a, b);
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void v69_personsProvisional_defaultsToFalse() {
|
|
UUID id = createPerson("Provisional", "Default");
|
|
|
|
Boolean provisional = jdbc.queryForObject(
|
|
"SELECT provisional FROM persons WHERE id = ?", Boolean.class, id);
|
|
assertThat(provisional).isFalse();
|
|
}
|
|
|
|
@Test
|
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
|
void v69_tagSourceRef_uniqueIndexRejectsDuplicate() {
|
|
jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupA', 'tag:dup')");
|
|
try {
|
|
assertThatThrownBy(() ->
|
|
jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupB', 'tag:dup')")
|
|
).isInstanceOf(DataIntegrityViolationException.class);
|
|
} finally {
|
|
jdbc.update("DELETE FROM tag WHERE source_ref = 'tag:dup'");
|
|
}
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
|
|
private UUID createPerson(String firstName, String lastName) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("INSERT INTO persons (id, first_name, last_name) VALUES (?, ?, ?)", id, firstName, lastName);
|
|
return id;
|
|
}
|
|
|
|
private UUID createTag(String name) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("INSERT INTO tag (id, name) VALUES (?, ?)", id, name);
|
|
return id;
|
|
}
|
|
|
|
private UUID createDocument() {
|
|
Document doc = documentRepository.save(Document.builder()
|
|
.title("Testdokument")
|
|
.originalFilename("test.pdf")
|
|
.status(DocumentStatus.UPLOADED)
|
|
.build());
|
|
// Flush so the row is visible to subsequent JdbcTemplate queries within the same transaction
|
|
em.flush();
|
|
return doc.getId();
|
|
}
|
|
|
|
private UUID createDocumentWithDate(String isoDate) {
|
|
UUID id = createDocument();
|
|
jdbc.update("UPDATE documents SET meta_date = ?::date WHERE id = ?", isoDate, id);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertAnnotation(UUID docId) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO document_annotations
|
|
(id, document_id, page_number, x, y, width, height, color)
|
|
VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
|
|
""", id, docId);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertBlock(UUID docId, UUID annotationId) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO transcription_blocks
|
|
(id, annotation_id, document_id, text, sort_order)
|
|
VALUES (?, ?, ?, '', 0)
|
|
""", id, annotationId, docId);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertUser(String email) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO app_users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
|
VALUES (?, ?, 'hash', true, false, false)
|
|
""", id, email);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO document_comments
|
|
(id, document_id, block_id, annotation_id, author_name, content)
|
|
VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
|
|
""", id, docId, blockId);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO document_comments
|
|
(id, document_id, block_id, annotation_id, author_name, content)
|
|
VALUES (?, ?, ?, ?, 'Tester', 'Hi')
|
|
""", id, docId, blockId, annotationId);
|
|
return id;
|
|
}
|
|
|
|
private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("""
|
|
INSERT INTO notifications
|
|
(id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
|
|
VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
|
|
""", id, recipientId, docId, commentId);
|
|
return id;
|
|
}
|
|
|
|
private UUID createUserGroup(String name) {
|
|
UUID id = UUID.randomUUID();
|
|
jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
|
|
return id;
|
|
}
|
|
}
|