Model/tests/domain/property/test_property.py
Khalim Conn-Kowlessar 92de07efba feat(property): Property aggregate + PropertyRepository (#1132)
Add the Ara modelling aggregate root (ADR-0002): domain/property/ with
PropertyIdentity, SiteNotes, Property, Properties. Property.source_path
implements the two disjoint source paths + Recency Tie-Break (ADR-0001;
survey wins on an equal date); effective_epc resolves to the surveyed data
(Site Notes path) or the public EPC (epc_with_overlay path — Landlord
Overrides overlay is a later slice). Pure dataclasses, no infrastructure imports.

PropertyRepository port + PropertyPostgresRepository hydrate the aggregate
whole from a defensive view of the FE-owned 'property' table (identity columns)
plus the EPC slice via EpcRepository.get_for_property. Reads only from repos
(ADR-0003). 8 domain + 1 hydration test; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:39:54 +00:00

127 lines
3.6 KiB
Python

"""Property aggregate — source-path precedence and Effective EPC resolution.
The two disjoint source paths (ADR-0001): a Property is modelled either from its
Site Notes alone, or from the public EPC (with Landlord Overrides, once that slice
lands). When both exist, the newer wins (Recency Tie-Break).
"""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from typing import Any
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.property.properties import Properties
from domain.property.property import Property, PropertyIdentity
from domain.property.site_notes import SiteNotes
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
def _epc(inspection: str = "2023-12-01") -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _identity() -> PropertyIdentity:
return PropertyIdentity(
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=12345
)
def test_source_path_is_epc_with_overlay_when_only_epc_present() -> None:
# Arrange
prop = Property(identity=_identity(), epc=_epc())
# Act
path = prop.source_path
# Assert
assert path == "epc_with_overlay"
def test_source_path_is_site_notes_when_only_site_notes_present() -> None:
# Arrange
prop = Property(
identity=_identity(),
site_notes=SiteNotes(surveyed_at=date(2024, 6, 1), epc=_epc()),
)
# Act
path = prop.source_path
# Assert
assert path == "site_notes"
def test_recency_tie_break_newer_site_notes_win_over_older_epc() -> None:
# Arrange — EPC inspected 2023-12-01; survey is newer
prop = Property(
identity=_identity(),
epc=_epc(),
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=_epc()),
)
# Act / Assert
assert prop.source_path == "site_notes"
def test_recency_tie_break_older_site_notes_lose_to_newer_epc() -> None:
# Arrange — survey predates the EPC's inspection date
prop = Property(
identity=_identity(),
epc=_epc(),
site_notes=SiteNotes(surveyed_at=date(2020, 1, 1), epc=_epc()),
)
# Act / Assert
assert prop.source_path == "epc_with_overlay"
def test_effective_epc_follows_the_selected_source_path() -> None:
# Arrange
survey_epc = _epc()
public_epc = _epc()
site_notes_property = Property(
identity=_identity(),
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=survey_epc),
)
epc_property = Property(identity=_identity(), epc=public_epc)
# Act / Assert
assert site_notes_property.effective_epc is survey_epc
assert epc_property.effective_epc is public_epc
def test_property_with_no_source_raises() -> None:
# Arrange
prop = Property(identity=_identity())
# Act / Assert
try:
_ = prop.source_path
except ValueError:
pass
else: # pragma: no cover
raise AssertionError("expected ValueError when no source is present")
def test_properties_collection_iterates_and_filters() -> None:
# Arrange
with_epc = Property(identity=_identity(), epc=_epc())
without = Property(identity=_identity())
properties = Properties([with_epc, without])
# Act
with_source = properties.filter(lambda p: p.epc is not None)
# Assert
assert len(properties) == 2
assert list(properties) == [with_epc, without]
assert len(with_source) == 1
assert list(with_source) == [with_epc]