diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 66b10da0..9db6e5d2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -68,6 +68,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*; public class DocumentService { private final DocumentRepository documentRepository; + private final DocumentTitleFactory documentTitleFactory; private final PersonService personService; private final FileService fileService; private final TagService tagService; @@ -379,8 +380,14 @@ public class DocumentService { DocumentStatus statusBefore = doc.getStatus(); + // Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state + // BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision + // skips nulls, so the old state must be read first. The submitted title is the catalog + // auto-title iff it equals this; only then does it follow date/location forward. + String autoTitleBefore = documentTitleFactory.build(doc); + // 1. Einfache Felder Update - doc.setTitle(dto.getTitle()); + doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto)); doc.setDocumentDate(dto.getDocumentDate()); applyDatePrecision(doc, dto); validateDateRange(doc); // guard before any save (updateDocumentTags below persists) @@ -452,6 +459,47 @@ public class DocumentService { return saved; } + /** + * Decides the title to persist on an edit (#726). The submitted title is the catalog + * auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact + * comparison with no heuristic, relying on the edit form round-tripping the stored title + * verbatim when untouched. A machine title is rebuilt from the new state so a corrected + * date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank + * submission is never persisted (title is always present) — it falls back to the rebuilt + * auto-title, which always carries at least the index. + */ + private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) { + if (submitted == null || submitted.isBlank()) { + return documentTitleFactory.build(projectedState(doc, dto)); + } + if (!Objects.equals(submitted, autoBefore)) { + return submitted; + } + return documentTitleFactory.build(projectedState(doc, dto)); + } + + /** + * The document state the regenerated title is built from, mirroring {@code updateDocument}'s + * setter asymmetry exactly: {@code documentDate}/{@code location} are overwritten from the DTO + * (a null value clears the field), while precision/end/raw are taken from the DTO only when + * non-null and otherwise kept from the stored entity (see {@link #applyDatePrecision}). The + * index ({@code originalFilename}) is never touched by a metadata edit. A mismatch here would + * silently produce a wrong label, so it is kept lock-step with the real setters. + */ + private Document projectedState(Document doc, DocumentUpdateDTO dto) { + return Document.builder() + .originalFilename(doc.getOriginalFilename()) + .documentDate(dto.getDocumentDate()) + .location(dto.getLocation()) + .metaDatePrecision(dto.getMetaDatePrecision() != null + ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision()) + .metaDateEnd(dto.getMetaDateEnd() != null + ? dto.getMetaDateEnd() : doc.getMetaDateEnd()) + .metaDateRaw(dto.getMetaDateRaw() != null + ? dto.getMetaDateRaw() : doc.getMetaDateRaw()) + .build(); + } + /** * Applies the three date-precision fields only when the DTO carries them. * A null field means "not submitted" — overwriting the stored value with null diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java index e25b3ce8..e2f514cf 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentTitleFactory.java @@ -23,7 +23,9 @@ public class DocumentTitleFactory { * location segment is dropped when blank. */ public String build(Document doc) { - StringBuilder title = new StringBuilder(doc.getOriginalFilename()); + // originalFilename is NOT NULL in production; guard only so a synthetic/partial entity + // never trips StringBuilder(null) with an opaque NPE. + StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename()); if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) { title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate( doc.getDocumentDate(), doc.getMetaDatePrecision(), diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index a1ff86bf..76c95dca 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; @@ -74,6 +75,9 @@ class DocumentServiceTest { @Mock AuditLogQueryService auditLogQueryService; @Mock TranscriptionBlockQueryService transcriptionBlockQueryService; @Mock ThumbnailAsyncRunner thumbnailAsyncRunner; + // Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the + // shared composition rather than a stub — the #726 single source of truth. + @Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory(); @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -228,6 +232,179 @@ class DocumentServiceTest { assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916"); } + // ─── updateDocument save-time auto-title regeneration (#726) ────────────── + // + // Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted + // title equals what the factory builds from the CURRENTLY-persisted state. The edit form + // round-trips the stored title verbatim when untouched, so an equal submission means the + // user did not type over it. makeStored() seeds index/date/precision/location and sets the + // stored title to the matching auto-title, mirroring a freshly-imported row. + + private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) { + Document doc = Document.builder() + .id(UUID.randomUUID()) + .originalFilename(index) + .documentDate(date) + .metaDatePrecision(precision) + .location(location) + .receivers(new HashSet<>()) + .tags(new HashSet<>()) + .build(); + doc.setTitle(documentTitleFactory.build(doc)); + return doc; + } + + /** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */ + private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date, + DatePrecision precision, String location) { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle(submittedTitle); + dto.setDocumentDate(date); + dto.setMetaDatePrecision(precision); + dto.setLocation(location); + return dto; + } + + private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception { + when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored)); + when(documentRepository.save(any())).thenReturn(stored); + documentService.updateDocument(stored.getId(), dto, null, null); + return stored; + } + + @Test + void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin"); + // title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928 + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin"); + } + + @Test + void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null); + stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title + DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter"); + } + + @Test + void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin"); + // user changed the date AND typed a new title in the same save + DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige"); + } + + @Test + void updateDocument_regeneratesWithNewDateAndLocation() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin"); + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München"); + } + + @Test + void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + // location cleared (null), title untouched + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928"); + } + + @Test + void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin"); + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).doesNotContain("2028"); + } + + @Test + void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null); + // stored auto-title "C-0029 – 1928"; set a full day at DAY precision + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928"); + } + + @Test + void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception { + Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null); + // stored auto-title is just "C-0029"; add a 1928 YEAR date + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928"); + } + + @Test + void updateDocument_roundTripsSeasonLabel() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null); + stored.setMetaDateRaw("Frühling 1943"); + stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943" + DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null); + dto.setMetaDateRaw("Frühling 1943"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943"); + } + + @Test + void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isNotBlank(); + } + + @Test + void updateDocument_treatsFileReplacedDoc_asManual() throws Exception { + // originalFilename was reassigned by an earlier file-replace, so the stored title (built + // at import from the old index) no longer matches build(currentState) → treated as manual. + Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…) + DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin"); + } + + @Test + void updateDocument_idempotent_whenNothingChanges() throws Exception { + Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + String before = stored.getTitle(); + DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin"); + + runUpdate(stored, dto); + + assertThat(stored.getTitle()).isEqualTo(before); + } + // ─── updateDocument date-range validation (#678) ────────────────────────── /** Builds a stored doc ready for an updateDocument call (collections initialised). */