Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions homeassistant/components/idrive_e2/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,14 @@ async def _list_backups(self) -> dict[str, AgentBackup]:
return self._backup_cache

backups = {}
response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket)

# Filter for metadata files only
metadata_files = [
obj
for obj in response.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
]
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)

for metadata_file in metadata_files:
try:
Expand Down
28 changes: 23 additions & 5 deletions homeassistant/components/splunk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,24 +179,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
except ClientConnectionError as err:
raise ConfigEntryNotReady(
f"Connection error connecting to Splunk at {host}:{port}: {err}"
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
f"Timeout connecting to Splunk at {host}:{port}"
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"host": host, "port": str(port)},
) from err
except Exception as err:
_LOGGER.exception("Unexpected error setting up Splunk")
raise ConfigEntryNotReady(
f"Unexpected error connecting to Splunk: {err}"
translation_domain=DOMAIN,
translation_key="unexpected_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
) from err

if not connectivity_ok:
raise ConfigEntryNotReady(
f"Unable to connect to Splunk instance at {host}:{port}"
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"host": host, "port": str(port)},
)
if not token_ok:
raise ConfigEntryAuthFailed("Invalid Splunk token - please reauthenticate")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
)

# Send startup event
payload: dict[str, Any] = {
Expand Down
5 changes: 1 addition & 4 deletions homeassistant/components/splunk/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,7 @@ rules:
status: exempt
comment: |
Integration does not create entities.
exception-translations:
status: todo
comment: |
Consider adding exception translations for user-facing errors beyond the current strings.json error section to provide more detailed translated error messages.
exception-translations: done
icon-translations:
status: exempt
comment: |
Expand Down
17 changes: 17 additions & 0 deletions homeassistant/components/splunk/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Unable to connect to Splunk at {host}:{port}."
},
"connection_error": {
"message": "Unable to connect to Splunk at {host}:{port}: {error}."
},
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"timeout_connect": {
"message": "Connection to Splunk at {host}:{port} timed out."
},
"unexpected_error": {
"message": "Unexpected error while connecting to Splunk at {host}:{port}: {error}."
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release.\n\nWhile importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the connection settings from your `{domain}:` configuration and configure the integration via the UI.\n\nNote: Entity filtering via YAML (`filter:`) will continue to work.",
Expand Down
10 changes: 6 additions & 4 deletions tests/components/idrive_e2/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from collections.abc import AsyncIterator, Generator
import json
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

Expand Down Expand Up @@ -53,9 +53,11 @@ def mock_client(agent_backup: AgentBackup) -> Generator[AsyncMock]:
client = create_client.return_value

tar_file, metadata_file = suggested_filenames(agent_backup)
client.list_objects_v2.return_value = {
"Contents": [{"Key": tar_file}, {"Key": metadata_file}]
}
# Mock the paginator for list_objects_v2
client.get_paginator = MagicMock()
client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": [{"Key": tar_file}, {"Key": metadata_file}]}
]
client.create_multipart_upload.return_value = {"UploadId": "upload_id"}
client.upload_part.return_value = {"ETag": "etag"}
client.list_buckets.return_value = {
Expand Down
144 changes: 121 additions & 23 deletions tests/components/idrive_e2/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ async def test_agents_get_backup_does_not_throw_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent get backup does not throw on a backup not found."""
mock_client.list_objects_v2.return_value = {"Contents": []}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]

client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"})
Expand All @@ -202,18 +204,20 @@ async def test_agents_list_backups_with_corrupted_metadata(
agent = IDriveE2BackupAgent(hass, mock_config_entry)

# Set up mock responses for both valid and corrupted metadata files
mock_client.list_objects_v2.return_value = {
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "valid_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{
"Key": "corrupted_backup.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
]
}
]

# Mock responses for get_object calls
valid_metadata = json.dumps(agent_backup.as_dict())
Expand Down Expand Up @@ -270,7 +274,9 @@ async def test_agents_delete_not_throwing_on_not_found(
mock_client: MagicMock,
) -> None:
"""Test agent delete backup does not throw on a backup not found."""
mock_client.list_objects_v2.return_value = {"Contents": []}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{"Contents": []}
]

client = await hass_ws_client(hass)

Expand All @@ -284,7 +290,7 @@ async def test_agents_delete_not_throwing_on_not_found(

assert response["success"]
assert response["result"] == {"agent_errors": {}}
assert mock_client.delete_object.call_count == 0
assert mock_client.delete_objects.call_count == 0


async def test_agents_upload(
Expand Down Expand Up @@ -490,28 +496,35 @@ async def test_cache_expiration(
metadata_content = json.dumps(agent_backup.as_dict())
mock_body = AsyncMock()
mock_body.read.return_value = metadata_content.encode()
mock_client.list_objects_v2.return_value = {
"Contents": [
{"Key": "test.metadata.json", "LastModified": "2023-01-01T00:00:00+00:00"}
]
}
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
{
"Contents": [
{
"Key": "test.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
}
]
}
]

mock_client.get_object.return_value = {"Body": mock_body}

# First call should query IDrive e2
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_paginator.call_count == 1
assert mock_client.get_object.call_count == 1

# Second call should use cache
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 1
assert mock_client.get_paginator.call_count == 1
assert mock_client.get_object.call_count == 1

# Set cache to expire
agent._cache_expiration = time() - 1

# Third call should query IDrive e2 again
await agent.async_list_backups()
assert mock_client.list_objects_v2.call_count == 2
assert mock_client.get_paginator.call_count == 2
assert mock_client.get_object.call_count == 2


Expand All @@ -526,3 +539,88 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None:
remove_listener()

assert DATA_BACKUP_AGENT_LISTENERS not in hass.data


async def test_list_backups_with_pagination(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test listing backups when paginating through multiple pages."""
# Create agent
agent = IDriveE2BackupAgent(hass, mock_config_entry)

# Create two different backups
backup1 = AgentBackup(
backup_id="backup1",
date="2023-01-01T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 1",
protected=False,
size=0,
)
backup2 = AgentBackup(
backup_id="backup2",
date="2023-01-02T00:00:00+00:00",
addons=[],
database_included=False,
extra_metadata={},
folders=[],
homeassistant_included=False,
homeassistant_version=None,
name="Backup 2",
protected=False,
size=0,
)

# Setup two pages of results
page1 = {
"Contents": [
{
"Key": "backup1.metadata.json",
"LastModified": "2023-01-01T00:00:00+00:00",
},
{"Key": "backup1.tar", "LastModified": "2023-01-01T00:00:00+00:00"},
]
}
page2 = {
"Contents": [
{
"Key": "backup2.metadata.json",
"LastModified": "2023-01-02T00:00:00+00:00",
},
{"Key": "backup2.tar", "LastModified": "2023-01-02T00:00:00+00:00"},
]
}

# Setup mock client
mock_client = mock_config_entry.runtime_data
mock_client.get_paginator.return_value.paginate.return_value.__aiter__.return_value = [
page1,
page2,
]

# Mock get_object responses based on the key
async def mock_get_object(**kwargs):
"""Mock get_object with different responses based on the key."""
key = kwargs.get("Key", "")
if "backup1" in key:
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup1.as_dict()).encode()
return {"Body": mock_body}
# backup2
mock_body = AsyncMock()
mock_body.read.return_value = json.dumps(backup2.as_dict()).encode()
return {"Body": mock_body}

mock_client.get_object.side_effect = mock_get_object

# List backups and verify we got both
backups = await agent.async_list_backups()
assert len(backups) == 2
backup_ids = {backup.backup_id for backup in backups}
assert backup_ids == {"backup1", "backup2"}
21 changes: 16 additions & 5 deletions tests/components/splunk/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,21 @@ async def test_setup_entry_success(


@pytest.mark.parametrize(
("side_effect", "expected_state"),
("side_effect", "expected_state", "expected_error_key"),
[
([False, False], ConfigEntryState.SETUP_RETRY),
(ClientConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY),
(TimeoutError(), ConfigEntryState.SETUP_RETRY),
([True, False], ConfigEntryState.SETUP_ERROR),
([False, False], ConfigEntryState.SETUP_RETRY, "cannot_connect"),
(
ClientConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
"connection_error",
),
(TimeoutError(), ConfigEntryState.SETUP_RETRY, "timeout_connect"),
(
Exception("Unexpected error"),
ConfigEntryState.SETUP_RETRY,
"unexpected_error",
),
([True, False], ConfigEntryState.SETUP_ERROR, "invalid_auth"),
],
)
async def test_setup_entry_error(
Expand All @@ -55,6 +64,7 @@ async def test_setup_entry_error(
mock_config_entry: MockConfigEntry,
side_effect: Exception | list[bool],
expected_state: ConfigEntryState,
expected_error_key: str,
) -> None:
"""Test setup with various errors results in appropriate states."""
mock_config_entry.add_to_hass(hass)
Expand All @@ -65,6 +75,7 @@ async def test_setup_entry_error(
await hass.async_block_till_done()

assert mock_config_entry.state is expected_state
assert mock_config_entry.error_reason_translation_key == expected_error_key


async def test_unload_entry(
Expand Down
Loading