test(search): add DocumentSearchEnrichmentTest for findEnrichmentData native query

Tests lateral join best-block selection, chr(1)/chr(2) headline delimiters,
sender/receiver/tag match flags, and null cases for missing relations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 17:40:47 +02:00
parent 003d68ed21
commit 8526e6c0a1
2 changed files with 354 additions and 0 deletions

View File

@@ -89,4 +89,55 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
""")
List<UUID> findRankedIdsByFts(@Param("query") String query);
/**
* Returns match-enrichment data for a set of documents identified by their IDs.
* Each row contains (in column order):
* <ol>
* <li>UUID — document id</li>
* <li>String — title headline with \x01/\x02 delimiters around matched terms</li>
* <li>String — best-ranked matching transcription block text, or null</li>
* <li>Boolean — whether the sender's name matched the query</li>
* <li>String — comma-separated matched receiver UUIDs, or null</li>
* <li>String — comma-separated matched tag UUIDs, or null</li>
* </ol>
* Short-circuit before calling this method when {@code ids} is empty or {@code query} is blank.
*/
@Query(nativeQuery = true, value = """
SELECT
d.id,
ts_headline('german', d.title, websearch_to_tsquery('german', :query),
'StartSel=' || chr(1) || ',StopSel=' || chr(2) || ',HighlightAll=true')
AS title_headline,
best_block.text AS transcription_snippet,
(s.id IS NOT NULL AND
to_tsvector('german', COALESCE(s.first_name, '') || ' ' || COALESCE(s.last_name, ''))
@@ websearch_to_tsquery('german', :query))
AS sender_matched,
(SELECT string_agg(r.id::text, ',')
FROM document_receivers dr
JOIN persons r ON r.id = dr.person_id
WHERE dr.document_id = d.id
AND to_tsvector('german', COALESCE(r.first_name, '') || ' ' || r.last_name)
@@ websearch_to_tsquery('german', :query)
) AS matched_receiver_ids,
(SELECT string_agg(t.id::text, ',')
FROM document_tags dt
JOIN tag t ON t.id = dt.tag_id
WHERE dt.document_id = d.id
AND to_tsvector('german', t.name) @@ websearch_to_tsquery('german', :query)
) AS matched_tag_ids
FROM documents d
LEFT JOIN persons s ON s.id = d.sender_id
LEFT JOIN LATERAL (
SELECT tb.text
FROM transcription_blocks tb
WHERE tb.document_id = d.id
AND to_tsvector('german', tb.text) @@ websearch_to_tsquery('german', :query)
ORDER BY ts_rank(to_tsvector('german', tb.text), websearch_to_tsquery('german', :query)) DESC
LIMIT 1
) best_block ON true
WHERE d.id IN :ids
""")
List<Object[]> findEnrichmentData(@Param("ids") Collection<UUID> ids, @Param("query") String query);
}

View File

@@ -0,0 +1,303 @@
package org.raddatz.familienarchiv.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
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 java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentSearchEnrichmentTest {
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired TagRepository tagRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired EntityManager em;
@BeforeEach
void setUp() {
blockRepository.deleteAll();
documentRepository.deleteAll();
personRepository.deleteAll();
tagRepository.deleteAll();
}
// ─── Lateral join: best transcription snippet ──────────────────────────────
@Test
void lateral_join_returns_highest_ranked_transcription_block() {
Document doc = documentRepository.saveAndFlush(document("Brief an Anna"));
UUID annotId = annotation(doc.getId());
// Three blocks — the one with two occurrences has highest rank
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Das Wetter war schön", 0));
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Brief Brief Brief", 1)); // highest rank for "Brief"
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Ein Brief liegt vor", 2)); // one occurrence
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String snippet = (String) rows.get(0)[2];
assertThat(snippet).isEqualTo("Brief Brief Brief");
}
@Test
void document_with_no_transcription_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Foto ohne Text"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Foto");
assertThat(rows).hasSize(1);
Object snippet = rows.get(0)[2];
assertThat(snippet).isNull();
}
@Test
void document_with_non_matching_blocks_has_null_snippet() {
Document doc = documentRepository.saveAndFlush(document("Dok"));
UUID annotId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotId, "Kein Match hier", 0));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[2]).isNull();
}
// ─── Title headline: delimiter-based offset detection ─────────────────────
@Test
void title_headline_contains_delimiters_when_title_matches() {
Document doc = documentRepository.saveAndFlush(document("Brief an die Familie"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
// chr(1) marks the start of the highlighted term
assertThat(headline).contains("\u0001");
assertThat(headline).contains("\u0002");
}
@Test
void title_headline_has_no_delimiters_when_title_does_not_match() {
Document doc = documentRepository.saveAndFlush(document("Familienfoto"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).doesNotContain("\u0001");
assertThat(headline).doesNotContain("\u0002");
}
@Test
void title_headline_matches_stemmed_form() {
// "Brief" (singular, query) should match "Briefe" (plural, in title) via German FTS stemming.
// Both reduce to the stem "brief" under the Snowball German algorithm — verified by the
// existing should_find_document_by_stemmed_inflected_form test in DocumentFtsTest.
Document doc = documentRepository.saveAndFlush(document("Alte Briefe aus Berlin"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
String headline = (String) rows.get(0)[1];
assertThat(headline).contains("\u0001");
}
// ─── Sender match ──────────────────────────────────────────────────────────
@Test
void sender_matched_is_true_when_sender_last_name_matches_query() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isTrue();
}
@Test
void sender_matched_is_false_when_sender_name_does_not_match() {
Person sender = personRepository.saveAndFlush(
Person.builder().firstName("Walter").lastName("Raddatz").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
@Test
void sender_matched_is_false_when_document_has_no_sender() {
Document doc = documentRepository.saveAndFlush(document("Brief von unbekannt"));
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
Boolean senderMatched = (Boolean) rows.get(0)[3];
assertThat(senderMatched).isFalse();
}
// ─── Receiver match ────────────────────────────────────────────────────────
@Test
void matched_receiver_ids_contains_uuid_of_matching_receiver() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Schmidt");
assertThat(rows).hasSize(1);
String receiverIds = (String) rows.get(0)[4];
assertThat(receiverIds).contains(receiver.getId().toString());
}
@Test
void matched_receiver_ids_is_null_when_no_receiver_matches() {
Person receiver = personRepository.saveAndFlush(
Person.builder().firstName("Anna").lastName("Schmidt").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(Set.of(receiver))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Raddatz");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[4]).isNull();
}
// ─── Tag match ─────────────────────────────────────────────────────────────
@Test
void matched_tag_ids_contains_uuid_of_matching_tag() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Familiengeschichte");
assertThat(rows).hasSize(1);
String tagIds = (String) rows.get(0)[5];
assertThat(tagIds).contains(tag.getId().toString());
}
@Test
void matched_tag_ids_is_null_when_no_tag_matches() {
Tag tag = tagRepository.saveAndFlush(Tag.builder().name("Familiengeschichte").build());
Document doc = documentRepository.saveAndFlush(Document.builder()
.title("Dokument")
.originalFilename("dok.pdf")
.status(DocumentStatus.UPLOADED)
.tags(Set.of(tag))
.build());
em.flush();
em.clear();
List<Object[]> rows = documentRepository.findEnrichmentData(List.of(doc.getId()), "Brief");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)[5]).isNull();
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private Document document(String title) {
return Document.builder()
.title(title)
.originalFilename(title.replace(" ", "_") + ".pdf")
.status(DocumentStatus.UPLOADED)
.build();
}
private UUID annotation(UUID documentId) {
DocumentAnnotation ann = annotationRepository.save(DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(1)
.x(0.1).y(0.2).width(0.3).height(0.4)
.color("#00C7B1")
.build());
em.flush();
return ann.getId();
}
private TranscriptionBlock block(UUID documentId, UUID annotationId, String text, int order) {
return TranscriptionBlock.builder()
.documentId(documentId)
.annotationId(annotationId)
.text(text)
.sortOrder(order)
.build();
}
}