"""Tests for app/integrations/pricing/profiles.py. Acceptance criteria covered ---------------------------- 1. ``load_profile("manual")`` succeeds and returns a valid ``ManualProfile``. 2. ``load_profile("tibber")`` succeeds and returns a valid ``TibberProfile``. 3. Missing profile file raises ``ProfileNotFoundError``. 4. Malformed YAML (missing required fields) raises ``ProfileValidationError``. 5. Wrong kind in YAML raises ``ProfileValidationError``. 6. ``validate_values`` accepts conforming values for both kinds. 7. ``validate_values`` fills in default values (``ode``, ``sell_adjust``, tibber ``management_fee``) when absent. 8. ``validate_values`` raises ``ProfileValidationError`` for missing required fields (no default). 9. ``validate_values`` raises ``ProfileValidationError`` for wrong-typed fields. 10. ``list_profiles()`` returns both profiles in a list of dicts. """ from __future__ import annotations import textwrap from pathlib import Path from unittest.mock import patch import pytest import yaml from app.integrations.pricing.profiles import ( DistrictHeatingProfile, ManualProfile, ProfileNotFoundError, ProfileValidationError, TibberProfile, list_profiles, load_profile, validate_values, ) # --------------------------------------------------------------------------- # 1-2: load_profile happy path # --------------------------------------------------------------------------- class TestLoadProfileManual: """Validate that the shipped manual.yaml loads and validates correctly.""" def test_returns_manual_profile_instance(self) -> None: profile = load_profile("manual") assert isinstance(profile, ManualProfile) def test_kind_is_manual(self) -> None: profile = load_profile("manual") assert profile.kind == "manual" def test_has_label(self) -> None: profile = load_profile("manual") assert isinstance(profile.label, str) and profile.label def test_dual_tariff_is_true(self) -> None: profile = load_profile("manual") assert profile.energy.dual_tariff is True def test_energy_buy_has_normal_and_dal(self) -> None: profile = load_profile("manual") assert profile.energy.buy.normal.unit == "EUR/kWh" assert profile.energy.buy.dal.unit == "EUR/kWh" def test_energy_sell_has_normal_and_dal(self) -> None: profile = load_profile("manual") assert profile.energy.sell.normal.unit == "EUR/kWh" assert profile.energy.sell.dal.unit == "EUR/kWh" def test_energy_tax_unit(self) -> None: profile = load_profile("manual") assert profile.energy.energy_tax.unit == "EUR/kWh" assert profile.energy.energy_tax.default is None # required field, no default def test_ode_has_default_zero(self) -> None: profile = load_profile("manual") assert profile.energy.ode.default == 0 def test_standing_fields(self) -> None: profile = load_profile("manual") assert profile.standing.network_fee.unit == "EUR/month" assert profile.standing.management_fee.unit == "EUR/month" def test_credits_heffingskorting(self) -> None: profile = load_profile("manual") assert profile.credits.heffingskorting.unit == "EUR/year" def test_no_concrete_values_in_profile(self) -> None: """Profile YAML must not contain any concrete price numbers.""" profile_path = ( Path(__file__).parent.parent / "app/integrations/pricing/profiles/manual.yaml" ) with profile_path.open() as fh: raw = yaml.safe_load(fh) # Leaf nodes should only have 'unit' and optionally 'default: 0', # not actual price values like 0.133 or 0.127. energy = raw.get("energy", {}) buy = energy.get("buy", {}) # Leaf buy nodes: only 'unit' key, no numeric value key. assert set(buy["normal"].keys()) == {"unit"}, ( "buy.normal leaf must only have 'unit'" ) assert set(buy["dal"].keys()) == {"unit"}, ( "buy.dal leaf must only have 'unit'" ) class TestLoadProfileTibber: """Validate that the shipped tibber.yaml loads and validates correctly.""" def test_returns_tibber_profile_instance(self) -> None: profile = load_profile("tibber") assert isinstance(profile, TibberProfile) def test_kind_is_tibber(self) -> None: profile = load_profile("tibber") assert profile.kind == "tibber" def test_has_label(self) -> None: profile = load_profile("tibber") assert isinstance(profile.label, str) and profile.label def test_energy_source_is_tibber_api(self) -> None: profile = load_profile("tibber") assert profile.energy.source == "tibber_api" def test_energy_tax_unit(self) -> None: profile = load_profile("tibber") assert profile.energy.energy_tax.unit == "EUR/kWh" assert profile.energy.energy_tax.default is None # required def test_sell_adjust_has_default_zero(self) -> None: profile = load_profile("tibber") assert profile.energy.sell_adjust.default == 0 def test_sell_fee_has_default_verkoopvergoeding(self) -> None: profile = load_profile("tibber") assert profile.energy.sell_fee.unit == "EUR/kWh" assert profile.energy.sell_fee.default == 0.0248 def test_management_fee_has_default(self) -> None: profile = load_profile("tibber") assert profile.standing.management_fee.default is not None assert isinstance(profile.standing.management_fee.default, float) def test_network_fee_unit(self) -> None: profile = load_profile("tibber") assert profile.standing.network_fee.unit == "EUR/month" def test_credits_heffingskorting(self) -> None: profile = load_profile("tibber") assert profile.credits.heffingskorting.unit == "EUR/year" class TestLoadProfileDistrictHeating: """The thermal profile contains only field structure and zero defaults.""" def test_d11_fields_units_and_zero_defaults(self) -> None: profile = load_profile("district_heating") assert isinstance(profile, DistrictHeatingProfile) assert profile.kind == "district_heating" assert list(type(profile.variable).model_fields) == [ "heating", "hot_water_heating", "hot_water", "hot_water_tax" ] assert profile.variable.heating.unit == "EUR/GJ" assert all( getattr(profile.variable, key).unit == "EUR/m³" for key in ("hot_water_heating", "hot_water", "hot_water_tax") ) assert list(type(profile.standing).model_fields) == [ "heating_network", "metering", "delivery_set", "hot_water_network", "other" ] assert all( getattr(profile.standing, key).unit == "EUR/year" for key in type(profile.standing).model_fields ) assert all( getattr(profile.standing, key).default == 0 for key in type(profile.standing).model_fields ) assert all( getattr(profile.variable, key).minimum == 0 for key in type(profile.variable).model_fields ) assert all( getattr(profile.standing, key).minimum == 0 for key in type(profile.standing).model_fields ) def test_profile_has_no_nonzero_tariff_defaults(self) -> None: profile_path = Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml" raw = yaml.safe_load(profile_path.read_text()) defaults = [ field.get("default") for section in (raw["variable"], raw["standing"]) for field in section.values() if "default" in field ] assert defaults == [0, 0, 0, 0, 0] def test_profile_contains_no_nonzero_numeric_tariff_reference(self) -> None: raw = yaml.safe_load( (Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml").read_text() ) def numeric_values(value): if isinstance(value, dict): return [number for child in value.values() for number in numeric_values(child)] if isinstance(value, (int, float)) and not isinstance(value, bool): return [value] return [] assert numeric_values(raw) == [0] * 14 # --------------------------------------------------------------------------- # 3-5: load_profile error cases # --------------------------------------------------------------------------- class TestLoadProfileErrors: def test_missing_profile_raises_not_found(self) -> None: with pytest.raises(ProfileNotFoundError, match="nonexistent"): load_profile("nonexistent") def test_missing_required_fields_raises_validation_error( self, tmp_path: Path ) -> None: """A YAML missing required fields raises ProfileValidationError.""" bad_yaml = textwrap.dedent( """\ kind: manual label: Bad manual profile # missing energy / standing / credits sections entirely """ ) bad_path = tmp_path / "manual.yaml" bad_path.write_text(bad_yaml) with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path): with pytest.raises(ProfileValidationError, match="manual"): load_profile("manual") def test_wrong_type_raises_validation_error(self, tmp_path: Path) -> None: """A YAML with a wrong type (string where dict is expected) raises ProfileValidationError.""" bad_yaml = textwrap.dedent( """\ kind: tibber label: Wrong type profile energy: "should be a mapping not a string" standing: management_fee: { unit: EUR/month, default: 5.99 } network_fee: { unit: EUR/month } credits: heffingskorting: { unit: EUR/year } """ ) bad_path = tmp_path / "tibber.yaml" bad_path.write_text(bad_yaml) with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path): with pytest.raises(ProfileValidationError, match="tibber"): load_profile("tibber") def test_unknown_kind_raises_validation_error(self, tmp_path: Path) -> None: """A YAML with an unknown kind raises ProfileValidationError.""" bad_yaml = textwrap.dedent( """\ kind: unknown_kind label: Unknown kind profile """ ) bad_path = tmp_path / "unknown_kind.yaml" bad_path.write_text(bad_yaml) with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path): with pytest.raises(ProfileValidationError, match="unknown_kind"): load_profile("unknown_kind") def test_district_heating_yaml_float_raises_validation_error(self, tmp_path: Path) -> None: path = tmp_path / "district_heating.yaml" path.write_text( """kind: district_heating label: Bad thermal profile variable: heating: {unit: EUR/GJ, label: Heating, help: Enter it, minimum: 0.1} hot_water_heating: {unit: EUR/m³, label: Heating, help: Enter it, minimum: 0} hot_water: {unit: EUR/m³, label: Water, help: Enter it, minimum: 0} hot_water_tax: {unit: EUR/m³, label: Tax, help: Enter it, minimum: 0} standing: heating_network: {unit: EUR/year, label: Network, help: Enter it, minimum: 0, default: 0} metering: {unit: EUR/year, label: Metering, help: Enter it, minimum: 0, default: 0} delivery_set: {unit: EUR/year, label: Set, help: Enter it, minimum: 0, default: 0} hot_water_network: {unit: EUR/year, label: Water network, help: Enter it, minimum: 0, default: 0} other: {unit: EUR/year, label: Other, help: Enter it, minimum: 0, default: 0} """ ) with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path): with pytest.raises(ProfileValidationError, match="float"): load_profile("district_heating") # --------------------------------------------------------------------------- # 6-9: validate_values # --------------------------------------------------------------------------- # A complete, conforming set of manual contract values. _VALID_MANUAL_VALUES = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.1108, "ode": 0.0, }, "standing": { "network_fee": 9.87, "management_fee": 9.87, }, "credits": { "heffingskorting": 600.0, }, } # A complete, conforming set of tibber contract values. _VALID_TIBBER_VALUES = { "energy": { "energy_tax": 0.1108, "sell_adjust": 0.0, }, "standing": { "management_fee": 5.99, "network_fee": 9.87, }, "credits": { "heffingskorting": 600.0, }, } class TestValidateValuesManual: def test_valid_values_accepted(self) -> None: filled = validate_values("manual", dict(_VALID_MANUAL_VALUES)) # Should not raise and should return a dict. assert isinstance(filled, dict) def test_ode_default_applied_when_absent(self) -> None: """When 'ode' is not in values, the default (0) should be inserted.""" values = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.1108, # ode is absent }, "standing": { "network_fee": 9.87, "management_fee": 9.87, }, "credits": {"heffingskorting": 600.0}, } filled = validate_values("manual", values) assert filled["energy"]["ode"] == 0 def test_missing_energy_tax_raises(self) -> None: values = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, # energy_tax absent — no default }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="energy_tax"): validate_values("manual", values) def test_missing_buy_normal_raises(self) -> None: values = { "energy": { "buy": {"dal": 0.127}, # normal absent "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.1108, "ode": 0.0, }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="normal"): validate_values("manual", values) def test_wrong_type_for_energy_tax_raises(self) -> None: values = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": "not_a_number", # wrong type "ode": 0.0, }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="energy_tax"): validate_values("manual", values) def test_missing_heffingskorting_raises(self) -> None: values = { "energy": { "buy": {"normal": 0.133, "dal": 0.127}, "sell": {"normal": 0.05, "dal": 0.05}, "energy_tax": 0.1108, "ode": 0.0, }, "standing": {"network_fee": 9.87, "management_fee": 9.87}, "credits": {}, # heffingskorting absent — no default } with pytest.raises(ProfileValidationError, match="heffingskorting"): validate_values("manual", values) class TestValidateValuesTibber: def test_valid_values_accepted(self) -> None: filled = validate_values("tibber", dict(_VALID_TIBBER_VALUES)) assert isinstance(filled, dict) def test_sell_adjust_default_applied_when_absent(self) -> None: values = { "energy": { "energy_tax": 0.1108, # sell_adjust absent — has default 0 }, "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } filled = validate_values("tibber", values) assert filled["energy"]["sell_adjust"] == 0 def test_sell_fee_default_applied_when_absent(self) -> None: values = { "energy": { "energy_tax": 0.1108, "sell_adjust": 0.0, # sell_fee absent — has default 0.0248 (verkoopvergoeding) }, "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } filled = validate_values("tibber", values) assert filled["energy"]["sell_fee"] == 0.0248 def test_management_fee_default_applied_when_absent(self) -> None: values = { "energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "standing": { "network_fee": 9.87, # management_fee absent — has a default }, "credits": {"heffingskorting": 600.0}, } filled = validate_values("tibber", values) assert "management_fee" in filled["standing"] assert isinstance(filled["standing"]["management_fee"], float) def test_missing_energy_tax_raises(self) -> None: values = { "energy": {"sell_adjust": 0.0}, # energy_tax absent — no default "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="energy_tax"): validate_values("tibber", values) def test_missing_network_fee_raises(self) -> None: values = { "energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "standing": {"management_fee": 5.99}, # network_fee absent — no default "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="network_fee"): validate_values("tibber", values) def test_wrong_type_for_sell_adjust_raises(self) -> None: values = { "energy": {"energy_tax": 0.1108, "sell_adjust": "zero"}, # wrong type "standing": {"management_fee": 5.99, "network_fee": 9.87}, "credits": {"heffingskorting": 600.0}, } with pytest.raises(ProfileValidationError, match="sell_adjust"): validate_values("tibber", values) _VALID_DISTRICT_HEATING_VALUES = { "variable": { "heating": "12.3400", "hot_water_heating": "1.20", "hot_water": "2.30", "hot_water_tax": "0.10", }, "standing": { "heating_network": "11", "metering": "12", "delivery_set": "13", "hot_water_network": "14", "other": "15", }, } class TestValidateValuesDistrictHeating: def test_complete_values_are_decimal_string_snapshots(self) -> None: filled = validate_values("district_heating", _VALID_DISTRICT_HEATING_VALUES) assert filled == _VALID_DISTRICT_HEATING_VALUES assert filled["variable"]["heating"] == "12.3400" def test_minimal_values_fill_all_zero_standing_fields(self) -> None: values = {"variable": _VALID_DISTRICT_HEATING_VALUES["variable"]} filled = validate_values("district_heating", values) assert filled["standing"] == { "heating_network": "0", "metering": "0", "delivery_set": "0", "hot_water_network": "0", "other": "0", } @pytest.mark.parametrize( ("values", "match"), [ ({"variable": {"heating": "1"}}, "hot_water_heating"), ({**_VALID_DISTRICT_HEATING_VALUES, "unexpected": {}}, "unknown section"), ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "extra": "1"}}, "unknown field"), ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": "-1"}}, "non-negative"), ({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": 1.5}}, "Decimal-compatible"), ], ) def test_rejects_missing_unknown_negative_and_float(self, values, match: str) -> None: with pytest.raises(ProfileValidationError, match=match): validate_values("district_heating", values) # --------------------------------------------------------------------------- # 10: list_profiles # --------------------------------------------------------------------------- class TestListProfiles: def test_returns_list_of_dicts(self) -> None: profiles = list_profiles() assert isinstance(profiles, list) for item in profiles: assert isinstance(item, dict) def test_contains_all_pricing_kinds(self) -> None: profiles = list_profiles() kinds = {p["kind"] for p in profiles} assert "manual" in kinds assert "tibber" in kinds assert "district_heating" in kinds def test_each_entry_has_label(self) -> None: profiles = list_profiles() for p in profiles: assert "label" in p and isinstance(p["label"], str)