test(importing): Testcontainers idempotency + human-edit-preserve IT
Full-stack integration test on real postgres:16-alpine (the UNIQUE(source_ref) + upsert-on-conflict only exist in real Postgres, never H2). Writes a synthetic-but-real four-artifact set, runs the import twice, and asserts person/tag/document counts are identical on re-import (no duplicates), plus the Resolved-decision-#1 precedence: a person field edited in-app survives a re-import. Also asserts register-first sender linkage with raw-text retention and the provisional contract. Fixes a re-import bug the IT surfaced: load() is now @Transactional so an existing document's lazy receivers collection initialises within the session (the previous self-invoked @Transactional on the per-row method never opened a transaction). PersonTreeImporter owns its ObjectMapper rather than depending on the web bean, which is absent in a NONE web environment. Refs #669 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,10 @@ public class DocumentImporter {
|
||||
/** Outcome of loading the document sheet: processed count + per-file skips. */
|
||||
public record LoadResult(int processed, List<ImportStatus.SkippedFile> skippedFiles) {}
|
||||
|
||||
// One transaction for the whole sheet keeps the Hibernate session open so an existing
|
||||
// document's lazy receivers collection initialises during an idempotent re-import.
|
||||
// Invoked cross-bean from the orchestrator, so the @Transactional proxy applies.
|
||||
@Transactional
|
||||
public LoadResult load(File artifact) {
|
||||
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
||||
int processed = 0;
|
||||
@@ -116,8 +120,7 @@ public class DocumentImporter {
|
||||
return persist(row, index, resolved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected Optional<ImportStatus.SkipReason> persist(CanonicalSheetReader.Row row, String index, Optional<File> file) {
|
||||
private Optional<ImportStatus.SkipReason> persist(CanonicalSheetReader.Row row, String index, Optional<File> file) {
|
||||
Document existing = documentService.findByOriginalFilename(index).orElse(null);
|
||||
if (existing != null && existing.getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||
return Optional.of(ImportStatus.SkipReason.ALREADY_EXISTS);
|
||||
|
||||
@@ -34,9 +34,12 @@ import java.util.UUID;
|
||||
@Slf4j
|
||||
public class PersonTreeImporter {
|
||||
|
||||
// The tree JSON is a local implementation detail, not a shared API payload, so the
|
||||
// importer owns its own mapper rather than depending on the web ObjectMapper bean.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final PersonService personService;
|
||||
private final RelationshipService relationshipService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public int load(File artifact) {
|
||||
JsonNode root = readTree(artifact);
|
||||
@@ -49,7 +52,7 @@ public class PersonTreeImporter {
|
||||
|
||||
private JsonNode readTree(File artifact) {
|
||||
try {
|
||||
return objectMapper.readTree(artifact);
|
||||
return OBJECT_MAPPER.readTree(artifact);
|
||||
} catch (Exception e) {
|
||||
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||
"Unreadable canonical artifact: " + artifact.getName());
|
||||
|
||||
Reference in New Issue
Block a user