mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-21 21:57:26 +01:00
* Add repair to move local data blocking a mount target When an add-on writes into a media/share directory while its network mount is not in place (#7037), the local data blocks re-creating the mount: mounting over a non-empty directory is refused. Until now this failed silently at Supervisor startup — the bind mounts were created as fire-and-forget tasks — and the only way out was to remove the data manually over SSH/Samba and re-create the mount via the API. Surface the condition as a new mount_target_not_empty issue and offer a move_local_data suggestion. The fixup moves the blocking data to a <name>_local_recovery folder in a user-accessible location — media or share for bind mount targets, local backup storage for backup mounts (their data mount directory is not reachable for users) — then reloads the mount. Nothing is deleted; users can inspect and clean up the recovered data via the media browser or the share and backup folders. Bind mount failures during load are now awaited and routed into resolution issues instead of being swallowed as fire-and-forget tasks; bind failures other than blocking local data create the existing mount_failed issue. A successful mount reload dismisses a stale local data issue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Attach move local data suggestion to the mount failed issue Review feedback on the repair: rather than introducing a separate mount_target_not_empty issue type, keep a single mount failed issue per mount and offer moving the blocking data as an additional suggestion alongside reload and remove. Reload stays available for users who prefer to clear the data themselves, and at most one repair exists per mount. Adding is idempotent, so an already-raised mount failed issue just gains the extra suggestion. When re-creating the bind mount after a successful reload fails on blocking local data, the mount failed issue is re-added together with the move suggestion, since the reload already dismissed it. This also resolves the reviewer note about not-a-directory conflicts being reported under a not-empty issue type: the issue type no longer encodes the filesystem detail, while the error messages keep the distinction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep an empty directory in place after relocating local data If remounting fails after the local data was moved aside (e.g. the server is unreachable at that moment), the renamed directory left nothing behind: media/share consumers saw the folder disappear entirely. Recreate an empty directory right after the rename so the path stays present regardless of whether the remount succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop move local data suggestion once the data was moved When the remount after relocating local data fails (e.g. the server is unreachable at that moment), the mount failed issue stays — but the move suggestion stayed with it, offering to move data that is no longer in the way. Dismiss the suggestion after the relocation step so only reload and remove remain for the leftover failure. Detection re-adds the move suggestion if local data blocks the target again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Filter Core-facing suggestions by minimum Core version The fix flow translations for a new suggestion ship with a Core release. Older Core frontends render an unknown suggestion as an empty, unlabeled menu entry in the repair fix flow. Filter such suggestions from Core-facing output — the issue events sent over the websocket and the resolution API responses when the caller is Home Assistant — until the connected Core is new enough. Other API consumers like the CLI always see the full suggestion list. The move_local_data suggestion requires Core 2026.9.0b0, the release its fix flow translations are targeted at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make fixup failure tests independent of error propagation behavior Suppress a potential ResolutionFixupError from the failing fixup calls so the tests pass both while fixup errors are swallowed and once they propagate to the caller (#7150). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: one recovery folder, check OSError for known issues Move all local data blocking a mount into a single recovery folder so the user finds it as one fix: when more than one directory holds data, later ones become subfolders named after their parent directory instead of numbered sibling folders. Also run OSError from the relocation through check_oserror to pick up known filesystem issues like corruption (bad message). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only filter Core-facing suggestions on v1 surfaces Per review: no Core version predating the repair suggestion filtering in its own fix flow (home-assistant/core#179540) supports the v2 API, so the Supervisor-side compatibility filter is only needed where old Core versions actually look. Filter the v1 resolution endpoints and the legacy websocket issue payloads; the v2 endpoints and v2 event payloads always carry the full suggestion list. The suggestions for issue endpoint gets a v1 handler for this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
563 lines
20 KiB
Python
563 lines
20 KiB
Python
"""Test Resolution API."""
|
|
|
|
import asyncio
|
|
from http import HTTPStatus
|
|
from unittest.mock import AsyncMock, PropertyMock, patch
|
|
|
|
from aiohttp.test_utils import TestClient
|
|
from awesomeversion import AwesomeVersion
|
|
import pytest
|
|
|
|
from supervisor.const import (
|
|
ATTR_ISSUES,
|
|
ATTR_SUGGESTIONS,
|
|
ATTR_UNHEALTHY,
|
|
ATTR_UNSUPPORTED,
|
|
CoreState,
|
|
FeatureFlag,
|
|
)
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import ResolutionError
|
|
from supervisor.homeassistant.const import WSType
|
|
from supervisor.resolution.const import (
|
|
ContextType,
|
|
IssueType,
|
|
SuggestionType,
|
|
UnhealthyReason,
|
|
UnsupportedReason,
|
|
)
|
|
from supervisor.resolution.data import Issue, Suggestion
|
|
|
|
|
|
async def test_api_resolution_base(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test resolution manager api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_unsupported_reason(UnsupportedReason.OS)
|
|
coresys.resolution.add_suggestion(
|
|
Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
|
)
|
|
coresys.resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM)
|
|
|
|
resp = await api_client.get(f"{prefix}/resolution/info")
|
|
result = await resp.json()
|
|
assert UnsupportedReason.OS in result["data"][ATTR_UNSUPPORTED]
|
|
assert (
|
|
result["data"][ATTR_SUGGESTIONS][-1]["type"] == SuggestionType.CLEAR_FULL_BACKUP
|
|
)
|
|
assert result["data"][ATTR_ISSUES][-1]["type"] == IssueType.FREE_SPACE
|
|
|
|
|
|
async def test_api_resolution_dismiss_suggestion(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test resolution manager dismiss suggestion api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_suggestion(
|
|
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
|
)
|
|
|
|
assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP
|
|
await api_client.delete(f"{prefix}/resolution/suggestion/{clear_backup.uuid}")
|
|
assert clear_backup not in coresys.resolution.suggestions
|
|
|
|
|
|
async def test_api_resolution_apply_suggestion(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test resolution manager suggestion apply api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_suggestion(
|
|
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
create_backup := Suggestion(
|
|
SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM
|
|
)
|
|
)
|
|
|
|
mock_backups = AsyncMock()
|
|
mock_health = AsyncMock()
|
|
coresys.backups.do_backup_full = mock_backups
|
|
coresys.resolution.healthcheck = mock_health
|
|
|
|
await api_client.post(f"{prefix}/resolution/suggestion/{clear_backup.uuid}")
|
|
await api_client.post(f"{prefix}/resolution/suggestion/{create_backup.uuid}")
|
|
|
|
assert clear_backup not in coresys.resolution.suggestions
|
|
assert create_backup not in coresys.resolution.suggestions
|
|
|
|
assert mock_backups.called
|
|
assert mock_health.called
|
|
|
|
with pytest.raises(ResolutionError):
|
|
await coresys.resolution.apply_suggestion(clear_backup)
|
|
|
|
|
|
async def test_api_resolution_suggestions_filtered_for_old_core(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test v1 responses hide suggestions the Core version cannot present.
|
|
|
|
The v2 API never filters: no Core version predating the suggestion
|
|
filtering in its repair flow supports v2.
|
|
"""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_issue(
|
|
issue := Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="test"),
|
|
suggestions=[SuggestionType.MOVE_LOCAL_DATA, SuggestionType.EXECUTE_RELOAD],
|
|
)
|
|
|
|
all_types = {"execute_reload", "move_local_data"}
|
|
for version, expected_types in [
|
|
(AwesomeVersion("2026.8.3"), {"execute_reload"} if not prefix else all_types),
|
|
(AwesomeVersion("2026.9.0b0"), all_types),
|
|
]:
|
|
with patch.object(
|
|
type(coresys.homeassistant),
|
|
"version",
|
|
new=PropertyMock(return_value=version),
|
|
):
|
|
resp = await api_client.get(f"{prefix}/resolution/info")
|
|
body = await resp.json()
|
|
assert {
|
|
suggestion["type"] for suggestion in body["data"]["suggestions"]
|
|
} == expected_types
|
|
|
|
resp = await api_client.get(
|
|
f"{prefix}/resolution/issue/{issue.uuid}/suggestions"
|
|
)
|
|
body = await resp.json()
|
|
assert {
|
|
suggestion["type"] for suggestion in body["data"]["suggestions"]
|
|
} == expected_types
|
|
|
|
|
|
async def test_api_resolution_dismiss_issue(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test resolution manager issue apply api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_issue(
|
|
updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM)
|
|
)
|
|
|
|
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED
|
|
await api_client.delete(f"{prefix}/resolution/issue/{updated_failed.uuid}")
|
|
assert updated_failed not in coresys.resolution.issues
|
|
|
|
|
|
async def test_api_resolution_unhealthy(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test resolution manager api."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
|
|
|
|
resp = await api_client.get(f"{prefix}/resolution/info")
|
|
result = await resp.json()
|
|
assert result["data"][ATTR_UNHEALTHY][-1] == UnhealthyReason.DOCKER
|
|
|
|
|
|
async def test_api_resolution_check_options(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test client API with checks options."""
|
|
api_client, prefix = api_client_with_prefix
|
|
free_space = coresys.resolution.check.get("free_space")
|
|
|
|
assert free_space.enabled
|
|
await api_client.post(
|
|
f"{prefix}/resolution/check/{free_space.slug}/options", json={"enabled": False}
|
|
)
|
|
assert not free_space.enabled
|
|
|
|
await api_client.post(
|
|
f"{prefix}/resolution/check/{free_space.slug}/options", json={"enabled": True}
|
|
)
|
|
assert free_space.enabled
|
|
|
|
|
|
async def test_api_resolution_check_run(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test client API with run check."""
|
|
api_client, prefix = api_client_with_prefix
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
free_space = coresys.resolution.check.get("free_space")
|
|
|
|
free_space.run_check = AsyncMock()
|
|
|
|
await api_client.post(f"{prefix}/resolution/check/{free_space.slug}/run")
|
|
|
|
assert free_space.run_check.called
|
|
|
|
|
|
async def test_api_resolution_suggestions_for_issue(
|
|
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
|
|
):
|
|
"""Test getting suggestions that fix an issue."""
|
|
api_client, prefix = api_client_with_prefix
|
|
coresys.resolution.add_issue(
|
|
corrupt_repo := Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1")
|
|
)
|
|
|
|
resp = await api_client.get(
|
|
f"{prefix}/resolution/issue/{corrupt_repo.uuid}/suggestions"
|
|
)
|
|
result = await resp.json()
|
|
|
|
assert result["data"]["suggestions"] == []
|
|
|
|
coresys.resolution.add_suggestion(
|
|
execute_reset := Suggestion(
|
|
SuggestionType.EXECUTE_RESET, ContextType.STORE, "repo_1"
|
|
)
|
|
)
|
|
coresys.resolution.add_suggestion(
|
|
execute_remove := Suggestion(
|
|
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1"
|
|
)
|
|
)
|
|
|
|
resp = await api_client.get(
|
|
f"{prefix}/resolution/issue/{corrupt_repo.uuid}/suggestions"
|
|
)
|
|
result = await resp.json()
|
|
|
|
suggestion = [
|
|
su for su in result["data"]["suggestions"] if su["uuid"] == execute_reset.uuid
|
|
]
|
|
assert len(suggestion) == 1
|
|
assert suggestion[0]["auto"] is True
|
|
|
|
suggestion = [
|
|
su for su in result["data"]["suggestions"] if su["uuid"] == execute_remove.uuid
|
|
]
|
|
assert len(suggestion) == 1
|
|
assert suggestion[0]["auto"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "url"),
|
|
[("delete", "/resolution/issue/bad"), ("get", "/resolution/issue/bad/suggestions")],
|
|
)
|
|
async def test_issue_not_found(
|
|
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
|
|
):
|
|
"""Test issue not found error."""
|
|
api_client, prefix = api_client_with_prefix
|
|
resp = await api_client.request(method, f"{prefix}{url}")
|
|
assert resp.status == 404
|
|
body = await resp.json()
|
|
assert body["message"] == "Issue bad does not exist"
|
|
assert body["error_key"] == "resolution_issue_not_found_error"
|
|
assert body["extra_fields"] == {"uuid": "bad"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "url"),
|
|
[("delete", "/resolution/suggestion/bad"), ("post", "/resolution/suggestion/bad")],
|
|
)
|
|
async def test_suggestion_not_found(
|
|
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
|
|
):
|
|
"""Test suggestion not found error."""
|
|
api_client, prefix = api_client_with_prefix
|
|
resp = await api_client.request(method, f"{prefix}{url}")
|
|
assert resp.status == 404
|
|
body = await resp.json()
|
|
assert body["message"] == "Suggestion bad does not exist"
|
|
assert body["error_key"] == "resolution_suggestion_not_found_error"
|
|
assert body["extra_fields"] == {"uuid": "bad"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "url"),
|
|
[("post", "/resolution/check/bad/options"), ("post", "/resolution/check/bad/run")],
|
|
)
|
|
async def test_check_not_found(
|
|
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
|
|
):
|
|
"""Test check not found error."""
|
|
api_client, prefix = api_client_with_prefix
|
|
resp = await api_client.request(method, f"{prefix}{url}")
|
|
assert resp.status == HTTPStatus.NOT_FOUND
|
|
body = await resp.json()
|
|
assert body["message"] == "Check 'bad' does not exist"
|
|
assert body["error_key"] == "resolution_check_not_found_error"
|
|
assert body["extra_fields"] == {"check": "bad"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("issue_type", "legacy_issue_type"),
|
|
[
|
|
(IssueType.DEPRECATED_APP, "deprecated_addon"),
|
|
(IssueType.DEPRECATED_ARCH_APP, "deprecated_arch_addon"),
|
|
(IssueType.DETACHED_APP_MISSING, "detached_addon_missing"),
|
|
(IssueType.DETACHED_APP_REMOVED, "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_api_resolution_info_v1_uses_legacy_names(
|
|
coresys: CoreSys, api_client: TestClient, issue_type: str, legacy_issue_type: str
|
|
):
|
|
"""Test v1 resolution info uses legacy issue type and check slug names."""
|
|
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
|
|
|
|
resp = await api_client.get("/resolution/info")
|
|
result = await resp.json()
|
|
|
|
# V1 should return legacy issue type name
|
|
issue_types = [issue["type"] for issue in result["data"][ATTR_ISSUES]]
|
|
assert legacy_issue_type in issue_types
|
|
assert issue_type not in issue_types
|
|
|
|
# V1 should return legacy check slugs
|
|
check_slugs = [check["slug"] for check in result["data"]["checks"]]
|
|
assert "addon_pwned" in check_slugs
|
|
assert "deprecated_addon" in check_slugs
|
|
assert "deprecated_arch_addon" in check_slugs
|
|
assert "detached_addon_missing" in check_slugs
|
|
assert "detached_addon_removed" in check_slugs
|
|
# Should NOT have new names
|
|
assert "app_pwned" not in check_slugs
|
|
assert "deprecated_app" not in check_slugs
|
|
assert "deprecated_arch_app" not in check_slugs
|
|
assert "detached_app_missing" not in check_slugs
|
|
assert "detached_app_removed" not in check_slugs
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"issue_type",
|
|
[
|
|
IssueType.DEPRECATED_APP,
|
|
IssueType.DEPRECATED_ARCH_APP,
|
|
IssueType.DETACHED_APP_MISSING,
|
|
IssueType.DETACHED_APP_REMOVED,
|
|
],
|
|
)
|
|
async def test_api_resolution_info_v2_uses_new_names(
|
|
coresys: CoreSys, api_client_v2: TestClient, issue_type: str
|
|
):
|
|
"""Test v2 resolution info uses new issue type and check slug names."""
|
|
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
|
|
|
|
resp = await api_client_v2.get("/v2/resolution/info")
|
|
result = await resp.json()
|
|
|
|
# V2 should return new issue type name
|
|
issue_types = [issue["type"] for issue in result["data"][ATTR_ISSUES]]
|
|
assert issue_type in issue_types
|
|
|
|
# V2 should return new check slugs
|
|
check_slugs = [check["slug"] for check in result["data"]["checks"]]
|
|
assert "app_pwned" in check_slugs
|
|
assert "deprecated_app" in check_slugs
|
|
assert "deprecated_arch_app" in check_slugs
|
|
assert "detached_app_missing" in check_slugs
|
|
assert "detached_app_removed" in check_slugs
|
|
# Should NOT have legacy names
|
|
assert "addon_pwned" not in check_slugs
|
|
assert "deprecated_addon" not in check_slugs
|
|
assert "deprecated_arch_addon" not in check_slugs
|
|
assert "detached_addon_missing" not in check_slugs
|
|
assert "detached_addon_removed" not in check_slugs
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("issue_type", "legacy_issue_type"),
|
|
[
|
|
(IssueType.DEPRECATED_APP, "deprecated_addon"),
|
|
(IssueType.DEPRECATED_ARCH_APP, "deprecated_arch_addon"),
|
|
(IssueType.DETACHED_APP_MISSING, "detached_addon_missing"),
|
|
(IssueType.DETACHED_APP_REMOVED, "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_ws_resolution_issue_events_legacy_compat(
|
|
coresys: CoreSys,
|
|
ha_ws_client: AsyncMock,
|
|
issue_type: str,
|
|
legacy_issue_type: str,
|
|
):
|
|
"""Test WS issue events use legacy names when SUPERVISOR_WEBSOCKET_V2_API is disabled."""
|
|
# Default: SUPERVISOR_WEBSOCKET_V2_API is disabled
|
|
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
|
|
await asyncio.sleep(0)
|
|
|
|
ws_events = [
|
|
call.args[0]
|
|
for call in ha_ws_client.async_send_command.call_args_list
|
|
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
|
|
and call.args[0].get("data", {}).get("event") == "issue_changed"
|
|
]
|
|
assert len(ws_events) == 1
|
|
assert ws_events[0]["data"]["data"]["type"] == legacy_issue_type
|
|
|
|
# After dismissing, the issue_removed event should also use legacy name
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
issue = coresys.resolution.issues[0]
|
|
coresys.resolution.dismiss_issue(issue)
|
|
await asyncio.sleep(0)
|
|
|
|
ws_events = [
|
|
call.args[0]
|
|
for call in ha_ws_client.async_send_command.call_args_list
|
|
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
|
|
and call.args[0].get("data", {}).get("event") == "issue_removed"
|
|
]
|
|
assert len(ws_events) == 1
|
|
assert ws_events[0]["data"]["data"]["type"] == legacy_issue_type
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"issue_type",
|
|
[
|
|
IssueType.DEPRECATED_APP,
|
|
IssueType.DEPRECATED_ARCH_APP,
|
|
IssueType.DETACHED_APP_MISSING,
|
|
IssueType.DETACHED_APP_REMOVED,
|
|
],
|
|
)
|
|
async def test_ws_resolution_issue_events_v2(
|
|
coresys: CoreSys, ha_ws_client: AsyncMock, issue_type: str
|
|
):
|
|
"""Test WS issue events use new names when SUPERVISOR_WEBSOCKET_V2_API is enabled."""
|
|
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, True)
|
|
|
|
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
|
|
await asyncio.sleep(0)
|
|
|
|
ws_events = [
|
|
call.args[0]
|
|
for call in ha_ws_client.async_send_command.call_args_list
|
|
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
|
|
and call.args[0].get("data", {}).get("event") == "issue_changed"
|
|
]
|
|
assert len(ws_events) == 1
|
|
assert ws_events[0]["data"]["data"]["type"] == issue_type
|
|
|
|
# After dismissing, the issue_removed event should also use new name
|
|
ha_ws_client.async_send_command.reset_mock()
|
|
issue = coresys.resolution.issues[0]
|
|
coresys.resolution.dismiss_issue(issue)
|
|
await asyncio.sleep(0)
|
|
|
|
ws_events = [
|
|
call.args[0]
|
|
for call in ha_ws_client.async_send_command.call_args_list
|
|
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
|
|
and call.args[0].get("data", {}).get("event") == "issue_removed"
|
|
]
|
|
assert len(ws_events) == 1
|
|
assert ws_events[0]["data"]["data"]["type"] == issue_type
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("check_slug", "legacy_slug"),
|
|
[
|
|
("app_pwned", "addon_pwned"),
|
|
("deprecated_app", "deprecated_addon"),
|
|
("deprecated_arch_app", "deprecated_arch_addon"),
|
|
("detached_app_missing", "detached_addon_missing"),
|
|
("detached_app_removed", "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_api_resolution_check_run_v1_accepts_legacy_names(
|
|
api_client: TestClient,
|
|
check_slug: str,
|
|
legacy_slug: str,
|
|
):
|
|
"""Test v1 check run endpoint translates legacy check slugs to new ones."""
|
|
# V1 should accept legacy slug and translate it to new slug
|
|
resp = await api_client.post(f"/resolution/check/{legacy_slug}/run")
|
|
assert resp.status == 200
|
|
|
|
# V1 should also accept new slug directly
|
|
resp = await api_client.post(f"/resolution/check/{check_slug}/run")
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("check_slug", "legacy_slug"),
|
|
[
|
|
("app_pwned", "addon_pwned"),
|
|
("deprecated_app", "deprecated_addon"),
|
|
("deprecated_arch_app", "deprecated_arch_addon"),
|
|
("detached_app_missing", "detached_addon_missing"),
|
|
("detached_app_removed", "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_api_resolution_check_run_v2_accepts_new_names(
|
|
api_client_v2: TestClient, check_slug: str, legacy_slug: str
|
|
):
|
|
"""Test v2 check run endpoint accepts new check slugs and rejects legacy ones."""
|
|
# V2 should accept new slug
|
|
resp = await api_client_v2.post(f"/v2/resolution/check/{check_slug}/run")
|
|
assert resp.status == 200
|
|
|
|
# V2 should NOT accept legacy slug
|
|
resp = await api_client_v2.post(f"/v2/resolution/check/{legacy_slug}/run")
|
|
assert resp.status == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("check_slug", "legacy_slug"),
|
|
[
|
|
("app_pwned", "addon_pwned"),
|
|
("deprecated_app", "deprecated_addon"),
|
|
("deprecated_arch_app", "deprecated_arch_addon"),
|
|
("detached_app_missing", "detached_addon_missing"),
|
|
("detached_app_removed", "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_api_resolution_check_options_v1_accepts_legacy_names(
|
|
api_client: TestClient,
|
|
check_slug: str,
|
|
legacy_slug: str,
|
|
):
|
|
"""Test v1 check options endpoint translates legacy check slugs to new ones."""
|
|
# V1 should accept legacy slug and translate it to new slug
|
|
resp = await api_client.post(
|
|
f"/resolution/check/{legacy_slug}/options", json={"enabled": False}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
# V1 should also accept new slug directly
|
|
resp = await api_client.post(
|
|
f"/resolution/check/{check_slug}/options", json={"enabled": True}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("check_slug", "legacy_slug"),
|
|
[
|
|
("app_pwned", "addon_pwned"),
|
|
("deprecated_app", "deprecated_addon"),
|
|
("deprecated_arch_app", "deprecated_arch_addon"),
|
|
("detached_app_missing", "detached_addon_missing"),
|
|
("detached_app_removed", "detached_addon_removed"),
|
|
],
|
|
)
|
|
async def test_api_resolution_check_options_v2_accepts_new_names(
|
|
api_client_v2: TestClient, check_slug: str, legacy_slug: str
|
|
):
|
|
"""Test v2 check options endpoint accepts new check slugs and rejects legacy ones."""
|
|
# V2 should accept new slug
|
|
resp = await api_client_v2.post(
|
|
f"/v2/resolution/check/{check_slug}/options", json={"enabled": False}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
resp = await api_client_v2.post(
|
|
f"/v2/resolution/check/{check_slug}/options", json={"enabled": True}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
# V2 should NOT accept legacy slug
|
|
resp = await api_client_v2.post(
|
|
f"/v2/resolution/check/{legacy_slug}/options", json={"enabled": False}
|
|
)
|
|
assert resp.status == HTTPStatus.NOT_FOUND
|