From 5d2d85aa8cdf6d5bf603743d7c4105d2d187bbc1 Mon Sep 17 00:00:00 2001 From: TO Date: Fri, 24 Jul 2026 02:28:22 +0200 Subject: [PATCH] fix(rental): complete condition report invariants (#7) --- backend/Dockerfile | 40 +++++++-- .../RentalApplicationConfiguration.java | 5 +- ...temPropertyConditionAttachmentStorage.java | 23 ++++- ...ongoPropertyConditionReportRepository.java | 3 + .../PropertyConditionDocumentService.java | 1 - .../PropertyConditionReportService.java | 80 ++++++++++++----- .../condition/PropertyConditionReport.java | 13 ++- .../PropertyConditionReportRepository.java | 1 + .../rental/property-condition-report.html | 2 +- ...ropertyConditionAttachmentStorageTest.java | 19 ++-- ...ditionPhotoNormalizerDockerAcceptance.java | 79 ++++++++++++++++ .../out/mongodb/RentalMongoTransactionIT.java | 60 +++++++++++++ ...PropertyConditionReportRepositoryTest.java | 14 +++ ...afPropertyConditionReportRendererTest.java | 12 ++- .../PropertyConditionDocumentServiceTest.java | 49 +++++++++- .../PropertyConditionReportServiceTest.java | 85 ++++++++++++++++-- .../PropertyConditionReportTest.java | 33 +++++++ .../condition-report-editor.ts | 31 +++++-- .../condition-report-evidence-section.html | 2 +- .../condition-report-evidence-section.ts | 18 +++- .../condition-report-page.html | 31 +++++-- .../condition-report-page.spec.ts | 90 ++++++++++++++++++- .../condition-report-page.ts | 46 +++++++--- 23 files changed, 652 insertions(+), 85 deletions(-) create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java diff --git a/backend/Dockerfile b/backend/Dockerfile index 08b9389..0d687d5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,17 +6,25 @@ RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests -# Run stage -FROM docker.io/library/eclipse-temurin:25-jre-alpine +# Runtime shared by acceptance and the final image +FROM docker.io/library/eclipse-temurin:25-jre-alpine AS runtime WORKDIR /app RUN apk add --no-cache imagemagick imagemagick-heic imagemagick-jpeg imagemagick-webp \ - && apk add --no-cache --virtual .fixture-tools exiftool \ && magick -version \ && magick identify -list format | awk '$1 ~ /^JPEG/ && $3 ~ /r/ { jpeg=1 } $1 ~ /^PNG/ && $3 ~ /r/ { png=1 } $1 ~ /^WEBP/ && $3 ~ /rw/ { webp=1 } $1 ~ /^HEIC/ && $3 ~ /r/ { heic=1 } END { exit !(jpeg && png && webp && heic) }' \ && addgroup -S lmnp \ && adduser -S -G lmnp lmnp \ - && mkdir -p /data/attachments /tmp/lmnp-condition-photos/build-check \ - && chown -R lmnp:lmnp /data/attachments /tmp/lmnp-condition-photos \ + && mkdir -p /data/attachments /tmp/lmnp-condition-photos \ + && chown -R lmnp:lmnp /data/attachments /tmp/lmnp-condition-photos +COPY src/main/resources/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml + +# Acceptance stage: fixtures never enter the final image's layer history +FROM runtime AS acceptance +COPY --from=build /app/target/backend-0.0.1-SNAPSHOT.jar app.jar +COPY --from=build /app/target/test-classes/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.class /tmp/docker-acceptance/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.class +RUN apk add --no-cache --virtual .fixture-tools exiftool unzip \ + && mv /etc/ImageMagick-7/policy.xml /tmp/imagemagick-policy.xml \ + && mkdir -p /tmp/lmnp-condition-photos/build-check \ && magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/top-left.jpg \ && magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/top-right.jpg \ && magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/bottom-right.jpg \ @@ -36,8 +44,9 @@ RUN apk add --no-cache imagemagick imagemagick-heic imagemagick-jpeg imagemagick && magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/small.png \ && magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/sample.webp \ && magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/sample.heic \ - && apk del .fixture-tools -COPY src/main/resources/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml + && magick -size 10x10 xc:red -size 10x10 xc:blue -delay 10 -loop 0 /tmp/lmnp-condition-photos/build-check/animated.webp \ + && magick -size 10x10 xc:red -size 10x10 xc:blue /tmp/lmnp-condition-photos/build-check/multipage.miff \ + && mv /tmp/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml RUN set -eu; \ for orientation in top-left top-right bottom-right bottom-left; do \ magick "/tmp/lmnp-condition-photos/build-check/$orientation.jpg[0]" -auto-orient -resize '1920x1080>' -strip -orient Undefined -quality 75 -define webp:lossless=false "webp:/tmp/lmnp-condition-photos/build-check/$orientation.webp"; \ @@ -58,9 +67,24 @@ RUN set -eu; \ magick '/tmp/lmnp-condition-photos/build-check/small.png[0]' -auto-orient -resize '1920x1080>' -strip -orient Undefined -quality 75 -define webp:lossless=false webp:/tmp/lmnp-condition-photos/build-check/small.webp; \ test "$(magick identify -quiet -format '%m|%wx%h|%[orientation]' /tmp/lmnp-condition-photos/build-check/small.webp)" = 'WEBP|800x600|Undefined'; \ ! magick identify -verbose /tmp/lmnp-condition-photos/build-check/small.webp | grep -Eiq 'exif:|xmp:|profile-|comment:'; \ + printf invalid > /tmp/lmnp-condition-photos/build-check/invalid.upload; \ + ! magick identify -quiet /etc/passwd; \ + ! magick identify -quiet -; \ magick identify -list policy | grep -q '/tmp/lmnp-condition-photos'; \ - rm -rf /tmp/lmnp-condition-photos/build-check + for limit in 'Memory: 256MiB' 'Map: 512MiB' 'Disk: 1GiB' 'Thread: 2' 'Time: 30 seconds'; do magick identify -list resource | grep -Fq "$limit"; done; \ + mkdir /tmp/docker-acceptance/classes; \ + unzip -q /app/app.jar -d /tmp/docker-acceptance/classes; \ + java -cp '/tmp/docker-acceptance:/tmp/docker-acceptance/classes/BOOT-INF/classes:/tmp/docker-acceptance/classes/BOOT-INF/lib/*' fr.nessar.lmnporganizer.rental.adapter.out.image.ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance; \ + rm -rf /tmp/docker-acceptance /tmp/lmnp-condition-photos/build-check; \ + apk del .fixture-tools; \ + test ! -e /tmp/lmnp-condition-photos/build-check; \ + touch /tmp/acceptance-passed + +# Final image +FROM runtime COPY --from=build /app/target/backend-0.0.1-SNAPSHOT.jar app.jar +COPY --from=acceptance /tmp/acceptance-passed /tmp/acceptance-passed +RUN test -f /tmp/acceptance-passed && rm /tmp/acceptance-passed USER lmnp EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java index 71d64bf..86be53d 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java @@ -95,13 +95,16 @@ public class RentalApplicationConfiguration { PropertyConditionReportService propertyConditionReportService(PropertyConditionReportRepository reports, LeaseRepository leases, LeaseDocumentRevisionRepository revisions, + FurnitureInventoryRepository inventories, LandlordRepository landlords, TenantRepository tenants, PropertyConditionPhotoNormalizer normalizer, PropertyConditionAttachmentStorage storage, + TransactionRunner transactions, IdentifierGenerator identifiers, Clock rentalClock) { - return new PropertyConditionReportService(reports, leases, revisions, landlords, tenants, normalizer, storage, identifiers, + return new PropertyConditionReportService(reports, leases, revisions, inventories, landlords, tenants, + normalizer, storage, transactions, identifiers, rentalClock); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java index 0e84cfd..46f0e7e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java @@ -3,9 +3,11 @@ package fr.nessar.lmnporganizer.rental.adapter.out.filesystem; import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties; import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage; import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.io.IOException; @@ -18,6 +20,7 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.Comparator; import java.util.List; +import java.util.function.Predicate; @Component public final class FileSystemPropertyConditionAttachmentStorage implements PropertyConditionAttachmentStorage { @@ -27,10 +30,18 @@ public final class FileSystemPropertyConditionAttachmentStorage implements Prope private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemPropertyConditionAttachmentStorage.class); private final Path root; private final Path staging; + private final Predicate referenced; - public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties) { + @Autowired + public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties, + PropertyConditionReportRepository reports) { + this(properties, reports::isPhotoStorageKeyReferenced); + } + + FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties, Predicate referenced) { root = properties.attachments().toAbsolutePath().normalize().resolve("condition-reports"); staging = root.resolve(".staging"); + this.referenced = referenced; recoverInterruptedDeletions(); } @@ -131,13 +142,17 @@ public final class FileSystemPropertyConditionAttachmentStorage implements Prope if (Files.isRegularFile(committed, LinkOption.NOFOLLOW_LINKS)) { discardArtifacts(deletionDirectory); } else { - Path active = active(staging.relativize(deletionDirectory).toString()); - if (!Files.exists(active, LinkOption.NOFOLLOW_LINKS)) { + String storageKey = staging.relativize(deletionDirectory).toString(); + Path active = active(storageKey); + boolean stillReferenced = referenced.test(storageKey); + if (stillReferenced && !Files.exists(active, LinkOption.NOFOLLOW_LINKS)) { Files.createDirectories(active.getParent()); Files.move(firstExisting(deletionDirectory.resolve(STAGED), deletionDirectory.resolve(BACKUP)), active, StandardCopyOption.ATOMIC_MOVE); - } else if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) { + } else if (stillReferenced && !Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Recovered condition attachment path is not a regular file."); + } else if (!stillReferenced) { + Files.deleteIfExists(active); } discardArtifacts(deletionDirectory); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java index 9a01825..d48a24e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java @@ -26,6 +26,9 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition @Override public Optional find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) { return Optional.ofNullable(mongo.findOne(query(landlord, lease, type), ReportDocument.class)).map(this::domain); } + @Override public boolean isPhotoStorageKeyReferenced(String storageKey) { + return mongo.exists(Query.query(Criteria.where("photos.storageKey").is(storageKey)), ReportDocument.class); + } @Override public PropertyConditionReport save(PropertyConditionReport report) { ReportDocument document = document(report); return domain(LandlordScopedMongoWriter.save(mongo, document, document.id(), document.landlordId(), report.version(), document::withVersion)); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java index e9e92af..440c23e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java @@ -165,7 +165,6 @@ public final class PropertyConditionDocumentService { PropertyConditionReportFinalization finalization = report.finalization(); add(files, "scan-signe.pdf", finalization.signedStorageKey(), finalization.signedSha256()); addPhotos(files, report.photos()); - addSupplements(files, report.supplements()); if (report.entrySnapshot() != null) { addPhotos(files, report.entrySnapshot().photos()); addSupplements(files, report.entrySnapshot().supplements()); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java index 32fc2ab..72b888e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java @@ -3,9 +3,12 @@ package fr.nessar.lmnporganizer.rental.application.condition; import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator; import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage; import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer; +import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner; import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange; import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound; import fr.nessar.lmnporganizer.rental.domain.condition.*; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository; @@ -28,26 +31,33 @@ public final class PropertyConditionReportService { private final PropertyConditionReportRepository reports; private final LeaseRepository leases; private final LeaseDocumentRevisionRepository revisions; + private final FurnitureInventoryRepository inventories; private final LandlordRepository landlords; private final TenantRepository tenants; private final PropertyConditionPhotoNormalizer normalizer; private final PropertyConditionAttachmentStorage storage; + private final TransactionRunner transactions; private final IdentifierGenerator identifiers; private final Clock clock; public PropertyConditionReportService(PropertyConditionReportRepository reports, LeaseRepository leases, - LeaseDocumentRevisionRepository revisions, LandlordRepository landlords, TenantRepository tenants, + LeaseDocumentRevisionRepository revisions, FurnitureInventoryRepository inventories, + LandlordRepository landlords, TenantRepository tenants, PropertyConditionPhotoNormalizer normalizer, - PropertyConditionAttachmentStorage storage, IdentifierGenerator identifiers, Clock clock) { - this.reports = reports; this.leases = leases; this.revisions = revisions; this.landlords = landlords; this.tenants = tenants; - this.normalizer = normalizer; this.storage = storage; this.identifiers = identifiers; + PropertyConditionAttachmentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers, + Clock clock) { + this.reports = reports; this.leases = leases; this.revisions = revisions; this.inventories = inventories; + this.landlords = landlords; this.tenants = tenants; + this.normalizer = normalizer; this.storage = storage; this.transactions = transactions; this.identifiers = identifiers; this.clock = clock; } public PropertyConditionReport get(LandlordId landlord, LeaseId lease, PropertyConditionReportType type, String timeZone) { lease(landlord, lease); return reports.find(landlord, lease, type).map(report -> refreshEntryDraft(report, timeZone)) - .orElseGet(() -> createDraft(landlord, lease, type, timeZone)); + .orElseGet(() -> type == PropertyConditionReportType.EXIT + ? transactions.run(() -> createExitDraft(landlord, lease, timeZone)) + : createEntryDraft(landlord, lease, timeZone)); } public PropertyConditionReport replace(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, @@ -151,9 +161,9 @@ public final class PropertyConditionReportService { photos.add(new ConditionPhoto(reference, source.zoneId(), storageKey, sha256(content), normalized.width(), normalized.height(), author, Instant.now(clock))); } - report.appendSupplement(new PropertyConditionReportSupplement(identifiers.next(), type, pdf, photos, - author, Instant.now(clock), requestedOn, heatingPeriodStartedOn, acceptedAdditions, audit)); - return reports.save(report); + PropertyConditionReportSupplement supplement = new PropertyConditionReportSupplement(identifiers.next(), type, pdf, + photos, author, Instant.now(clock), requestedOn, heatingPeriodStartedOn, acceptedAdditions, audit); + return transactions.run(() -> appendSupplement(landlord, leaseId, supplement)); } catch (RuntimeException failure) { storedKeys.forEach(key -> discard(key, failure)); throw failure; @@ -164,28 +174,44 @@ public final class PropertyConditionReportService { throw new IllegalStateException("Le scan signé et l'attestation de correspondance sont obligatoires."); } - private PropertyConditionReport createDraft(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, String timeZone) { + private PropertyConditionReport createEntryDraft(LandlordId landlord, LeaseId leaseId, String timeZone) { Lease lease = lease(landlord, leaseId); ZoneId zone = zone(timeZone); LocalDate today = LocalDate.now(clock.withZone(zone)); - if (type == PropertyConditionReportType.ENTRY) { - LeaseDocumentRevision revision = revisions.findCurrent(landlord, leaseId).orElseThrow(() -> - new IllegalStateException("Une révision du bail est requise avant de préparer l'état des lieux d'entrée.")); - PropertyConditionReport draft = PropertyConditionReport.draft(new PropertyConditionReportId(identifiers.next()), - landlord, leaseId, type, today, zone.getId(), revision.number(), revision.sourceFingerprint()); - prefillEntryDraft(draft, lease, revision, landlord); - return reports.save(draft); + LeaseDocumentRevision revision = currentEntryRevision(landlord, leaseId); + PropertyConditionReport draft = PropertyConditionReport.draft(new PropertyConditionReportId(identifiers.next()), + landlord, leaseId, PropertyConditionReportType.ENTRY, today, zone.getId(), revision.number(), revision.sourceFingerprint()); + prefillEntryDraft(draft, lease, revision, landlord); + return reports.save(draft); + } + private PropertyConditionReport createExitDraft(LandlordId landlord, LeaseId leaseId, String timeZone) { + return reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).orElseGet(() -> { + PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY) + .orElseThrow(() -> new ResourceNotFound("Entry property condition report")); + if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé avant la sortie."); + ZoneId zone = zone(timeZone); + PropertyConditionReport exit = PropertyConditionReport.draftExit(new PropertyConditionReportId(identifiers.next()), + entry, LocalDate.now(clock.withZone(zone)), zone.getId()); + reports.save(entry); + return reports.save(exit); + }); + } + private PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, + PropertyConditionReportSupplement supplement) { + if (reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).isPresent()) { + throw new IllegalStateException("Un complément ne peut plus être ajouté après la création de la sortie."); } - PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY).orElseThrow(() -> new ResourceNotFound("Entry property condition report")); - if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé avant la sortie."); - return reports.save(PropertyConditionReport.draftExit(new PropertyConditionReportId(identifiers.next()), entry, today, zone.getId())); + PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY) + .orElseThrow(() -> new ResourceNotFound("Entry property condition report")); + if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé."); + entry.appendSupplement(supplement); + return reports.save(entry); } private Lease lease(LandlordId landlord, LeaseId leaseId) { return leases.find(landlord, leaseId).orElseThrow(() -> new ResourceNotFound("Lease")); } private PropertyConditionReport refreshEntryDraft(PropertyConditionReport report, String timeZone) { if (report.type() != PropertyConditionReportType.ENTRY || report.status() == PropertyConditionReportStatus.FINALIZED) { return report; } - LeaseDocumentRevision current = revisions.findCurrent(report.landlordId(), report.leaseId()) - .orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable.")); - if (current != null && report.referencesLeaseRevision(current.number(), current.sourceFingerprint())) { + LeaseDocumentRevision current = currentEntryRevision(report.landlordId(), report.leaseId()); + if (report.referencesLeaseRevision(current.number(), current.sourceFingerprint())) { return report; } if (report.status() == PropertyConditionReportStatus.DRAFT) report.markObsolete(); @@ -195,6 +221,16 @@ public final class PropertyConditionReportService { prefillEntryDraft(report, lease, current, report.landlordId()); return reports.save(report); } + private LeaseDocumentRevision currentEntryRevision(LandlordId landlord, LeaseId leaseId) { + LeaseDocumentRevision revision = revisions.findCurrent(landlord, leaseId).orElseThrow(() -> + new IllegalStateException("Une révision du bail est requise avant de préparer l'état des lieux d'entrée.")); + FurnitureInventorySnapshot inventory = inventories.find(landlord, leaseId).map(FurnitureInventorySnapshot::from) + .orElseThrow(() -> new IllegalStateException("L'inventaire du bail est introuvable.")); + if (!revision.furnitureInventory().equals(inventory)) { + throw new IllegalStateException("La révision du bail ne correspond plus à l'inventaire."); + } + return revision; + } private void prefillEntryDraft(PropertyConditionReport draft, Lease lease, LeaseDocumentRevision revision, LandlordId landlord) { draft.replaceParticipants(participants(lease, landlord)); List zones = PropertyConditionDraftTemplate.requiredZones(revision); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java index 1d3699a..a3cadba 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java @@ -324,6 +324,7 @@ public final class PropertyConditionReport { requireSameIds(zones, observations, ConditionZone::id, ConditionObservation::subjectId, "Condition observations"); validateZoneEvidence(); + validateMeterPhotoReferences(); if (type == PropertyConditionReportType.EXIT) validateExitAgainstEntrySnapshot(); } @@ -332,8 +333,10 @@ public final class PropertyConditionReport { if (participants.stream().noneMatch(participant -> participant.partyId().equals(attestation.partyId()))) { throw new IllegalArgumentException("Delivery attestation party is not a report participant."); } + if (deliveryAttestations.stream().anyMatch(value -> value.partyId().equals(attestation.partyId()))) { + throw new IllegalStateException("Delivery attestations are append-only."); + } List values = new ArrayList<>(deliveryAttestations); - values.removeIf(value -> value.partyId().equals(attestation.partyId())); values.add(attestation); deliveryAttestations = unique(values, PropertyConditionReportDeliveryAttestation::partyId, "Report delivery attestations"); @@ -363,6 +366,14 @@ public final class PropertyConditionReport { } } + private void validateMeterPhotoReferences() { + Set photoReferences = byId(photos, ConditionPhoto::reference).keySet(); + if (meterReadings.stream().map(MeterReading::photoId).filter(reference -> !reference.isEmpty()) + .anyMatch(reference -> !photoReferences.contains(reference))) { + throw new IllegalStateException("Meter photos must reference a retained report photo."); + } + } + private void validateExitAgainstEntrySnapshot() { if (!zones.equals(entrySnapshot.zones())) { throw new IllegalStateException("Exit report zones must match the entry snapshot."); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java index 5ae67aa..7dbe0a3 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java @@ -7,5 +7,6 @@ import java.util.Optional; public interface PropertyConditionReportRepository { Optional find(LandlordId landlordId, LeaseId leaseId, PropertyConditionReportType type); + boolean isPhotoStorageKeyReferenced(String storageKey); PropertyConditionReport save(PropertyConditionReport report); } diff --git a/backend/src/main/resources/templates/rental/property-condition-report.html b/backend/src/main/resources/templates/rental/property-condition-report.html index 5439889..2f7ab8a 100644 --- a/backend/src/main/resources/templates/rental/property-condition-report.html +++ b/backend/src/main/resources/templates/rental/property-condition-report.html @@ -11,7 +11,7 @@ body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: coll

Observations

ÉlémentÉtatObservation
ÉlémentÉtatObservation

Photos

Résumé des photos

Absences et correspondances photo

Zone/référenceMotif ou correspondance
ZoneMotif
Photo d’entréeCorrespondance
-

Relevés de compteurs

CompteurTypeIndexUnitéObservation
CompteurTypeIndexUnitéObservation
+

Relevés de compteurs

CompteurTypeIndexUnitéPhotoObservation
CompteurTypeIndexUnitéPhotoObservation

Moyens d’accès

TypeDestinationQuantitéObservation
TypeDestinationQuantitéObservation

Inventaire constaté

ÉlémentQuantité contractuelleRépartition constatée
ÉlémentQuantité
  • Répartition

Adresses de correspondance

LocataireAdresse
LocataireAdresse
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java index f8c2d47..654a555 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java @@ -21,21 +21,30 @@ class FileSystemPropertyConditionAttachmentStorageTest { RentalStorageProperties properties = new RentalStorageProperties(temporaryDirectory.resolve("attachments")); Path root = properties.attachments().resolve("condition-reports"); FileSystemPropertyConditionAttachmentStorage storage = - new FileSystemPropertyConditionAttachmentStorage(properties); + new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true); String key = storage.store(new LeaseId("lease-a"), "photo-a", "webp", new byte[]{1, 2, 3}); storage.stageDeletion(key); - storage = new FileSystemPropertyConditionAttachmentStorage(properties); + storage = new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true); try (var content = storage.read(key)) { assertArrayEquals(new byte[]{1, 2, 3}, content.readAllBytes()); } storage.stageDeletion(key); Path deletion = root.resolve(".staging").resolve(key); - Files.move(deletion.resolve("backup"), deletion.resolve("committed"), StandardCopyOption.ATOMIC_MOVE); - - new FileSystemPropertyConditionAttachmentStorage(properties); + new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> false); assertFalse(Files.exists(root.resolve(key))); assertFalse(Files.exists(deletion)); + + storage = new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true); + String committedKey = storage.store(new LeaseId("lease-a"), "photo-b", "webp", new byte[]{4, 5, 6}); + storage.stageDeletion(committedKey); + Path committedDeletion = root.resolve(".staging").resolve(committedKey); + Files.move(committedDeletion.resolve("backup"), committedDeletion.resolve("committed"), + StandardCopyOption.ATOMIC_MOVE); + + new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true); + assertFalse(Files.exists(root.resolve(committedKey))); + assertFalse(Files.exists(committedDeletion)); } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java new file mode 100644 index 0000000..cd7ec31 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java @@ -0,0 +1,79 @@ +package fr.nessar.lmnporganizer.rental.adapter.out.image; + +import fr.nessar.lmnporganizer.rental.adapter.in.config.PropertyConditionPhotoProperties; +import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure; +import org.springframework.util.unit.DataSize; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Comparator; +import java.util.List; + +public final class ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance { + private static final Path ROOT = Path.of("/tmp/lmnp-condition-photos"); + private static final Path FIXTURES = ROOT.resolve("build-check"); + + public static void main(String[] arguments) throws IOException { + var normalizer = new ImageMagickPropertyConditionPhotoNormalizer(properties()); + var normalized = normalizer.normalize(Files.readAllBytes(FIXTURES.resolve("small.png"))); + require(normalized.width() == 800 && normalized.height() == 600 && normalized.content().length >= 12, + "The normalized image was not post-validated"); + rejects(normalizer, "animated.webp"); + rejects(normalizer, "multipage.miff"); + rejects(normalizer, "invalid.upload"); + rejects(new ImageMagickPropertyConditionPhotoNormalizer(restrictiveProperties()), "small.png"); + Path timeoutDirectory = Files.createTempDirectory(ROOT, "timeout-"); + try { + try { + ImageMagickPropertyConditionPhotoNormalizer.run( + List.of("/bin/sh", "-c", "sleep 1"), timeoutDirectory, Duration.ofMillis(10)); + throw new AssertionError("The ImageMagick process did not time out"); + } catch (PropertyConditionPhotoNormalizationFailure expected) { + // expected + } + } finally { + delete(timeoutDirectory); + } + try (var files = Files.list(ROOT)) { + require(files.noneMatch(path -> path.getFileName().toString().startsWith("photo-")), + "The normalizer left temporary files behind"); + } + } + + private static void rejects(ImageMagickPropertyConditionPhotoNormalizer normalizer, String fixture) + throws IOException { + try { + normalizer.normalize(Files.readAllBytes(FIXTURES.resolve(fixture))); + throw new AssertionError(fixture + " was accepted"); + } catch (PropertyConditionPhotoNormalizationFailure expected) { + // expected + } + } + + private static PropertyConditionPhotoProperties properties() { + return new PropertyConditionPhotoProperties("magick", DataSize.ofMegabytes(10), 12_000, 12_000, + DataSize.ofMegabytes(192), DataSize.ofMegabytes(384), DataSize.ofMegabytes(768), 2, + Duration.ofSeconds(20)); + } + + private static PropertyConditionPhotoProperties restrictiveProperties() { + return new PropertyConditionPhotoProperties("magick", DataSize.ofMegabytes(10), 12_000, 12_000, + DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ofSeconds(20)); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static void delete(Path directory) throws IOException { + try (var paths = Files.walk(directory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java index 83764e3..c961908 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java @@ -3,6 +3,15 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb; import fr.nessar.lmnporganizer.LmnpOrganizerApplication; import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner; import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService; +import fr.nessar.lmnporganizer.rental.domain.condition.OnTimeSupplement; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFinalization; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportId; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportStatus; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import fr.nessar.lmnporganizer.rental.domain.rent.RentDue; @@ -53,6 +62,9 @@ class RentalMongoTransactionIT { @Autowired private RentPaymentRepository rentPayments; + @Autowired + private PropertyConditionReportRepository conditionReports; + @Autowired private MongoTemplate mongoTemplate; @@ -68,6 +80,7 @@ class RentalMongoTransactionIT { mongoTemplate.dropCollection(PROBE_COLLECTION); mongoTemplate.getCollection("rentDues").deleteMany(new Document()); mongoTemplate.getCollection("rentPayments").deleteMany(new Document()); + mongoTemplate.getCollection("propertyConditionReports").deleteMany(new Document()); } @Test @@ -143,6 +156,41 @@ class RentalMongoTransactionIT { .allocations().stream().map(PaymentAllocation::rentDueId).toList()); } + @Test + void rejectsAnExitSnapshotCreatedBeforeAConcurrentEntrySupplementCommits() { + LandlordId landlordId = new LandlordId("landlord-1"); + LeaseId leaseId = new LeaseId("lease-1"); + conditionReports.save(finalizedEntry(landlordId, leaseId)); + PropertyConditionReport staleEntry = conditionReports.find( + landlordId, leaseId, PropertyConditionReportType.ENTRY).orElseThrow(); + + transactions.run(() -> { + PropertyConditionReport currentEntry = conditionReports.find( + landlordId, leaseId, PropertyConditionReportType.ENTRY).orElseThrow(); + currentEntry.appendSupplement(new PropertyConditionReportSupplement( + "supplement-1", PropertyConditionReportSupplementType.GENERAL, + new PropertyConditionReportSupplement.FileEvidence( + "supplement-pdf", "supplement-storage", "supplement-sha"), + List.of(), "author", Instant.parse("2026-07-25T10:00:00Z"), + LocalDate.of(2026, 7, 25), null, "Ajout accepté", + new OnTimeSupplement(LocalDate.of(2026, 7, 30)))); + return conditionReports.save(currentEntry); + }); + + assertThrows(OptimisticLockingFailureException.class, () -> transactions.run(() -> { + PropertyConditionReport exit = PropertyConditionReport.draftExit( + new PropertyConditionReportId("report-exit"), staleEntry, + LocalDate.of(2026, 7, 31), "Europe/Paris"); + conditionReports.save(staleEntry); + return conditionReports.save(exit); + })); + + assertEquals(1, conditionReports.find(landlordId, leaseId, PropertyConditionReportType.ENTRY) + .orElseThrow().supplements().size()); + assertEquals(Optional.empty(), + conditionReports.find(landlordId, leaseId, PropertyConditionReportType.EXIT)); + } + private static MongoDBContainer startMongo() { if (System.getenv("SPRING_DATA_MONGODB_URI") != null) { return null; @@ -156,4 +204,16 @@ class RentalMongoTransactionIT { return RentDue.create(new RentDueId(dueId), new LandlordId(landlordId), leaseId, month, month.plusMonths(1).atDay(5), new BigDecimal("900"), new BigDecimal("50")); } + + private static PropertyConditionReport finalizedEntry(LandlordId landlordId, LeaseId leaseId) { + return PropertyConditionReport.reconstitute( + new PropertyConditionReportId("report-entry"), landlordId, leaseId, + PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), List.of(), + new PropertyConditionReportFinalization(1, "signed-storage", "signed-sha", "author", + Instant.parse("2026-07-20T10:00:00Z")), + List.of(), null, 1, "lease-revision-fingerprint", + PropertyConditionReportStatus.FINALIZED, -1); + } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java index baa1f18..13c0771 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java @@ -29,6 +29,7 @@ import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -72,6 +73,19 @@ class MongoPropertyConditionReportRepositoryTest { }); } + @Test + void checksWhetherAReportStillReferencesAStagedPhoto() { + when(mongo.exists(any(Query.class), eq(MongoPropertyConditionReportRepository.ReportDocument.class))) + .thenReturn(true); + MongoPropertyConditionReportRepository repository = new MongoPropertyConditionReportRepository(mongo); + + assertTrue(repository.isPhotoStorageKeyReferenced("lease-1/photo.webp")); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + verify(mongo).exists(query.capture(), eq(MongoPropertyConditionReportRepository.ReportDocument.class)); + assertThat(query.getValue().getQueryObject()).containsEntry("photos.storageKey", "lease-1/photo.webp"); + } + private static PropertyConditionReport report() { return PropertyConditionReport.reconstitute(new PropertyConditionReportId("report-1"), LANDLORD_ID, LEASE_ID, PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java index 71ad048..9f5a825 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java @@ -56,12 +56,14 @@ class ThymeleafPropertyConditionReportRendererTest { assertTrue(occurrences(text, "Léa Durand") >= 2); assertTrue(occurrences(text, "Marc Martin") >= 2); assertTrue(text.contains("Mandataire de Camille Martin")); - assertTrue(text.contains("Nombre : 2")); - assertTrue(text.contains("photo-001 à photo-002")); + assertTrue(text.contains("Nombre : 3")); + assertTrue(text.contains("photo-001 à photo-meter-001")); assertTrue(text.contains("Chaise de cuisine")); assertTrue(text.contains("Cuisine — Murs")); assertTrue(text.contains("Dégradé")); assertTrue(text.contains("Clé")); + assertTrue(text.contains("Photo")); + assertTrue(text.contains("photo-meter-001")); assertFalse(text.contains("chair-1") || text.contains("kitchen:walls") || text.contains("DEGRADE")); } @@ -79,7 +81,7 @@ class ThymeleafPropertyConditionReportRendererTest { report.replaceObservations(List.of(new ConditionObservation("kitchen:walls", ConditionState.DEGRADE, "Rayure visible"))); report.replaceMeterReadings(List.of(new MeterReading("meter-1", MeterType.ELECTRICITE, - MeterReadingState.RECORDED, new BigDecimal("12.5"), "kWh", "Compteur cuisine", "", ""))); + MeterReadingState.RECORDED, new BigDecimal("12.5"), "kWh", "Compteur cuisine", "photo-meter-001", ""))); report.replaceAccessMeans(List.of(new AccessMeans("key-1", AccessMeansType.CLE, "Porte d’entrée", 2, ""))); report.replaceInventoryConditions(List.of(new InventoryCondition("chair-1", "kitchen", "Cuisine", "Chaise de cuisine", 2, List.of(new InventoryQuantitySplit("chair-1", ConditionState.DEGRADE, 2, "Assise usée"))))); @@ -87,7 +89,9 @@ class ThymeleafPropertyConditionReportRendererTest { new ConditionPhoto("photo-002", "kitchen:walls", "storage-2", "sha-2", 100, 100, "author", Instant.parse("2026-07-20T10:00:00Z")), new ConditionPhoto("photo-001", "kitchen:walls", "storage-1", "sha-1", 100, 100, "author", - Instant.parse("2026-07-20T10:00:00Z")))); + Instant.parse("2026-07-20T10:00:00Z")), + new ConditionPhoto("photo-meter-001", "kitchen:walls", "storage-meter", "sha-meter", 100, 100, + "author", Instant.parse("2026-07-20T10:00:00Z")))); return report; } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java index 63ee90a..006e2f0 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java @@ -10,11 +10,14 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFi import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportId; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportStatus; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType; import fr.nessar.lmnporganizer.rental.domain.condition.ReportParticipant; import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole; import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance; import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState; +import fr.nessar.lmnporganizer.rental.domain.condition.OnTimeSupplement; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision; @@ -34,9 +37,12 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -83,6 +89,31 @@ class PropertyConditionDocumentServiceTest { assertEquals(ConditionState.DEGRADE, comparison.observations().getFirst().exit().state()); } + @Test + void keepsTheFinalizedEntryBundleByteIdenticalAfterAppendingASupplement() { + ConditionPhoto photo = new ConditionPhoto("report-photo", "zone-a", "report-photo-key", sha("%photo"), + 800, 600, "author-a", Instant.parse("2026-07-23T10:00:00Z")); + MemoryReports reports = new MemoryReports(finalized(PropertyConditionReportType.ENTRY, List.of(), null, + List.of(photo))); + MemoryStorage storage = new MemoryStorage(); + storage.files.put("signed", "%PDF-signed".getBytes()); + storage.files.put("report-photo-key", "%photo".getBytes()); + storage.files.put("supplement-signed", "%PDF-supplement".getBytes()); + PropertyConditionDocumentService service = service(reports, storage); + + byte[] finalizedBundle = service.downloadBundle(LANDLORD, LEASE, PropertyConditionReportType.ENTRY).content(); + assertEquals(Set.of("scan-signe.pdf", "photos/report-photo.webp", "manifest.sha256"), zipEntries(finalizedBundle)); + reports.entry.appendSupplement(new PropertyConditionReportSupplement("supplement-1", + PropertyConditionReportSupplementType.GENERAL, + new PropertyConditionReportSupplement.FileEvidence("supplement-scan", "supplement-signed", + sha("%PDF-supplement")), + List.of(), "author-a", Instant.parse("2026-07-24T10:00:00Z"), LocalDate.of(2026, 7, 24), null, + "Ajout accepté", new OnTimeSupplement(LocalDate.of(2026, 7, 30)))); + + assertArrayEquals(finalizedBundle, + service.downloadBundle(LANDLORD, LEASE, PropertyConditionReportType.ENTRY).content()); + } + @Test void scopesEveryDocumentOperationToTheLandlordAndObsoletesThePriorRevision() { MemoryReports reports = new MemoryReports(draft()); @@ -205,7 +236,22 @@ class PropertyConditionDocumentServiceTest { } private static String sha(String content) { - return "878c16e93410be0acef881624a20807d767af39bd4a6665a94c0a4ee6905a8d1"; + return switch (content) { + case "%PDF-signed" -> "878c16e93410be0acef881624a20807d767af39bd4a6665a94c0a4ee6905a8d1"; + case "%photo" -> "23658f0b8465f98ec39ae43c49aa8a7c5094fb66cdbf40576dd82dc042c57d2d"; + case "%PDF-supplement" -> "c8c4fe5890cd64b58b87aa29d60fc6b7a2d1680562c4b9ffec7fd3f0ae787d68"; + default -> throw new IllegalArgumentException("Unexpected test file."); + }; + } + + private static Set zipEntries(byte[] content) { + Set entries = new HashSet<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { + for (var entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) entries.add(entry.getName()); + return entries; + } catch (java.io.IOException failure) { + throw new IllegalStateException(failure); + } } private static final class MemoryReports implements PropertyConditionReportRepository { @@ -218,6 +264,7 @@ class PropertyConditionDocumentServiceTest { if (!LANDLORD.equals(landlord) || !LEASE.equals(lease)) return Optional.empty(); return Optional.ofNullable(type == PropertyConditionReportType.ENTRY ? entry : exit); } + @Override public boolean isPhotoStorageKeyReferenced(String storageKey) { return false; } @Override public PropertyConditionReport save(PropertyConditionReport report) { PropertyConditionReport saved = PropertyConditionReport.reconstitute(report.id(), report.landlordId(), report.leaseId(), diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java index 784ba0e..7dccf2d 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java @@ -3,11 +3,16 @@ package fr.nessar.lmnporganizer.rental.application.condition; import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator; import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage; import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer; +import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.condition.*; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord; @@ -30,6 +35,7 @@ import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; class PropertyConditionReportServiceTest { @@ -66,6 +72,33 @@ class PropertyConditionReportServiceTest { assertEquals(2, restarted.leaseRevisionNumber()); } + @Test + void refusesAnEntryDraftUntilTheLeaseRevisionIsRegeneratedAfterAnInventoryEdit() { + Fixture fixture = fixture(revision(1, "source-1")); + fixture.inventories.current = inventory(3); + + assertThrows(IllegalStateException.class, () -> fixture.service.get(LANDLORD, LEASE, + PropertyConditionReportType.ENTRY, "Europe/Paris")); + assertNull(fixture.reports.entry); + + fixture.revisions.current = revision(2, "source-2", 3); + PropertyConditionReport report = fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.ENTRY, + "Europe/Paris"); + + assertEquals(2, report.leaseRevisionNumber()); + } + + @Test + void refusesToRestartAnEntryDraftFromARevisionWithAnOutdatedInventorySnapshot() { + Fixture fixture = fixture(revision(1, "source-1")); + fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.ENTRY, "Europe/Paris"); + fixture.inventories.current = inventory(3); + + assertThrows(IllegalStateException.class, () -> fixture.service.get(LANDLORD, LEASE, + PropertyConditionReportType.ENTRY, "Europe/Paris")); + assertEquals(1, fixture.reports.entry.leaseRevisionNumber()); + } + @Test void allowsAnIncompleteDraftButRejectsForeignPartiesAndZones() { Fixture fixture = fixture(revision(1, "source-1")); @@ -124,6 +157,21 @@ class PropertyConditionReportServiceTest { PropertyConditionReportSupplementType.GENERAL, null, false, null)); } + @Test + void wrapsExitSnapshotCreationAndEntrySupplementsInTransactions() { + Fixture fixture = fixture(revision(1, "source-1")); + fixture.reports.entry = finalizedEntry(); + + fixture.service.appendSupplement(LANDLORD, LEASE, "%PDF".getBytes(), List.of(), "author", + LocalDate.of(2026, 7, 25), "Ajout accepté", PropertyConditionReportSupplementType.GENERAL, + null, false, null); + assertEquals(1, fixture.transactions.calls); + + fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.EXIT, "Europe/Paris"); + assertEquals(2, fixture.transactions.calls); + assertEquals(1, fixture.reports.exit.entrySnapshot().supplements().size()); + } + private static PropertyConditionReport finalizedEntry() { return PropertyConditionReport.reconstitute(new PropertyConditionReportId("report-1"), LANDLORD, LEASE, PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", List.of(), List.of(), @@ -135,11 +183,15 @@ class PropertyConditionReportServiceTest { } private static LeaseDocumentRevision revision(int number, String fingerprint) { + return revision(number, fingerprint, 2); + } + + private static LeaseDocumentRevision revision(int number, String fingerprint, int quantity) { return new LeaseDocumentRevision("revision-" + number, LANDLORD, LEASE, number, "FR-MEUBLE-2025-01", fingerprint, "storage", "sha", Instant.parse("2026-07-20T10:00:00Z"), "author", new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of( new FurnitureInventorySnapshot.Line("chair", fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory.TABLE_AND_SEATING, - FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", 2, ""))), + FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", quantity, ""))), List.of(new PrivateArea("kitchen", PrivateArea.Family.PIECE_DE_SERVICE, PrivateArea.Subtype.KITCHEN, "Cuisine")), List.of(), List.of()); } @@ -150,6 +202,7 @@ class PropertyConditionReportServiceTest { new RentTerms(new BigDecimal("800"), new BigDecimal("50"), new BigDecimal("800"))); MemoryReports reports = new MemoryReports(); MemoryRevisions revisions = new MemoryRevisions(revision); + MemoryInventories inventories = new MemoryInventories(inventory(revision.furnitureInventory().lines().getFirst().quantity())); LeaseRepository leases = new LeaseRepository() { @Override public List findAll(LandlordId landlord) { return List.of(lease); } @Override public Optional find(LandlordId landlord, LeaseId leaseId) { return Optional.of(lease); } @@ -169,20 +222,36 @@ class PropertyConditionReportServiceTest { @Override public Tenant save(Tenant value) { return value; } @Override public boolean delete(Tenant value) { return false; } }; - PropertyConditionReportService service = new PropertyConditionReportService(reports, leases, revisions, landlords, tenants, - source -> new PropertyConditionPhotoNormalizer.NormalizedPhoto(source, 1, 1), new Storage(), + RecordingTransactions transactions = new RecordingTransactions(); + PropertyConditionReportService service = new PropertyConditionReportService(reports, leases, revisions, inventories, + landlords, tenants, + source -> new PropertyConditionPhotoNormalizer.NormalizedPhoto(source, 1, 1), new Storage(), transactions, new IdentifierGenerator() { private int next; @Override public String next() { return "id-" + ++next; } }, Clock.fixed(Instant.parse("2026-07-31T10:00:00Z"), ZoneOffset.UTC)); - return new Fixture(service, reports, revisions); + return new Fixture(service, reports, revisions, inventories, transactions); } - private record Fixture(PropertyConditionReportService service, MemoryReports reports, MemoryRevisions revisions) { } + private static FurnitureInventory inventory(int quantity) { + return FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD, LEASE, + "FR-MEUBLE-2025-01", List.of(new FurnitureLine("chair", + fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory.TABLE_AND_SEATING, + FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", quantity, "")), false, 0); + } + + private record Fixture(PropertyConditionReportService service, MemoryReports reports, MemoryRevisions revisions, + MemoryInventories inventories, + RecordingTransactions transactions) { } + private static final class RecordingTransactions implements TransactionRunner { + private int calls; + @Override public T run(java.util.function.Supplier operation) { calls++; return operation.get(); } + } private static final class MemoryReports implements PropertyConditionReportRepository { private PropertyConditionReport entry; private PropertyConditionReport exit; @Override public Optional find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) { return Optional.ofNullable(type == PropertyConditionReportType.ENTRY ? entry : exit); } + @Override public boolean isPhotoStorageKeyReferenced(String storageKey) { return false; } @Override public PropertyConditionReport save(PropertyConditionReport value) { if (value.type() == PropertyConditionReportType.ENTRY) entry = value; else exit = value; return value; @@ -196,6 +265,12 @@ class PropertyConditionReportServiceTest { @Override public Optional find(LandlordId landlord, LeaseId lease, int number) { return current.number() == number ? Optional.of(current) : Optional.empty(); } @Override public LeaseDocumentRevision save(LeaseDocumentRevision value) { current = value; return value; } } + private static final class MemoryInventories implements FurnitureInventoryRepository { + private FurnitureInventory current; + private MemoryInventories(FurnitureInventory current) { this.current = current; } + @Override public Optional find(LandlordId landlord, LeaseId lease) { return Optional.ofNullable(current); } + @Override public FurnitureInventory save(FurnitureInventory value) { current = value; return value; } + } private static final class Storage implements PropertyConditionAttachmentStorage { @Override public String store(LeaseId lease, String reference, String extension, byte[] content) { return reference; } @Override public InputStream read(String key) { return new ByteArrayInputStream(new byte[0]); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java index 0daacd7..c9097aa 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java @@ -91,6 +91,25 @@ class PropertyConditionReportTest { report.finalizeReport(finalization(1)); } + @Test + void requiresMeterPhotoReferencesToBeRetainedAtFinalization() { + PropertyConditionReport report = entryDraft(); + report.replaceParticipants(participants(ParticipantAttendance.PRESENT)); + report.replaceZones(List.of(new ConditionZone("floor", "living-room", "Sol", ZoneEquipment.NONE))); + report.replaceObservations(List.of(new ConditionObservation("floor", ConditionState.BON_ETAT, ""))); + report.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE, + MeterReadingState.RECORDED, new BigDecimal("12.5"), "m3", "", "missing-photo", ""))); + report.replacePhotos(List.of(photo("report-photo", "floor", "entry"))); + report.addRevision(revision(1)); + + assertThrows(IllegalStateException.class, () -> report.finalizeReport(finalization(1))); + + report.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE, + MeterReadingState.RECORDED, new BigDecimal("12.5"), "m3", "", "report-photo", ""))); + report.addRevision(revision(2)); + report.finalizeReport(finalization(2)); + } + @Test void snapshotsZonesButAllowsExitAttendanceToChangeAndRequiresOnlyTenantAddresses() { PropertyConditionReport entry = finalizedEntry(); @@ -136,6 +155,20 @@ class PropertyConditionReportTest { assertEquals(exit.zones(), restored.zones()); } + @Test + void keepsDeliveryAttestationsAppendOnly() { + PropertyConditionReport entry = finalizedEntry(); + var attestation = new PropertyConditionReportDeliveryAttestation("tenant-1", "landlord-1", + Instant.parse("2026-07-21T10:00:00Z")); + + entry.attestExternalDelivery(attestation); + + assertThrows(IllegalStateException.class, () -> entry.attestExternalDelivery( + new PropertyConditionReportDeliveryAttestation("tenant-1", "landlord-1", + Instant.parse("2026-07-22T10:00:00Z")))); + assertEquals(List.of(attestation), entry.deliveryAttestations()); + } + @Test void replacesTheDraftOccurrenceAndTimeZoneOnlyBeforeFinalization() { PropertyConditionReport report = entryDraft(); diff --git a/frontend/src/app/components/condition-report-page/condition-report-editor.ts b/frontend/src/app/components/condition-report-page/condition-report-editor.ts index 1fa8c84..86311c3 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-editor.ts +++ b/frontend/src/app/components/condition-report-page/condition-report-editor.ts @@ -150,16 +150,31 @@ export class ConditionReportEditor { ) ) return 'Renseignez l’adresse de correspondance de chaque locataire.'; - if ( - this.type() === 'EXIT' && - this.entryPhotos().some((photo) => { + if (this.type() === 'EXIT') { + const usedExitPhotoIds = new Set(); + for (const entryPhoto of this.entryPhotos()) { const disposition = report.entryPhotoDispositions.find( - (item) => item.entryPhotoId === photo.reference, + (item) => item.entryPhotoId === entryPhoto.reference, ); - return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim(); - }) - ) - return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.'; + if (!disposition?.exitPhotoId) { + if (!disposition?.absenceReason?.trim()) { + return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.'; + } + continue; + } + const exitPhoto = report.photos.find( + (photo) => photo.reference === disposition.exitPhotoId, + ); + if ( + !exitPhoto || + exitPhoto.zoneId !== entryPhoto.zoneId || + usedExitPhotoIds.has(exitPhoto.reference) + ) { + return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.'; + } + usedExitPhotoIds.add(exitPhoto.reference); + } + } return ''; } diff --git a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html index 3900b7b..78202cb 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html +++ b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html @@ -72,7 +72,7 @@ (change)="setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)" > - @for (exitPhoto of report().photos; track exitPhoto.reference) { + @for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) { } diff --git a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts index 7d648fe..44410bc 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts +++ b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts @@ -105,8 +105,8 @@ export class ConditionReportEvidenceSection implements OnDestroy { .subscribe({ next: () => { this.retakePhoto(zoneId); - this.successOccurred.emit('Photo ajoutée.'); this.reloadRequested.emit(); + this.successOccurred.emit('Photo ajoutée.'); }, error: (error) => this.errorOccurred.emit( @@ -143,6 +143,22 @@ export class ConditionReportEvidenceSection implements OnDestroy { ); } + exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] { + const report = this.report(); + const current = this.disposition(entryPhoto.reference).exitPhotoId; + const used = new Set( + report.entryPhotoDispositions + .filter((item) => item.entryPhotoId !== entryPhoto.reference) + .map((item) => item.exitPhotoId) + .filter((reference): reference is string => Boolean(reference)), + ); + return report.photos.filter( + (photo) => + photo.zoneId === entryPhoto.zoneId && + (photo.reference === current || !used.has(photo.reference)), + ); + } + photoUrl(type: ConditionReportType, reference: string): string { return this.reports.photoUrl(this.leaseId(), type, reference); } diff --git a/frontend/src/app/components/condition-report-page/condition-report-page.html b/frontend/src/app/components/condition-report-page/condition-report-page.html index b7f116b..e62cc00 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-page.html +++ b/frontend/src/app/components/condition-report-page/condition-report-page.html @@ -8,16 +8,19 @@ - @if (loading()) { + @if (loading() && !report()) {

Chargement de l’état des lieux…

} @else if (error() && !report()) { } @else if (report(); as value) { + @if (loading()) { +

Mise à jour de l’état des lieux…

+ } @if (error()) { } - @if (success()) { + @if (success() && !error()) {

{{ success() }}

} @if (value.leaseRevisionNumber !== null) { @@ -47,9 +50,22 @@

Révision et finalisation

@if (value.status === 'DRAFT') { - + @if (revisionDownloadError()) { + + }