diff --git a/repositories/product/product_json_repository.py b/repositories/product/product_json_repository.py new file mode 100644 index 00000000..902f931f --- /dev/null +++ b/repositories/product/product_json_repository.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +from domain.modelling.contingencies import contingency_rate +from domain.modelling.product import Product +from repositories.product.product_repository import ProductRepository + + +class ProductJsonRepository(ProductRepository): + """Reads Products from a JSON catalogue file — the stopgap source for + costs the ETL does not yet supply, behind the same `ProductRepository` + port as the Postgres-backed catalogue. + + The file maps each Measure Type to its fully-loaded unit cost:: + + {"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}, ...} + + The per-Measure-Type contingency is joined from config (not stored in the + file), exactly as `ProductPostgresRepository` joins it — config stays the + single source of truth for contingency. + """ + + def __init__(self, path: Path) -> None: + with path.open(encoding="utf-8") as handle: + loaded: Any = json.load(handle) + if not isinstance(loaded, dict): + raise ValueError(f"product catalogue {path} is not a JSON object") + self._entries: dict[str, Any] = loaded + + def get(self, measure_type: str) -> Product: + entry: Any = self._entries.get(measure_type) + if entry is None: + raise ValueError(f"no product for measure type {measure_type!r}") + if not isinstance(entry, dict): + raise ValueError(f"product {measure_type!r} entry is not an object") + typed_entry: dict[str, Any] = cast("dict[str, Any]", entry) + unit_cost: Any = typed_entry.get("unit_cost_per_m2") + if isinstance(unit_cost, bool) or not isinstance(unit_cost, (int, float)): + raise ValueError( + f"product {measure_type!r} has no numeric unit_cost_per_m2" + ) + return Product( + measure_type=measure_type, + unit_cost_per_m2=float(unit_cost), + contingency_rate=contingency_rate(measure_type), + ) diff --git a/tests/repositories/product/test_product_json_repository.py b/tests/repositories/product/test_product_json_repository.py new file mode 100644 index 00000000..a991f2a6 --- /dev/null +++ b/tests/repositories/product/test_product_json_repository.py @@ -0,0 +1,61 @@ +"""Behaviour of the JSON-backed ProductRepository: reading a Product from a +catalogue file — the stopgap source for costs the ETL does not yet supply, +behind the same port as the Postgres-backed catalogue. The per-measure-type +contingency is joined from config, not stored in the file. See CONTEXT.md +(Product, Cost, Contingency).""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from domain.modelling.product import Product +from repositories.product.product_json_repository import ProductJsonRepository + + +def _write_catalogue(tmp_path: Path, payload: dict[str, Any]) -> Path: + path: Path = tmp_path / "products.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_get_maps_a_json_entry_to_a_product_with_contingency( + tmp_path: Path, +) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}} + ) + + # Act + product: Product = ProductJsonRepository(catalogue).get( + "cavity_wall_insulation" + ) + + # Assert + assert product.measure_type == "cavity_wall_insulation" + assert abs(product.unit_cost_per_m2 - 18.5) <= 1e-9 + assert abs(product.contingency_rate - 0.10) <= 1e-9 + + +def test_get_raises_when_measure_type_absent(tmp_path: Path) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"loft_insulation": {"unit_cost_per_m2": 22.0}} + ) + + # Act / Assert + with pytest.raises(ValueError): + ProductJsonRepository(catalogue).get("cavity_wall_insulation") + + +def test_get_raises_when_entry_lacks_unit_cost(tmp_path: Path) -> None: + # Arrange + catalogue: Path = _write_catalogue( + tmp_path, {"cavity_wall_insulation": {"cost_unit": "gbp_per_m2"}} + ) + + # Act / Assert + with pytest.raises(ValueError): + ProductJsonRepository(catalogue).get("cavity_wall_insulation")