"""Tests for M6-T05: Tibber price refresh service (app/services/tibber_prices.py). Covers: 1. With active tibber contract + non-empty token: ``refresh_prices`` calls the client, upserts returned price points, and returns the count. 2. Idempotency: running ``refresh_prices`` twice does not double-insert rows; rows are updated in-place (``starts_at`` remains unique). 3. No active contract → no-op (returns 0, no DB writes). 4. Active contract with kind="manual" (not tibber) → no-op. 5. Token is empty string → no-op. 6. Token is whitespace-only → no-op. 7. Client errors propagate (not swallowed by the service). All Tibber API calls are intercepted via monkeypatching ``fetch_price_range`` so no real network access is needed. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from app.integrations.tibber.client import PricePoint, TibberError from app.models.energy import EnergyContract, EnergyContractVersion, TibberPrice from app.services.tibber_prices import refresh_prices # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_app_alembic_config(database_url: str) -> Config: cfg = Config("alembic_app.ini") cfg.set_main_option("sqlalchemy.url", database_url) return cfg @pytest.fixture() def energy_db(tmp_path: Path): """Temporary SQLite DB upgraded to head (has all energy tables).""" db_path = tmp_path / "tibber_prices_test.db" db_url = f"sqlite:///{db_path}" alembic_cfg = _make_app_alembic_config(db_url) command.upgrade(alembic_cfg, "head") engine = create_engine(db_url, connect_args={"check_same_thread": False}) session = Session(engine) yield session session.close() engine.dispose() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _UTC = UTC def _ts(hour: int, minute: int = 0) -> datetime: """Return a UTC datetime on 2026-06-23 at the given hour:minute.""" return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC) def _make_price_points(count: int = 3) -> list[PricePoint]: """Build a list of *count* fake PricePoint objects 15 minutes apart.""" base = _ts(10, 0) points = [] for i in range(count): starts_at = base + timedelta(minutes=15 * i) points.append( PricePoint( starts_at=starts_at, total=0.20 + i * 0.01, energy=0.16 + i * 0.01, tax=0.04, currency="EUR", level="NORMAL", resolution="QUARTER_HOURLY", ) ) return points class _FakeSettings: """Minimal settings stand-in with Tibber fields.""" def __init__(self, token: str = "valid-token", home_id: str = ""): self.tibber_api_token = token self.tibber_home_id = home_id def _create_active_tibber_contract(session: Session) -> EnergyContract: """Insert and commit an active tibber contract + one open version.""" now = datetime.now(UTC) contract = EnergyContract( name="Tibber Test", kind="tibber", active=True, currency="EUR", created_at=now, updated_at=now, ) session.add(contract) session.flush() version = EnergyContractVersion( contract_id=contract.id, effective_from=now, effective_to=None, values={"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "standing": {}, "credits": {}}, created_at=now, ) session.add(version) session.commit() return contract def _create_active_manual_contract(session: Session) -> EnergyContract: """Insert and commit an active manual contract.""" now = datetime.now(UTC) contract = EnergyContract( name="Manual Test", kind="manual", active=True, currency="EUR", created_at=now, updated_at=now, ) session.add(contract) session.flush() version = EnergyContractVersion( contract_id=contract.id, effective_from=now, effective_to=None, values={}, created_at=now, ) session.add(version) session.commit() return contract def _count_tibber_price_rows(session: Session) -> int: """Return the current number of rows in tibber_price.""" return len(session.execute(select(TibberPrice)).scalars().all()) # --------------------------------------------------------------------------- # Test: happy path (active tibber contract + valid token) # --------------------------------------------------------------------------- def test_refresh_prices_upserts_price_points(energy_db, monkeypatch): """Active tibber contract + token → price points are upserted and count returned.""" session = energy_db _create_active_tibber_contract(session) points = _make_price_points(3) monkeypatch.setattr( "app.services.tibber_prices.fetch_price_range", lambda token, home_id_or_none: points, ) count = refresh_prices(session, _FakeSettings()) assert count == 3 rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all() assert len(rows) == 3 for row, point in zip(rows, points): # starts_at is stored UTC-aware; SQLite may return naive — normalise. stored_starts_at = row.starts_at if stored_starts_at.tzinfo is None: stored_starts_at = stored_starts_at.replace(tzinfo=UTC) assert stored_starts_at == point.starts_at assert row.total == pytest.approx(point.total) assert row.energy == pytest.approx(point.energy) assert row.tax == pytest.approx(point.tax) assert row.currency == point.currency assert row.resolution == "QUARTER_HOURLY" # --------------------------------------------------------------------------- # Test: idempotency (running twice must not double-insert) # --------------------------------------------------------------------------- def test_refresh_prices_is_idempotent(energy_db, monkeypatch): """Running refresh_prices twice does not increase the row count.""" session = energy_db _create_active_tibber_contract(session) points = _make_price_points(4) call_count = {"n": 0} def _fake_fetch(token, home_id_or_none): call_count["n"] += 1 return points monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count1 = refresh_prices(session, _FakeSettings()) count2 = refresh_prices(session, _FakeSettings()) assert call_count["n"] == 2 # fetch was called twice assert count1 == 4 assert count2 == 4 # same number of "upserted" (all updated in-place) # DB must still have exactly 4 rows (not 8). assert _count_tibber_price_rows(session) == 4 def test_refresh_prices_idempotent_updates_fields(energy_db, monkeypatch): """On second run, existing rows are updated (not duplicated).""" session = energy_db _create_active_tibber_contract(session) points = _make_price_points(2) monkeypatch.setattr( "app.services.tibber_prices.fetch_price_range", lambda token, home_id_or_none: points, ) refresh_prices(session, _FakeSettings()) # Modify the points to simulate Tibber updating a price. updated_points = [ PricePoint( starts_at=p.starts_at, total=p.total + 0.10, # price changed energy=p.energy + 0.08, tax=p.tax + 0.02, currency=p.currency, level="EXPENSIVE", resolution=p.resolution, ) for p in points ] monkeypatch.setattr( "app.services.tibber_prices.fetch_price_range", lambda token, home_id_or_none: updated_points, ) refresh_prices(session, _FakeSettings()) rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all() assert len(rows) == 2 # still 2, not 4 for row, up in zip(rows, updated_points): assert row.total == pytest.approx(up.total) assert row.level == "EXPENSIVE" # --------------------------------------------------------------------------- # Test: no-op conditions # --------------------------------------------------------------------------- def test_refresh_prices_noop_no_active_contract(energy_db, monkeypatch): """No active contract at all → no-op (returns 0, no DB writes).""" session = energy_db fetch_called = {"called": False} def _fake_fetch(token, home_id_or_none): fetch_called["called"] = True return _make_price_points(3) monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count = refresh_prices(session, _FakeSettings()) assert count == 0 assert not fetch_called["called"] assert _count_tibber_price_rows(session) == 0 def test_refresh_prices_noop_manual_contract(energy_db, monkeypatch): """Active manual contract (not tibber) → no-op.""" session = energy_db _create_active_manual_contract(session) fetch_called = {"called": False} def _fake_fetch(token, home_id_or_none): fetch_called["called"] = True return _make_price_points(3) monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count = refresh_prices(session, _FakeSettings()) assert count == 0 assert not fetch_called["called"] assert _count_tibber_price_rows(session) == 0 def test_refresh_prices_noop_empty_token(energy_db, monkeypatch): """Empty token → no-op even if tibber contract is active.""" session = energy_db _create_active_tibber_contract(session) fetch_called = {"called": False} def _fake_fetch(token, home_id_or_none): fetch_called["called"] = True return _make_price_points(3) monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count = refresh_prices(session, _FakeSettings(token="")) assert count == 0 assert not fetch_called["called"] assert _count_tibber_price_rows(session) == 0 def test_refresh_prices_noop_whitespace_token(energy_db, monkeypatch): """Whitespace-only token → no-op (treated as not configured).""" session = energy_db _create_active_tibber_contract(session) fetch_called = {"called": False} def _fake_fetch(token, home_id_or_none): fetch_called["called"] = True return _make_price_points(2) monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count = refresh_prices(session, _FakeSettings(token=" ")) assert count == 0 assert not fetch_called["called"] # --------------------------------------------------------------------------- # Test: inactive tibber contract # --------------------------------------------------------------------------- def test_refresh_prices_noop_inactive_tibber_contract(energy_db, monkeypatch): """Inactive tibber contract → no-op (active flag is False).""" session = energy_db # Create a tibber contract but do NOT activate it. now = datetime.now(UTC) contract = EnergyContract( name="Inactive Tibber", kind="tibber", active=False, # <-- inactive currency="EUR", created_at=now, updated_at=now, ) session.add(contract) session.commit() fetch_called = {"called": False} def _fake_fetch(token, home_id_or_none): fetch_called["called"] = True return _make_price_points(2) monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch) count = refresh_prices(session, _FakeSettings()) assert count == 0 assert not fetch_called["called"] # --------------------------------------------------------------------------- # Test: client errors propagate # --------------------------------------------------------------------------- def test_refresh_prices_propagates_client_error(energy_db, monkeypatch): """TibberError from the client is NOT swallowed by the service.""" session = energy_db _create_active_tibber_contract(session) def _failing_fetch(token, home_id_or_none): raise TibberError("simulated network failure") monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _failing_fetch) with pytest.raises(TibberError): refresh_prices(session, _FakeSettings()) # No rows should have been written. assert _count_tibber_price_rows(session) == 0