fix(rental): complete condition report invariants (#7)

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:16 +02:00
1 parent 62d0f171f4
commit 5d2d85aa8c
23 files changed
+652 -85

No files matched your search

+32 -8
View File
@@ -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"]
@@ -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);
}
@@ -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<String> referenced;
public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties) {
@Autowired
public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties,
PropertyConditionReportRepository reports) {
this(properties, reports::isPhotoStorageKeyReferenced);
}
FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties, Predicate<String> 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);
}
@@ -26,6 +26,9 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
@Override public Optional<PropertyConditionReport> 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));
@@ -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());
@@ -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<ConditionZone> zones = PropertyConditionDraftTemplate.requiredZones(revision);
@@ -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<PropertyConditionReportDeliveryAttestation> 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<String> 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.");
@@ -7,5 +7,6 @@ import java.util.Optional;
public interface PropertyConditionReportRepository {
Optional<PropertyConditionReport> find(LandlordId landlordId, LeaseId leaseId, PropertyConditionReportType type);
boolean isPhotoStorageKeyReferenced(String storageKey);
PropertyConditionReport save(PropertyConditionReport report);
}
@@ -11,7 +11,7 @@ body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: coll
<h2>Observations</h2><table><thead><tr><th>Élément</th><th>État</th><th>Observation</th></tr></thead><tbody><tr th:each="observation : ${report.observations()}"><td th:text="${zoneLabels.get(observation.subjectId()) ?: 'Élément observé'}">Élément</td><td th:text="${labels.condition(observation.state())}">État</td><td th:text="${observation.observation()}">Observation</td></tr></tbody></table>
<h2>Photos</h2><p th:text="|Nombre : ${photoCount} — références : ${photoReferenceRange}|">Résumé des photos</p>
<h2>Absences et correspondances photo</h2><table><thead><tr><th>Zone/référence</th><th>Motif ou correspondance</th></tr></thead><tbody><tr th:each="absence : ${report.zonePhotoAbsences()}"><td th:text="${zoneLabels.get(absence.zoneId()) ?: 'Zone'}">Zone</td><td th:text="${absence.absence().reason()}">Motif</td></tr><tr th:each="disposition : ${report.entryPhotoDispositions()}"><td>Photo d’entrée</td><td th:text="${disposition instanceof T(fr.nessar.lmnporganizer.rental.domain.condition.MatchedEntryPhoto) ? 'Photo de sortie associée' : 'Motif d’absence renseigné'}">Correspondance</td></tr></tbody></table>
<h2>Relevés de compteurs</h2><table><thead><tr><th>Compteur</th><th>Type</th><th>Index</th><th>Unité</th><th>Observation</th></tr></thead><tbody><tr th:each="meter : ${report.meterReadings()}"><td th:text="${meter.label().isEmpty() ? 'Compteur' : meter.label()}">Compteur</td><td th:text="${labels.meter(meter.type())}">Type</td><td th:text="${meter.state().name() == 'INACCESSIBLE' ? 'Inaccessible' : meter.index()}">Index</td><td th:text="${meter.unit()}">Unité</td><td th:text="${meter.observation()}">Observation</td></tr></tbody></table>
<h2>Relevés de compteurs</h2><table><thead><tr><th>Compteur</th><th>Type</th><th>Index</th><th>Unité</th><th>Photo</th><th>Observation</th></tr></thead><tbody><tr th:each="meter : ${report.meterReadings()}"><td th:text="${meter.label().isEmpty() ? 'Compteur' : meter.label()}">Compteur</td><td th:text="${labels.meter(meter.type())}">Type</td><td th:text="${meter.state().name() == 'INACCESSIBLE' ? 'Inaccessible' : meter.index()}">Index</td><td th:text="${meter.unit()}">Unité</td><td th:text="${meter.photoId().isEmpty() ? '—' : meter.photoId()}">Photo</td><td th:text="${meter.observation()}">Observation</td></tr></tbody></table>
<h2>Moyens d’accès</h2><table><thead><tr><th>Type</th><th>Destination</th><th>Quantité</th><th>Observation</th></tr></thead><tbody><tr th:each="access : ${report.accessMeans()}"><td th:text="${labels.access(access.type())}">Type</td><td th:text="${access.destination()}">Destination</td><td th:text="${access.quantity()}">Quantité</td><td th:text="${access.observation()}">Observation</td></tr></tbody></table>
<h2>Inventaire constaté</h2><table><thead><tr><th>Élément</th><th>Quantité contractuelle</th><th>Répartition constatée</th></tr></thead><tbody><tr th:each="inventory : ${report.inventoryConditions()}"><td th:text="|${inventory.designation()} — ${inventory.privateAreaLabel()}|">Élément</td><td th:text="${inventory.contractualQuantity()}">Quantité</td><td><ul><li th:each="split : ${inventory.splits()}" th:text="|${split.quantity()} × ${labels.condition(split.state())}${split.observation().isEmpty() ? '' : ' — ' + split.observation()}|">Répartition</li></ul></td></tr></tbody></table>
<h2>Adresses de correspondance</h2><table><thead><tr><th>Locataire</th><th>Adresse</th></tr></thead><tbody><tr th:each="address : ${report.forwardingAddresses()}"><td th:text="${participantLabels.get(address.tenantPartyId()) ?: 'Locataire'}">Locataire</td><td th:text="${address.address()}">Adresse</td></tr></tbody></table>
@@ -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));
}
}
@@ -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);
}
}
}
}
@@ -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);
}
}
@@ -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> 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",
@@ -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;
}
@@ -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<String> zipEntries(byte[] content) {
Set<String> 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(),
@@ -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<Lease> findAll(LandlordId landlord) { return List.of(lease); }
@Override public Optional<Lease> 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> T run(java.util.function.Supplier<T> operation) { calls++; return operation.get(); }
}
private static final class MemoryReports implements PropertyConditionReportRepository {
private PropertyConditionReport entry;
private PropertyConditionReport exit;
@Override public Optional<PropertyConditionReport> 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<LeaseDocumentRevision> 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<FurnitureInventory> 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]); }
@@ -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();