diff --git a/.strict-typing b/.strict-typing index b4f3b14ff01f..0ea45570fff4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -173,6 +173,7 @@ homeassistant.components.devolo_home_control.* homeassistant.components.devolo_home_network.* homeassistant.components.dhcp.* homeassistant.components.diagnostics.* +homeassistant.components.discogs.* homeassistant.components.discovergy.* homeassistant.components.dlna_dmr.* homeassistant.components.dlna_dms.* diff --git a/CODEOWNERS b/CODEOWNERS index eb25e9ba74d4..ab4457e0da6d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -403,6 +403,7 @@ CLAUDE.md @home-assistant/core /tests/components/diagnostics/ @home-assistant/core /homeassistant/components/digital_ocean/ @fabaff /homeassistant/components/discogs/ @thibmaek +/tests/components/discogs/ @thibmaek /homeassistant/components/discord/ @tkdrob /tests/components/discord/ @tkdrob /homeassistant/components/discovergy/ @jpbede diff --git a/homeassistant/components/discogs/__init__.py b/homeassistant/components/discogs/__init__.py index 90a17763ea65..7365f531a7db 100644 --- a/homeassistant/components/discogs/__init__.py +++ b/homeassistant/components/discogs/__init__.py @@ -1 +1,39 @@ -"""The discogs component.""" +"""The Discogs integration.""" + +import discogs_client + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE + +from .const import PLATFORMS + +type DiscogsConfigEntry = ConfigEntry[discogs_client.Client] + + +async def async_setup_entry(hass: HomeAssistant, entry: DiscogsConfigEntry) -> bool: + """Set up Discogs from a config entry.""" + + def _setup_client() -> discogs_client.Client: + client = discogs_client.Client( + SERVER_SOFTWARE, user_token=entry.data[CONF_TOKEN] + ) + client.identity() + return client + + try: + client = await hass.async_add_executor_job(_setup_client) + except discogs_client.exceptions.HTTPError as err: + raise ConfigEntryNotReady(f"Error communicating with Discogs: {err}") from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DiscogsConfigEntry) -> bool: + """Unload Discogs config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/discogs/config_flow.py b/homeassistant/components/discogs/config_flow.py new file mode 100644 index 000000000000..8e084e7721f8 --- /dev/null +++ b/homeassistant/components/discogs/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for Discogs.""" + +from typing import Any, override + +import discogs_client +import probatio +import requests + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE + +from .const import DOMAIN, LOGGER + +CONFIG_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_TOKEN): str, + } +) + + +class DiscogsConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Discogs.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + user_id, username, errors = await self.hass.async_add_executor_job( + _validate_token, user_input[CONF_TOKEN] + ) + if not errors: + await self.async_set_unique_id(str(user_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=username, + data={CONF_TOKEN: user_input[CONF_TOKEN]}, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from YAML configuration.""" + user_id, username, errors = await self.hass.async_add_executor_job( + _validate_token, import_data[CONF_TOKEN] + ) + if errors: + return self.async_abort(reason=errors["base"]) + await self.async_set_unique_id(str(user_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=import_data.get("name") or username, + data={CONF_TOKEN: import_data[CONF_TOKEN]}, + ) + + +def _validate_token(token: str) -> tuple[int | None, str, dict[str, str]]: + """Validate the token and return the user ID, username, and errors.""" + errors: dict[str, str] = {} + user_id = None + username = "" + try: + client = discogs_client.Client(SERVER_SOFTWARE, user_token=token) + identity = client.identity() + user_id = identity.id + username = identity.name + except discogs_client.exceptions.HTTPError as err: + if err.status_code == 401: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except requests.RequestException: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error validating Discogs token") + errors["base"] = "unknown" + return user_id, username, errors diff --git a/homeassistant/components/discogs/const.py b/homeassistant/components/discogs/const.py new file mode 100644 index 000000000000..c9c1d19cdf4c --- /dev/null +++ b/homeassistant/components/discogs/const.py @@ -0,0 +1,11 @@ +"""Constants for Discogs.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "discogs" +PLATFORMS = [Platform.SENSOR] +DEFAULT_NAME = "Discogs" diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index f724b4bc6fd1..925978daaeed 100644 --- a/homeassistant/components/discogs/manifest.json +++ b/homeassistant/components/discogs/manifest.json @@ -2,7 +2,9 @@ "domain": "discogs", "name": "Discogs", "codeowners": ["@thibmaek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discogs", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["discogs_client"], "quality_scale": "legacy", diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 76b35ac4c05f..dbe00be26c3f 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -1,7 +1,6 @@ """Show the amount of records in a user's Discogs collection.""" from datetime import timedelta -import logging import random from typing import Any, override @@ -13,19 +12,23 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_NAME, DOMAIN ATTR_IDENTITY = "identity" -DEFAULT_NAME = "Discogs" - ICON_RECORD = "mdi:album" ICON_PLAYER = "mdi:record-player" UNIT_RECORDS = "records" @@ -39,19 +42,19 @@ SENSOR_RANDOM_RECORD_TYPE = "random_record" SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_COLLECTION_TYPE, - name="Collection", + translation_key="collection", icon=ICON_RECORD, native_unit_of_measurement=UNIT_RECORDS, ), SensorEntityDescription( key=SENSOR_WANTLIST_TYPE, - name="Wantlist", + translation_key="wantlist", icon=ICON_RECORD, native_unit_of_measurement=UNIT_RECORDS, ), SensorEntityDescription( key=SENSOR_RANDOM_RECORD_TYPE, - name="Random Record", + translation_key="random_record", icon=ICON_PLAYER, ), ) @@ -60,61 +63,105 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { probatio.Required(CONF_TOKEN): cv.string, - probatio.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - probatio.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): probatio.All( - cv.ensure_list, [probatio.In(SENSOR_KEYS)] + probatio.Optional("name"): cv.string, + probatio.Optional("monitored_conditions"): probatio.All( + cv.ensure_list, [cv.string] ), } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Discogs sensor.""" - token = config[CONF_TOKEN] - name = config[CONF_NAME] + """Import YAML configuration and forward to config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data={ + CONF_TOKEN: config[CONF_TOKEN], + "name": config.get("name", DEFAULT_NAME), + }, + ) - try: - _discogs_client = discogs_client.Client(SERVER_SOFTWARE, user_token=token) - - discogs_data = { - "user": _discogs_client.identity().name, - "folders": _discogs_client.identity().collection_folders, - "collection_count": _discogs_client.identity().num_collection, - "wantlist_count": _discogs_client.identity().num_wantlist, - } - except discogs_client.exceptions.HTTPError: - _LOGGER.error("API token is not valid") + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Discogs", + }, + ) return - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - DiscogsSensor(discogs_data, name, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Discogs", + }, + ) - add_entities(entities, True) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Discogs sensor from a config entry.""" + client = entry.runtime_data + async_add_entities( + (DiscogsSensor(entry, client, description) for description in SENSOR_TYPES), + True, + ) class DiscogsSensor(SensorEntity): """Create a new Discogs sensor for a specific type.""" _attr_attribution = "Data provided by Discogs" + _attr_has_entity_name = True def __init__( - self, discogs_data, name, description: SensorEntityDescription + self, + entry: ConfigEntry, + client: discogs_client.Client, + description: SensorEntityDescription, ) -> None: """Initialize the Discogs sensor.""" + assert entry.unique_id is not None self.entity_description = description - self._discogs_data = discogs_data - self._attrs: dict = {} - - self._attr_name = f"{name} {description.name}" + self._client = client + self._discogs_data: dict[str, Any] = {} + self._attrs: dict[str, Any] = {} + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + configuration_url="https://www.discogs.com", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer=DEFAULT_NAME, + name=entry.title, + ) @property @override @@ -143,7 +190,7 @@ class DiscogsSensor(SensorEntity): ATTR_IDENTITY: self._discogs_data["user"], } - def get_random_record(self): + def get_random_record(self) -> str | None: """Get a random record suggestion from the user's collection.""" # Index 0 in the folders is the 'All' folder collection = self._discogs_data["folders"][0] @@ -161,6 +208,14 @@ class DiscogsSensor(SensorEntity): def update(self) -> None: """Set state to the amount of records in user's collection.""" + identity = self._client.identity() + self._discogs_data = { + "user": identity.name, + "folders": identity.collection_folders, + "collection_count": identity.num_collection, + "wantlist_count": identity.num_wantlist, + } + if self.entity_description.key == SENSOR_COLLECTION_TYPE: self._attr_native_value = self._discogs_data["collection_count"] elif self.entity_description.key == SENSOR_WANTLIST_TYPE: diff --git a/homeassistant/components/discogs/strings.json b/homeassistant/components/discogs/strings.json new file mode 100644 index 000000000000..b776a6716a7e --- /dev/null +++ b/homeassistant/components/discogs/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "token": "Personal access token from Discogs developer settings." + } + } + } + }, + "entity": { + "sensor": { + "collection": { + "name": "Collection" + }, + "random_record": { + "name": "Random record" + }, + "wantlist": { + "name": "Wantlist" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your YAML configuration, the Discogs API could not be reached or the token was invalid. Please verify your token and restart Home Assistant to retry, or remove the {domain} sensor from your configuration and set up the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c65c3d747a71..848a2d286915 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -163,6 +163,7 @@ FLOWS = { "dexcom", "dialogflow", "directv", + "discogs", "discord", "discovergy", "dlink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 57731dce82ea..cefc07b2b148 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1444,8 +1444,8 @@ }, "discogs": { "name": "Discogs", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_polling" }, "discord": { diff --git a/mypy.ini b/mypy.ini index 998c3a6c5767..57ebca56658b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1488,6 +1488,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.discogs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.discovergy.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/discogs/__init__.py b/tests/components/discogs/__init__.py new file mode 100644 index 000000000000..8103feeb359a --- /dev/null +++ b/tests/components/discogs/__init__.py @@ -0,0 +1,5 @@ +"""Tests for the Discogs integration.""" + +MOCK_TOKEN = "test_token_123" +MOCK_USERNAME = "testuser" +MOCK_USER_ID = 12345 diff --git a/tests/components/discogs/conftest.py b/tests/components/discogs/conftest.py new file mode 100644 index 000000000000..7762f6ee318a --- /dev/null +++ b/tests/components/discogs/conftest.py @@ -0,0 +1,99 @@ +"""Configure tests for the Discogs integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.discogs.const import DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from . import MOCK_TOKEN, MOCK_USER_ID, MOCK_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock Discogs config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_USERNAME, + data={CONF_TOKEN: MOCK_TOKEN}, + unique_id=str(MOCK_USER_ID), + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.discogs.async_setup_entry", + return_value=True, + ): + yield + + +@pytest.fixture +def mock_discogs_data() -> dict: + """Return mock Discogs data for a random record.""" + return { + "artists": [{"name": "Artist Name"}], + "title": "Album Title", + "labels": [{"catno": "CAT001", "name": "Label Name"}], + "cover_image": "https://example.com/cover.jpg", + "formats": [{"name": "Vinyl", "descriptions": ["LP", "Album"]}], + "year": "2023", + } + + +@pytest.fixture +def mock_identity(mock_discogs_data: dict) -> MagicMock: + """Return a mock Discogs identity.""" + identity = MagicMock() + identity.id = MOCK_USER_ID + identity.name = MOCK_USERNAME + identity.num_collection = 42 + identity.num_wantlist = 10 + + release = MagicMock() + release.release.data = mock_discogs_data + + folder = MagicMock() + folder.count = 42 + folder.releases.__getitem__ = MagicMock(return_value=release) + + identity.collection_folders = [folder] + return identity + + +@pytest.fixture +def mock_discogs_client(mock_identity: MagicMock) -> Generator[MagicMock]: + """Mock a Discogs client.""" + with ( + patch( + "homeassistant.components.discogs.discogs_client.Client", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.discogs.config_flow.discogs_client.Client", + new=mock_client, + ), + ): + client = mock_client.return_value + client.identity.return_value = mock_identity + yield client + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discogs_client: MagicMock, +) -> MockConfigEntry: + """Set up the Discogs integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/discogs/snapshots/test_sensor.ambr b/tests/components/discogs/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..ad33d4046445 --- /dev/null +++ b/tests/components/discogs/snapshots/test_sensor.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_sensors[sensor.testuser_collection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_collection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Collection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:album', + 'original_name': 'Collection', + 'platform': 'discogs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'collection', + 'unique_id': '12345_collection', + 'unit_of_measurement': 'records', + }) +# --- +# name: test_sensors[sensor.testuser_collection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by Discogs', + : 'testuser Collection', + : 'mdi:album', + 'identity': 'testuser', + : 'records', + }), + 'context': , + 'entity_id': 'sensor.testuser_collection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_sensors[sensor.testuser_random_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_random_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Random record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:record-player', + 'original_name': 'Random record', + 'platform': 'discogs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'random_record', + 'unique_id': '12345_random_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_random_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by Discogs', + 'cat_no': 'CAT001', + 'cover_image': 'https://example.com/cover.jpg', + 'format': 'Vinyl (LP)', + : 'testuser Random record', + : 'mdi:record-player', + 'identity': 'testuser', + 'label': 'Label Name', + 'released': '2023', + }), + 'context': , + 'entity_id': 'sensor.testuser_random_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Artist Name - Album Title', + }) +# --- +# name: test_sensors[sensor.testuser_wantlist-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_wantlist', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wantlist', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:album', + 'original_name': 'Wantlist', + 'platform': 'discogs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wantlist', + 'unique_id': '12345_wantlist', + 'unit_of_measurement': 'records', + }) +# --- +# name: test_sensors[sensor.testuser_wantlist-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Data provided by Discogs', + : 'testuser Wantlist', + : 'mdi:album', + 'identity': 'testuser', + : 'records', + }), + 'context': , + 'entity_id': 'sensor.testuser_wantlist', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- diff --git a/tests/components/discogs/test_config_flow.py b/tests/components/discogs/test_config_flow.py new file mode 100644 index 000000000000..2f8f3e065624 --- /dev/null +++ b/tests/components/discogs/test_config_flow.py @@ -0,0 +1,150 @@ +"""Test Discogs config flow.""" + +from unittest.mock import MagicMock + +import discogs_client +import pytest +import requests + +from homeassistant.components.discogs.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_TOKEN, MOCK_USER_ID, MOCK_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_full_user_flow( + hass: HomeAssistant, mock_discogs_client: MagicMock +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_USERNAME + assert result["data"] == {CONF_TOKEN: MOCK_TOKEN} + assert result["result"].unique_id == str(MOCK_USER_ID) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (discogs_client.exceptions.HTTPError("Unauthorized", 401), "invalid_auth"), + (discogs_client.exceptions.HTTPError("Rate Limited", 429), "cannot_connect"), + (requests.ConnectionError("Connection refused"), "cannot_connect"), + (requests.Timeout("Request timed out"), "cannot_connect"), + (RuntimeError("Something went wrong"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_errors_then_success( + hass: HomeAssistant, + mock_discogs_client: MagicMock, + error: Exception, + message: str, +) -> None: + """Test that errors can be recovered from.""" + mock_discogs_client.identity.side_effect = error + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: "bad_token"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == message + + mock_discogs_client.identity.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_already_configured( + hass: HomeAssistant, + mock_discogs_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test flow aborts when account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow(hass: HomeAssistant, mock_discogs_client: MagicMock) -> None: + """Test YAML import creates a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_TOKEN: MOCK_TOKEN}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_USERNAME + assert result["data"] == {CONF_TOKEN: MOCK_TOKEN} + assert result["result"].unique_id == str(MOCK_USER_ID) + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow_with_name( + hass: HomeAssistant, mock_discogs_client: MagicMock +) -> None: + """Test YAML import preserves custom name as entry title.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_TOKEN: MOCK_TOKEN, "name": "My Vinyl"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My Vinyl" + assert result["data"] == {CONF_TOKEN: MOCK_TOKEN} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow_already_configured( + hass: HomeAssistant, + mock_discogs_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test YAML import aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_TOKEN: MOCK_TOKEN}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/discogs/test_init.py b/tests/components/discogs/test_init.py new file mode 100644 index 000000000000..fedc70ed4d30 --- /dev/null +++ b/tests/components/discogs/test_init.py @@ -0,0 +1,45 @@ +"""Test Discogs integration init.""" + +from unittest.mock import MagicMock + +import discogs_client + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discogs_client: MagicMock, +) -> None: + """Test successful setup and unload of config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discogs_client: MagicMock, +) -> None: + """Test setup entry retries when Discogs API is unavailable.""" + mock_discogs_client.identity.side_effect = discogs_client.exceptions.HTTPError( + "Service Unavailable", 503 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/discogs/test_sensor.py b/tests/components/discogs/test_sensor.py new file mode 100644 index 000000000000..63bc54e2cf3d --- /dev/null +++ b/tests/components/discogs/test_sensor.py @@ -0,0 +1,68 @@ +"""Test Discogs sensor platform.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_discogs_client: MagicMock, +) -> None: + """Test sensor entities with snapshot.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.discogs.PLATFORMS", [Platform.SENSOR]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_empty_collection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensors when the collection is empty.""" + mock_identity = MagicMock() + mock_identity.name = "testuser" + mock_identity.num_collection = 0 + mock_identity.num_wantlist = 0 + + folder = MagicMock() + folder.count = 0 + mock_identity.collection_folders = [folder] + + mock_client = MagicMock() + mock_client.identity.return_value = mock_identity + + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.discogs.discogs_client.Client", + return_value=mock_client, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.testuser_collection") + assert state is not None + assert state.state == "0" + + state = hass.states.get("sensor.testuser_wantlist") + assert state is not None + assert state.state == "0" + + state = hass.states.get("sensor.testuser_random_record") + assert state is not None + assert state.state == "unknown"