fix(rental): complete condition report invariants (#7)
This commit is contained in:
1 parent
62d0f171f4
commit
5d2d85aa8c
23 files changed
+652
-85
No files matched your search
+32
-8
@@ -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"]
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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));
|
||||
|
||||
-1
@@ -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());
|
||||
|
||||
+58
-22
@@ -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);
|
||||
|
||||
+12
-1
@@ -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.");
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
+14
-5
@@ -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));
|
||||
}
|
||||
}
|
||||
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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",
|
||||
|
||||
+8
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+48
-1
@@ -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(),
|
||||
|
||||
+80
-5
@@ -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]); }
|
||||
|
||||
+33
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user