278 files
This commit is contained in:
@@ -1,18 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
from homeassistant.helpers.selector import SelectSelector
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
from custom_components.seatemperatures import async_migrate_entry
|
||||
from custom_components.seatemperatures import _async_fetch, async_migrate_entry
|
||||
from custom_components.seatemperatures.api import (
|
||||
MAP_LOCATIONS_TTL,
|
||||
REQUEST_TIMEOUT,
|
||||
SeaTemperatureAPI,
|
||||
SeaTemperatureError,
|
||||
parse_map_locations,
|
||||
parse_search_results,
|
||||
)
|
||||
from custom_components.seatemperatures.config_flow import SeaTemperatureConfigFlow
|
||||
from custom_components.seatemperatures.config_flow import (
|
||||
CONTINENT_NAMES,
|
||||
SeaTemperatureConfigFlow,
|
||||
)
|
||||
from custom_components.seatemperatures.const import (
|
||||
CONF_AREA,
|
||||
CONF_CONTINENT,
|
||||
@@ -118,34 +127,6 @@ async def test_validate_location_path() -> None:
|
||||
validate_location_path("/../../etc/passwd")
|
||||
|
||||
|
||||
async def test_parse_search_results() -> None:
|
||||
"""Search results should be normalized into path-based location records."""
|
||||
data = {
|
||||
"results": [
|
||||
{
|
||||
"name": "Copenhagen",
|
||||
"country": "Denmark",
|
||||
"region": "Europe",
|
||||
"path": "/europe/denmark/copenhagen/",
|
||||
"area": "Denmark, Europe",
|
||||
},
|
||||
{"name": "Broken", "path": "https://example.com/not-allowed"},
|
||||
]
|
||||
}
|
||||
|
||||
results = parse_search_results(data)
|
||||
|
||||
assert results == [
|
||||
{
|
||||
"name": "Copenhagen",
|
||||
"country": "Denmark",
|
||||
"region": "Europe",
|
||||
"area": "Denmark, Europe",
|
||||
"path": "/europe/denmark/copenhagen/",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_parse_map_locations() -> None:
|
||||
"""Legacy map-locations payload should support place_id migration."""
|
||||
data = {
|
||||
@@ -264,7 +245,7 @@ async def test_config_flow_stores_path_based_location(mock_hass) -> None:
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(SeaTemperatureAPI, "_get_map_locations", AsyncMock(return_value=locations_cache)):
|
||||
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)):
|
||||
user_result = await flow.async_step_user(None)
|
||||
assert user_result["type"] == "form"
|
||||
assert user_result["step_id"] == "user"
|
||||
@@ -313,17 +294,26 @@ async def test_async_migrate_entry_from_place_id(mock_hass) -> None:
|
||||
"path": "/africa/algeria/ain-el-turk/",
|
||||
}
|
||||
),
|
||||
):
|
||||
), patch(
|
||||
# The unique_id migration is exercised in test_entry.py; here it would
|
||||
# only drag a real entity registry into a mocked hass.
|
||||
"custom_components.seatemperatures._async_migrate_unique_ids",
|
||||
AsyncMock(),
|
||||
) as migrate_unique_ids:
|
||||
migrated = await async_migrate_entry(mock_hass, entry)
|
||||
|
||||
assert migrated is True
|
||||
mock_hass.config_entries.async_update_entry.assert_called_once()
|
||||
_, kwargs = mock_hass.config_entries.async_update_entry.call_args
|
||||
migrate_unique_ids.assert_awaited_once()
|
||||
assert mock_hass.config_entries.async_update_entry.call_count == 2
|
||||
_, kwargs = mock_hass.config_entries.async_update_entry.call_args_list[0]
|
||||
assert kwargs["data"][CONF_PLACE] == "Ain El Turk"
|
||||
assert kwargs["data"][CONF_COUNTRY] == "Algeria"
|
||||
assert kwargs["data"][CONF_PATH] == "/africa/algeria/ain-el-turk/"
|
||||
assert kwargs["unique_id"] == "/africa/algeria/ain-el-turk/"
|
||||
assert kwargs["version"] == 2
|
||||
assert mock_hass.config_entries.async_update_entry.call_args_list[1].kwargs == {
|
||||
"version": 4
|
||||
}
|
||||
|
||||
|
||||
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
|
||||
@@ -335,3 +325,254 @@ async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_has
|
||||
|
||||
assert migrated is False
|
||||
mock_hass.config_entries.async_update_entry.assert_not_called()
|
||||
|
||||
|
||||
async def test_config_flow_reshows_the_form_when_the_site_is_unreachable(mock_hass) -> None:
|
||||
"""An unreachable site must show a translated error, not an abort."""
|
||||
flow = SeaTemperatureConfigFlow()
|
||||
flow.hass = mock_hass
|
||||
|
||||
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=None)):
|
||||
result = await flow.async_step_user(None)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_config_flow_retries_after_a_failed_fetch(mock_hass) -> None:
|
||||
"""Submitting the error form should fetch again instead of raising."""
|
||||
flow = SeaTemperatureConfigFlow()
|
||||
flow.hass = mock_hass
|
||||
|
||||
locations_cache = {
|
||||
"sea-1": {
|
||||
"name": "Copenhagen",
|
||||
"country": "Denmark",
|
||||
"area": "",
|
||||
"path": "/europe/denmark/copenhagen/",
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
SeaTemperatureAPI,
|
||||
"get_map_locations",
|
||||
AsyncMock(side_effect=[None, locations_cache]),
|
||||
):
|
||||
await flow.async_step_user(None)
|
||||
# The retry form has no continent field, so it submits an empty mapping.
|
||||
result = await flow.async_step_user({})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert not result.get("errors")
|
||||
assert flow._continents == ["Europe"]
|
||||
|
||||
|
||||
# The nine first path segments /api/map-locations.json actually publishes.
|
||||
PUBLISHED_CONTINENT_SLUGS = {
|
||||
"africa",
|
||||
"antarctica",
|
||||
"asia",
|
||||
"australia-and-oceania",
|
||||
"central-america-and-the-caribbean",
|
||||
"europe",
|
||||
"middle-east",
|
||||
"north-america",
|
||||
"south-america",
|
||||
}
|
||||
|
||||
|
||||
async def test_continent_map_matches_the_published_slugs() -> None:
|
||||
"""A stale slug adds a continent no location ever resolves to."""
|
||||
assert set(CONTINENT_NAMES) == PUBLISHED_CONTINENT_SLUGS
|
||||
|
||||
|
||||
async def test_continent_names_read_as_names() -> None:
|
||||
"""Every published slug should map to one readable spelling."""
|
||||
flow = SeaTemperatureConfigFlow()
|
||||
|
||||
names = {flow._get_continent_name(slug) for slug in PUBLISHED_CONTINENT_SLUGS}
|
||||
|
||||
assert names == {
|
||||
"Africa",
|
||||
"Antarctica",
|
||||
"Asia",
|
||||
"Australia and Oceania",
|
||||
"Central America and the Caribbean",
|
||||
"Europe",
|
||||
"Middle East",
|
||||
"North America",
|
||||
"South America",
|
||||
}
|
||||
|
||||
|
||||
async def test_unknown_continent_slug_keeps_joining_words_small() -> None:
|
||||
"""A slug the site adds later still has to read as a name."""
|
||||
flow = SeaTemperatureConfigFlow()
|
||||
|
||||
assert flow._get_continent_name("islands-of-the-atlantic") == "Islands of the Atlantic"
|
||||
|
||||
|
||||
def _selector_for(schema, key: str) -> SelectSelector:
|
||||
"""Pull the selector a schema uses for one key."""
|
||||
for marker, validator in schema.schema.items():
|
||||
if str(marker) == key:
|
||||
return validator
|
||||
raise AssertionError(f"{key} is not in the schema")
|
||||
|
||||
|
||||
async def test_every_step_offers_a_searchable_dropdown(mock_hass) -> None:
|
||||
"""4,477 places in a plain list cannot be picked from without a search."""
|
||||
flow = SeaTemperatureConfigFlow()
|
||||
flow.hass = mock_hass
|
||||
|
||||
locations_cache = {
|
||||
f"sea-{index}": {
|
||||
"name": f"Place {index}",
|
||||
"country": "United States",
|
||||
"area": "",
|
||||
"path": f"/north-america/united-states/place-{index}/",
|
||||
}
|
||||
for index in range(3)
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)
|
||||
):
|
||||
user_result = await flow.async_step_user(None)
|
||||
await flow.async_step_user({CONF_CONTINENT: "North America"})
|
||||
country_result = await flow.async_step_country(None)
|
||||
await flow.async_step_country({CONF_COUNTRY: "United States"})
|
||||
place_result = await flow.async_step_place(None)
|
||||
|
||||
for result, key in (
|
||||
(user_result, CONF_CONTINENT),
|
||||
(country_result, CONF_COUNTRY),
|
||||
(place_result, CONF_PLACE),
|
||||
):
|
||||
selector = _selector_for(result["data_schema"], key)
|
||||
assert isinstance(selector, SelectSelector), key
|
||||
assert selector.config["mode"] == "dropdown", key
|
||||
|
||||
|
||||
async def test_get_temperatures_does_not_log_its_own_error(
|
||||
mock_hass, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The coordinator logs a failed refresh, so the API layer must not."""
|
||||
api = SeaTemperatureAPI(mock_hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.async_get_clientsession"
|
||||
) as mock_session:
|
||||
mock_session.return_value.get.side_effect = aiohttp.ClientError("boom")
|
||||
|
||||
with caplog.at_level(logging.DEBUG), pytest.raises(SeaTemperatureError) as excinfo:
|
||||
await api.get_temperatures("/europe/denmark/copenhagen/")
|
||||
|
||||
assert "boom" in str(excinfo.value)
|
||||
assert [record for record in caplog.records if record.levelno >= logging.ERROR] == []
|
||||
|
||||
|
||||
async def test_get_temperatures_rejects_an_invalid_path(mock_hass) -> None:
|
||||
"""An unusable path is a failed refresh, not a silent None."""
|
||||
api = SeaTemperatureAPI(mock_hass)
|
||||
|
||||
with pytest.raises(SeaTemperatureError):
|
||||
await api.get_temperatures("https://example.com/evil/")
|
||||
|
||||
|
||||
async def test_fetch_turns_an_api_error_into_update_failed() -> None:
|
||||
"""The coordinator has to see one UpdateFailed carrying the reason."""
|
||||
api = MagicMock()
|
||||
api.get_temperatures = AsyncMock(side_effect=SeaTemperatureError("upstream is down"))
|
||||
|
||||
with pytest.raises(UpdateFailed, match="upstream is down"):
|
||||
await _async_fetch(api, "/europe/denmark/copenhagen/", "Copenhagen")
|
||||
|
||||
|
||||
async def test_every_request_carries_an_explicit_timeout(mock_hass) -> None:
|
||||
"""A host that accepts the connection but never answers must not hang a refresh."""
|
||||
api = SeaTemperatureAPI(mock_hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.async_get_clientsession"
|
||||
) as mock_session:
|
||||
get = mock_session.return_value.get
|
||||
mock_response = AsyncMock()
|
||||
mock_response.text.return_value = load_fixture("copenhagen.html")
|
||||
mock_response.json.return_value = {"locations": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
get.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
await api.get_temperatures("/europe/denmark/copenhagen/")
|
||||
await api.get_map_locations()
|
||||
|
||||
assert get.call_count == 2
|
||||
for call in get.call_args_list:
|
||||
assert call.kwargs["timeout"] is REQUEST_TIMEOUT
|
||||
|
||||
assert REQUEST_TIMEOUT.total is not None
|
||||
assert REQUEST_TIMEOUT.total > 0
|
||||
|
||||
|
||||
async def test_map_locations_are_cached_within_the_ttl(mock_hass) -> None:
|
||||
"""Repeated lookups inside the TTL must not refetch 19k rows."""
|
||||
api = SeaTemperatureAPI(mock_hass)
|
||||
payload = {
|
||||
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.async_get_clientsession"
|
||||
) as mock_session:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.json.return_value = payload
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
first = await api.get_map_locations()
|
||||
second = await api.get_map_locations()
|
||||
|
||||
assert mock_session.return_value.get.call_count == 1
|
||||
|
||||
assert first == second
|
||||
assert "sea-1" in first
|
||||
|
||||
|
||||
async def test_map_locations_are_refetched_once_the_ttl_expires(mock_hass) -> None:
|
||||
"""A beach added upstream must appear without restarting Home Assistant."""
|
||||
api = SeaTemperatureAPI(mock_hass)
|
||||
first_payload = {
|
||||
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
|
||||
}
|
||||
second_payload = {
|
||||
"locations": [
|
||||
["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"],
|
||||
["sea-2", "Sylt", "Germany", "", "/europe/germany/island-of-sylt/"],
|
||||
]
|
||||
}
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.async_get_clientsession"
|
||||
) as mock_session:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.json.side_effect = [first_payload, second_payload]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.time.monotonic", return_value=0.0
|
||||
):
|
||||
first = await api.get_map_locations()
|
||||
|
||||
with patch(
|
||||
"custom_components.seatemperatures.api.time.monotonic",
|
||||
return_value=MAP_LOCATIONS_TTL + 1.0,
|
||||
):
|
||||
second = await api.get_map_locations()
|
||||
|
||||
assert mock_session.return_value.get.call_count == 2
|
||||
|
||||
assert "sea-2" not in first
|
||||
assert "sea-2" in second
|
||||
|
||||
Reference in New Issue
Block a user