/
/
/
1"""
2Regression tests for malformed provider config entries (issue #5728).
3
4When a provider failed to load, its ``last_error`` was written back to the
5provider config key. If the config had been removed in the meantime (e.g. the
6user removed an unsupported provider while a load/retry was still in flight),
7the underlying ``set`` helper auto-created the parent dict, leaving a stub entry
8with no ``domain`` key. That stub then crashed ``get_provider_configs`` (and
9therefore startup, via ``create_builtin_provider_config``) with
10``KeyError: 'domain'``.
11
12Two complementary fixes are covered here:
13- ``update_provider_last_error`` only writes when the config still exists, so a
14 removed provider is never resurrected as a domain-less stub (root cause).
15- the ``migrate`` settings migration drops any pre-existing orphaned stubs left
16 on disk by older versions, before they can reach the read path.
17"""
18
19from __future__ import annotations
20
21from typing import Any
22
23from music_assistant_models.config_entries import ProviderError
24from music_assistant_models.errors import SetupFailedError
25
26from music_assistant.constants import CONF_PROVIDERS
27from music_assistant.controllers.config.migrations import migrate
28from music_assistant.mass import MusicAssistant
29
30
31async def test_migrate_drops_orphaned_provider_stub() -> None:
32 """A stored provider stub lacking a 'domain' key is removed by migration."""
33 data: dict[str, Any] = {
34 CONF_PROVIDERS: {
35 "sonic_analysis--orphan": {"last_error": {"error_code": 999, "message": "x"}},
36 "filesystem_local--1": {
37 "domain": "filesystem_local",
38 "type": "music",
39 "instance_id": "filesystem_local--1",
40 },
41 }
42 }
43 assert await migrate(data) is True
44 # the domain-less stub is gone, the valid entry is untouched
45 assert "sonic_analysis--orphan" not in data[CONF_PROVIDERS]
46 assert "filesystem_local--1" in data[CONF_PROVIDERS]
47
48
49async def test_migrate_leaves_valid_provider_configs_alone() -> None:
50 """Migration is a no-op for provider configs that all have a 'domain'."""
51 data: dict[str, Any] = {
52 CONF_PROVIDERS: {
53 "filesystem_local--1": {
54 "domain": "filesystem_local",
55 "type": "music",
56 "instance_id": "filesystem_local--1",
57 },
58 }
59 }
60 assert await migrate(data) is False
61 assert "filesystem_local--1" in data[CONF_PROVIDERS]
62
63
64async def test_update_provider_last_error_ignores_removed_entry(
65 mass_minimal: MusicAssistant,
66) -> None:
67 """Writing last_error must not resurrect a removed config as a domain-less stub."""
68 config = mass_minimal.config
69 instance = "sonic_analysis--xyz"
70 # No config entry exists (it was removed).
71 error = ProviderError(error_code=SetupFailedError.error_code, message="boom")
72 config.update_provider_last_error(instance, error)
73 assert config.get(f"{CONF_PROVIDERS}/{instance}") is None
74
75
76async def test_update_provider_last_error_writes_when_entry_exists(
77 mass_minimal: MusicAssistant,
78) -> None:
79 """When the config entry still exists, last_error is persisted as usual."""
80 config = mass_minimal.config
81 instance = "filesystem_local--1"
82 config.set(
83 f"{CONF_PROVIDERS}/{instance}",
84 {"domain": "filesystem_local", "type": "music", "instance_id": instance},
85 )
86 error = ProviderError(error_code=SetupFailedError.error_code, message="boom")
87 config.update_provider_last_error(instance, error)
88 stored = config.get(f"{CONF_PROVIDERS}/{instance}/last_error")
89 assert stored is not None
90 assert stored["message"] == "boom"
91