562 lines
21 KiB
Python
562 lines
21 KiB
Python
"""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)
|