mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
127 lines
3.6 KiB
Python
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]
|