Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.
Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:
sell = total − energy_tax − sell_fee − sell_adjust
`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
434 lines
16 KiB
Python
434 lines
16 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 (
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_manual_and_tibber(self) -> None:
|
|
profiles = list_profiles()
|
|
kinds = {p["kind"] for p in profiles}
|
|
assert "manual" in kinds
|
|
assert "tibber" 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)
|