2026-10-02 09:19:25 +08:00
|
|
|
from unittest.mock import Mock
|
|
|
|
|
|
|
|
|
|
from pigway_frame_flow.models import WeatherConfig
|
|
|
|
|
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transport():
|
|
|
|
|
response = Mock()
|
|
|
|
|
response.json.return_value = {
|
|
|
|
|
"current": {
|
|
|
|
|
"temperature_2m": 24,
|
|
|
|
|
"weather_code": 3,
|
|
|
|
|
"time": "2026-10-02T10:00",
|
|
|
|
|
},
|
|
|
|
|
"daily": {
|
|
|
|
|
"time": ["2026-10-02"],
|
|
|
|
|
"weather_code": [61],
|
|
|
|
|
"temperature_2m_min": [20],
|
|
|
|
|
"temperature_2m_max": [26],
|
|
|
|
|
"precipitation_probability_max": [80],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return Mock(return_value=response)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_current_forecast_and_cache(service):
|
|
|
|
|
get = transport()
|
|
|
|
|
provider = ConfigurableWeatherProvider(service.db, get)
|
|
|
|
|
provider.configure(WeatherConfig(provider="open-meteo"))
|
|
|
|
|
assert provider.get_current()["temperature"] == "24°C"
|
|
|
|
|
assert provider.get_forecast()[0]["rain_probability"] == 80
|
|
|
|
|
assert get.call_count == 1
|
|
|
|
|
assert get.call_args.kwargs["timeout"] == 8
|
|
|
|
|
assert not get.call_args.kwargs["allow_redirects"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_weather_stale_and_error_backoff(service):
|
|
|
|
|
get = transport()
|
|
|
|
|
provider = ConfigurableWeatherProvider(service.db, get)
|
|
|
|
|
provider.configure(WeatherConfig(provider="open-meteo"))
|
|
|
|
|
provider.get_current()
|
|
|
|
|
provider.next_fetch = 0
|
|
|
|
|
get.side_effect = TimeoutError("private transport detail")
|
|
|
|
|
assert provider.get_current()["stale"]
|
|
|
|
|
assert provider.get_current()["temperature"] == "24°C"
|
|
|
|
|
assert get.call_count == 2
|
|
|
|
|
assert "private" not in str(provider.get_current())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unavailable_never_masquerades_as_mock(service):
|
|
|
|
|
get = Mock(side_effect=TimeoutError("secret"))
|
|
|
|
|
provider = ConfigurableWeatherProvider(service.db, get)
|
|
|
|
|
provider.configure(WeatherConfig(provider="open-meteo"))
|
|
|
|
|
current = provider.get_current()
|
|
|
|
|
assert not current["mock"] and not current["available"] and current["stale"]
|
|
|
|
|
assert "secret" not in str(current)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_provider_settings_persist_and_validate(client, service):
|
|
|
|
|
assert client.get("/api/v1/providers/weather").json()["current"]["mock"]
|
|
|
|
|
assert (
|
|
|
|
|
client.put("/api/v1/settings/weather", json={"latitude": 100}).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
"/api/v1/settings/weather",
|
|
|
|
|
json={
|
|
|
|
|
"provider": "mock",
|
|
|
|
|
"location": "Test town",
|
|
|
|
|
"latitude": 30,
|
|
|
|
|
"longitude": 121,
|
|
|
|
|
},
|
|
|
|
|
).status_code
|
|
|
|
|
== 200
|
|
|
|
|
)
|
|
|
|
|
assert ConfigurableWeatherProvider(service.db).config().location == "Test town"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_manual_job_run_and_preview_are_distinct(client, service):
|
|
|
|
|
job = client.post(
|
|
|
|
|
"/api/v1/jobs",
|
|
|
|
|
json={
|
|
|
|
|
"name": "Preview",
|
|
|
|
|
"enabled": False,
|
|
|
|
|
"schedule": {"type": "daily", "time": "07:00"},
|
|
|
|
|
"source": {"type": "template", "template": "quote", "data": {}},
|
|
|
|
|
},
|
|
|
|
|
).json()
|
|
|
|
|
assert client.get("/api/v1/jobs/" + job["id"]).status_code == 200
|
|
|
|
|
preview = client.post("/api/v1/jobs/" + job["id"] + "/preview").json()
|
|
|
|
|
service.process_one()
|
|
|
|
|
assert service.operation(preview["operation_id"])["status"] == "COMPLETED"
|
|
|
|
|
assert service.registry.get("living-room").client.uploads == 0
|
|
|
|
|
service.settings.mock = False
|
|
|
|
|
assert client.post("/api/v1/jobs/" + job["id"] + "/run").status_code == 403
|
|
|
|
|
service.settings.mock = True
|
|
|
|
|
first = client.post(
|
|
|
|
|
"/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"}
|
|
|
|
|
)
|
|
|
|
|
second = client.post(
|
|
|
|
|
"/api/v1/jobs/" + job["id"] + "/run", headers={"Idempotency-Key": "manual-test"}
|
|
|
|
|
)
|
|
|
|
|
assert first.status_code == 202 and first.json() == second.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_image_url_allowlist_is_persistent_and_restrictive(client, service):
|
|
|
|
|
assert client.get("/api/v1/settings/image-url").json()["allowed_hosts"] == []
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
"/api/v1/settings/image-url",
|
|
|
|
|
json={"allowed_hosts": ["https://example.com"]},
|
|
|
|
|
).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
"/api/v1/settings/image-url", json={"allowed_hosts": ["*.example.com"]}
|
|
|
|
|
).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
assert client.put(
|
|
|
|
|
"/api/v1/settings/image-url",
|
|
|
|
|
json={"allowed_hosts": ["EXAMPLE.com", "example.com"]},
|
|
|
|
|
).json()["allowed_hosts"] == ["example.com"]
|
|
|
|
|
assert service.settings.image_url_hosts == ["example.com"]
|
|
|
|
|
assert service.db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
|
2026-10-02 09:27:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_weather_template_resolves_forecast(service):
|
|
|
|
|
provider = ConfigurableWeatherProvider(service.db, transport())
|
|
|
|
|
provider.configure(WeatherConfig(provider="open-meteo"))
|
|
|
|
|
service.renderer.weather = provider
|
|
|
|
|
snapshot = service.renderer.resolve("weather", {})
|
|
|
|
|
assert snapshot["forecast"][0]["maximum_c"] == 26
|
|
|
|
|
assert snapshot["weather_attribution"] == "Open-Meteo / CC BY 4.0"
|
|
|
|
|
assert service.renderer.render("weather", snapshot)
|