M8-R06: refresh Tibber prices after startup and configuration
This commit is contained in:
@@ -946,6 +946,55 @@ def test_get_config_tibber_api_token_value_masked_after_save(
|
||||
assert "some-tibber-token" not in resp_after.text
|
||||
|
||||
|
||||
def test_put_tibber_config_triggers_refresh_only_for_active_tibber_contract(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
"""Saved Tibber credentials request a refresh only when their contract is active."""
|
||||
_login(client)
|
||||
conn = sqlite3.connect(test_database_urls["app_path"])
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO energy_contract (name, kind, scope, active, currency, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
|
||||
("Tibber", "tibber", "electricity", True, "EUR"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"TIBBER_API_TOKEN": "new-secret-token"})},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
trigger.assert_called_once()
|
||||
|
||||
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"TIBBER_API_TOKEN": ""})},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
trigger.assert_not_called()
|
||||
|
||||
|
||||
def test_put_tibber_config_does_not_refresh_without_active_tibber_contract(client: TestClient) -> None:
|
||||
_login(client)
|
||||
|
||||
with patch("app.api.routes.api.config.trigger_tibber_refresh") as trigger:
|
||||
response = client.put(
|
||||
"/api/config",
|
||||
json={"updates": _full_config_payload({"TIBBER_HOME_ID": "new-home-id"})},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
trigger.assert_not_called()
|
||||
|
||||
|
||||
def test_post_mqtt_test_uses_db_broker_host(
|
||||
client: TestClient, test_database_urls
|
||||
) -> None:
|
||||
|
||||
@@ -48,6 +48,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -614,6 +615,52 @@ def test_activate_contract_mutual_exclusion(contracts_client):
|
||||
assert active_contracts[0].id == id_b
|
||||
|
||||
|
||||
def test_activating_tibber_contract_triggers_refresh_only_after_commit(contracts_client):
|
||||
"""Inactive→active Tibber is the only contract transition that requests refresh."""
|
||||
client, engine = contracts_client
|
||||
_login(client)
|
||||
created = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_tibber_payload(),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
contract_id = created.json()["id"]
|
||||
|
||||
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
|
||||
response = client.patch(
|
||||
f"/api/energy/contracts/{contract_id}",
|
||||
json={"active": True},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert trigger.call_count == 1
|
||||
with Session(engine) as session:
|
||||
assert session.get(EnergyContract, contract_id).active is True
|
||||
|
||||
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
|
||||
response = client.patch(
|
||||
f"/api/energy/contracts/{contract_id}",
|
||||
json={"active": True},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
trigger.assert_not_called()
|
||||
|
||||
manual = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_manual_payload(),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
).json()
|
||||
with patch("app.api.routes.api.energy_contracts.trigger_tibber_refresh") as trigger:
|
||||
response = client.patch(
|
||||
f"/api/energy/contracts/{manual['id']}",
|
||||
json={"active": True},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
trigger.assert_not_called()
|
||||
|
||||
|
||||
def test_deactivate_contract(contracts_client):
|
||||
"""PATCH active=false deactivates the contract without touching others."""
|
||||
client, _ = contracts_client
|
||||
|
||||
@@ -152,3 +152,40 @@ def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value(
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def test_lifespan_schedules_immediate_and_hourly_tibber_refresh(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The single Tibber interval job starts immediately while retaining its hourly trigger."""
|
||||
import app.main as main
|
||||
|
||||
app_database_url = _prepare_app_db(tmp_path)
|
||||
added_jobs = []
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **_kwargs): pass
|
||||
def add_job(self, func, **kwargs): added_jobs.append((func, kwargs))
|
||||
def start(self): pass
|
||||
def shutdown(self, **_kwargs): pass
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setattr(main, "BackgroundScheduler", _Scheduler)
|
||||
monkeypatch.setattr(main.mqtt_manager, "connect", lambda _settings: None)
|
||||
monkeypatch.setattr(main.mqtt_manager, "disconnect", lambda: None)
|
||||
monkeypatch.setattr(main, "apply_dsmr_subscription", lambda _settings: None)
|
||||
monkeypatch.setattr(main.warmtelink_worker_manager, "start", lambda: None)
|
||||
monkeypatch.setattr(main.warmtelink_worker_manager, "shutdown", lambda: None)
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
anyio.run(_run_lifespan, create_app())
|
||||
|
||||
tibber_jobs = [kwargs for func, kwargs in added_jobs if func is main._run_scheduled_tibber_refresh]
|
||||
assert len(tibber_jobs) == 1
|
||||
assert tibber_jobs[0]["id"] == "tibber-refresh"
|
||||
assert tibber_jobs[0]["max_instances"] == 1
|
||||
assert tibber_jobs[0]["next_run_time"] is not None
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
@@ -27,7 +27,7 @@ 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
|
||||
from app.services.tibber_prices import refresh_prices, run_tibber_refresh_best_effort, trigger_tibber_refresh
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -392,3 +392,55 @@ def test_refresh_prices_propagates_client_error(energy_db, monkeypatch):
|
||||
|
||||
# No rows should have been written.
|
||||
assert _count_tibber_price_rows(session) == 0
|
||||
|
||||
|
||||
def test_best_effort_refresh_uses_isolated_session_and_sanitises_failure(monkeypatch, caplog):
|
||||
"""Background failures close their own session and never log supplied secrets."""
|
||||
import app.db as db_module
|
||||
import app.services.config_page as config_page
|
||||
import app.services.tibber_prices as tibber_prices
|
||||
|
||||
token = "secret-token-must-not-appear"
|
||||
home_id = "secret-home-must-not-appear"
|
||||
settings = _FakeSettings(token=token, home_id=home_id)
|
||||
|
||||
class _Session:
|
||||
rolled_back = False
|
||||
closed = False
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
session = _Session()
|
||||
monkeypatch.setattr(db_module, "get_session_local", lambda: lambda: session)
|
||||
monkeypatch.setattr(config_page, "build_runtime_settings", lambda *_args: settings)
|
||||
monkeypatch.setattr(tibber_prices, "refresh_prices", lambda *_args: (_ for _ in ()).throw(RuntimeError(token)))
|
||||
|
||||
assert run_tibber_refresh_best_effort() is True
|
||||
assert session.rolled_back is True
|
||||
assert session.closed is True
|
||||
assert token not in caplog.text
|
||||
assert home_id not in caplog.text
|
||||
|
||||
|
||||
def test_trigger_tibber_refresh_starts_daemon_without_running_inline(monkeypatch):
|
||||
"""API triggers are non-blocking; the worker owns the eventual refresh."""
|
||||
import app.services.tibber_prices as tibber_prices
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Thread:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def start(self):
|
||||
captured["started"] = True
|
||||
|
||||
monkeypatch.setattr(tibber_prices, "Thread", _Thread)
|
||||
trigger_tibber_refresh()
|
||||
assert captured["started"] is True
|
||||
assert captured["daemon"] is True
|
||||
assert captured["target"] is run_tibber_refresh_best_effort
|
||||
|
||||
Reference in New Issue
Block a user