refactor: another refactor
Some checks failed
Integration Testing / Integration Tests (2024.12.0, 3.11) (push) Failing after 27s
Integration Testing / Integration Tests (2024.12.0, 3.12) (push) Failing after 56s
Integration Testing / Integration Tests (2024.12.0, 3.13) (push) Failing after 1m38s
Integration Testing / Integration Tests (2025.9.4, 3.11) (push) Failing after 19s
Integration Testing / Integration Tests (2025.9.4, 3.12) (push) Failing after 20s
Integration Testing / Integration Tests (2025.9.4, 3.13) (push) Failing after 25s
Code Quality Check / Code Quality Analysis (push) Failing after 20s
Code Quality Check / Security Analysis (push) Failing after 21s

Signed-off-by: Rafal Zielinski <sq4ind@gmail.com>
This commit is contained in:
2025-09-28 17:01:21 +01:00
parent 1aad59c582
commit 554b8ac16b
25 changed files with 464 additions and 543 deletions

View File

@@ -2,55 +2,86 @@ name: Integration Testing
on: on:
push: push:
branches: [ main ] branches: [ main, develop ]
pull_request: pull_request:
branches: [ main ] branches: [ main ]
jobs: jobs:
test-integration: test-integration:
name: Test Integration name: Integration Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.13"] python-version: ["3.11", "3.12", "3.13"]
home-assistant-version: ["2025.9.4"] home-assistant-version: ["2024.12.0", "2025.9.4"]
steps: steps:
- name: Checkout - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies - name: Cache pip dependencies
id: pip-cache-dir
run: echo "dir=$(pip cache dir)" >> "$GITHUB_OUTPUT"
- name: Cache pip
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ${{ steps.pip-cache-dir.outputs.dir }} path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt') }}
restore-keys: | restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip-${{ matrix.python-version }}-
${{ runner.os }}-pip-
- name: Install Python dependencies - name: Install Dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install homeassistant==${{ matrix.home-assistant-version }}
pip install -r requirements-dev.txt
- name: Ensure package structure - name: Ensure Package Structure
run: | run: |
mkdir -p custom_components mkdir -p custom_components
touch custom_components/__init__.py touch custom_components/__init__.py
- name: Run tests - name: Run Unit Tests
run: | run: |
python -m pytest tests/ -v --cov=custom_components/adguard_hub --cov-report=term-missing --asyncio-mode=auto python -m pytest tests/ -v --tb=short --cov=custom_components/adguard_hub --cov-report=xml --cov-report=term-missing --asyncio-mode=auto
- name: Upload coverage - name: Test Installation
if: always() run: |
run: echo "Tests completed" python -c "
import sys
sys.path.insert(0, 'custom_components')
try:
from adguard_hub import DOMAIN
print(f'✅ Integration can be imported, domain: {DOMAIN}')
except Exception as e:
print(f'❌ Import failed: {e}')
sys.exit(1)
"
- name: Test Manifest Validation
run: |
python -c "
import json
import sys
try:
with open('custom_components/adguard_hub/manifest.json', 'r') as f:
manifest = json.load(f)
required_keys = ['domain', 'name', 'version', 'documentation', 'requirements']
missing = [k for k in required_keys if k not in manifest]
if missing:
print(f'❌ Missing manifest keys: {missing}')
sys.exit(1)
print('✅ Manifest is valid')
except Exception as e:
print(f'❌ Manifest validation failed: {e}')
sys.exit(1)
"
- name: Upload Coverage Reports
uses: actions/upload-artifact@v4
if: matrix.python-version == '3.13' && matrix.home-assistant-version == '2025.9.4'
with:
name: coverage-report
path: coverage.xml

View File

@@ -23,17 +23,48 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install flake8 black isort pip install -r requirements-dev.txt
pip install homeassistant==2025.9.4
- name: Code Formatting Check - name: Code Formatting Check (Black)
run: | run: |
black --check custom_components/ || echo "Code formatting issues found" black --check custom_components/ tests/
- name: Import Sorting - name: Import Sorting Check (isort)
run: | run: |
isort --check-only custom_components/ || echo "Import sorting issues found" isort --check-only --diff custom_components/ tests/
- name: Linting - name: Linting (flake8)
run: | run: |
flake8 custom_components/ --count --select=E9,F63,F7,F82 --show-source --statistics || echo "Critical linting issues found" flake8 custom_components/ tests/
- name: Type Checking (mypy)
run: |
mypy custom_components/adguard_hub/ --ignore-missing-imports
continue-on-error: true
security-scan:
name: Security Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install Security Tools
run: |
python -m pip install --upgrade pip
pip install bandit safety
- name: Security Check (Bandit)
run: |
bandit -r custom_components/ -ll
- name: Dependency Security Check (Safety)
run: |
pip install -r requirements.txt
safety check

View File

@@ -3,32 +3,68 @@ name: Release
on: on:
push: push:
tags: tags:
- 'v*' - 'v*.*.*'
permissions:
contents: write
jobs: jobs:
release: create-release:
name: Create Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Version - name: Validate Tag Format
run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid tag format. Expected: v1.2.3"
exit 1
fi
echo "✅ Valid semantic version tag: ${{ github.ref_name }}"
- name: Extract Version
id: version id: version
run: | run: |
VERSION=${GITHUB_REF#refs/tags/v} VERSION=${{ github.ref_name }}
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT VERSION_NUMBER=${VERSION#v}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_number=${VERSION_NUMBER}" >> $GITHUB_OUTPUT
- name: Update Manifest Version
run: |
sed -i 's/"version": ".*"/"version": "${{ steps.version.outputs.version_number }}"/' custom_components/adguard_hub/manifest.json
- name: Run Tests Before Release
run: |
python -m pip install --upgrade pip
pip install homeassistant==2025.9.4
pip install -r requirements-dev.txt
mkdir -p custom_components
touch custom_components/__init__.py
python -m pytest tests/ -v --tb=short
- name: Create Release Archive - name: Create Release Archive
run: | run: |
cd custom_components cd custom_components
zip -r ../adguard-control-hub-${{ steps.version.outputs.VERSION }}.zip adguard_hub/ zip -r ../adguard-control-hub-${{ steps.version.outputs.version_number }}.zip adguard_hub/
- name: Generate Release Notes - name: Generate Changelog
id: changelog
run: | run: |
echo "# AdGuard Control Hub v${{ steps.version.outputs.VERSION }}" > release_notes.md PREVIOUS_TAG=$(git tag --sort=-version:refname | head -2 | tail -1 2>/dev/null || echo "")
echo "Complete Home Assistant integration for AdGuard Home" >> release_notes.md if [ -z "$PREVIOUS_TAG" ]; then
echo "changelog=Initial release of AdGuard Control Hub" >> $GITHUB_OUTPUT
else
echo "changelog=Changes since $PREVIOUS_TAG" >> $GITHUB_OUTPUT
fi
- name: Create Release - name: Success Message
run: echo "Release created for version ${{ steps.version.outputs.VERSION }}" run: |
echo "🎉 Release ${{ steps.version.outputs.version }} created!"
echo "📦 Archive: adguard-control-hub-${{ steps.version.outputs.version_number }}.zip"

33
.gitignore vendored
View File

@@ -1,12 +1,35 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.pyo
*.egg-info/
build/
dist/
.eggs/
# Virtual environments
.venv .venv
venv/ venv/
__pycache__/ ENV/
*.pyc env.bak/
venv.bak/
# Testing
.pytest_cache/ .pytest_cache/
.coverage .coverage
.mypy_cache/ htmlcov/
*.egg-info/ .tox/
.DS_Store .nox/
# IDEs
.vscode/ .vscode/
.idea/ .idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
*.log *.log

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 AdGuard Control Hub Copyright (c) 2024 AdGuard Control Hub
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -9,6 +9,9 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

View File

@@ -2,6 +2,8 @@
**The ultimate Home Assistant integration for AdGuard Home** **The ultimate Home Assistant integration for AdGuard Home**
Transform your AdGuard Home into a smart network management powerhouse.
## ✨ Features ## ✨ Features
### 🎯 Smart Client Management ### 🎯 Smart Client Management
@@ -35,6 +37,7 @@
4. Add via Integrations UI 4. Add via Integrations UI
## ⚙️ Configuration ## ⚙️ Configuration
- **Host**: AdGuard Home IP/hostname - **Host**: AdGuard Home IP/hostname
- **Port**: Default 3000 - **Port**: Default 3000
- **Username/Password**: Admin credentials - **Username/Password**: Admin credentials
@@ -56,4 +59,5 @@ automation:
``` ```
## 📄 License ## 📄 License
MIT License - Made with ❤️ for Home Assistant users! MIT License - Made with ❤️ for Home Assistant users!

View File

@@ -1 +0,0 @@
"""Custom components for Home Assistant."""

View File

@@ -6,7 +6,7 @@ Transform your AdGuard Home into a smart network management powerhouse.
import asyncio import asyncio
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Dict, Any from typing import Any, Dict
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
@@ -15,8 +15,8 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import AdGuardHomeAPI, AdGuardConnectionError from .api import AdGuardHomeAPI, AdGuardConnectionError, AdGuardHomeError
from .const import DOMAIN, PLATFORMS, SCAN_INTERVAL, CONF_SSL, CONF_VERIFY_SSL from .const import CONF_SSL, CONF_VERIFY_SSL, DOMAIN, PLATFORMS, SCAN_INTERVAL
from .services import AdGuardControlHubServices from .services import AdGuardControlHubServices
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,16 +46,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data[CONF_HOST], entry.data[CONF_HOST],
entry.data[CONF_PORT] entry.data[CONF_PORT]
) )
except Exception as err: except AdGuardHomeError as err:
_LOGGER.error("Failed to connect to AdGuard Home: %s", err) _LOGGER.error("Failed to connect to AdGuard Home: %s", err)
raise ConfigEntryNotReady(f"Unable to connect: {err}") from err raise ConfigEntryNotReady(f"Unable to connect: {err}") from err
except Exception as err:
_LOGGER.exception("Unexpected error connecting to AdGuard Home")
raise ConfigEntryNotReady(f"Unexpected error: {err}") from err
# Create update coordinator # Create update coordinator
coordinator = AdGuardControlHubCoordinator(hass, api) coordinator = AdGuardControlHubCoordinator(hass, api)
try: try:
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
except Exception as err: except UpdateFailed as err:
_LOGGER.error("Failed to perform initial data refresh: %s", err) _LOGGER.error("Failed to perform initial data refresh: %s", err)
raise ConfigEntryNotReady(f"Failed to fetch initial data: {err}") from err raise ConfigEntryNotReady(f"Failed to fetch initial data: {err}") from err
@@ -72,17 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except Exception as err: except Exception as err:
_LOGGER.error("Failed to set up platforms: %s", err) _LOGGER.error("Failed to set up platforms: %s", err)
# Clean up on failure # Clean up on failure
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id, None)
raise ConfigEntryNotReady(f"Failed to set up platforms: {err}") from err raise ConfigEntryNotReady(f"Failed to set up platforms: {err}") from err
# Register services (only once) # Register services (only once)
if not hass.services.has_service(DOMAIN, "block_services"): services_key = f"{DOMAIN}_services"
if services_key not in hass.data:
services = AdGuardControlHubServices(hass) services = AdGuardControlHubServices(hass)
services.register_services() services.register_services()
hass.data.setdefault(f"{DOMAIN}_services", services) hass.data[services_key] = services
_LOGGER.info("AdGuard Control Hub setup complete for %s:%s", _LOGGER.info("AdGuard Control Hub setup complete")
entry.data[CONF_HOST], entry.data[CONF_PORT])
return True return True
@@ -92,14 +95,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
# Remove this entry's data # Remove this entry's data
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id, None)
# Unregister services if this was the last entry # Unregister services if this was the last entry
if not hass.data[DOMAIN]: if not hass.data[DOMAIN]:
services = hass.data.get(f"{DOMAIN}_services") services_key = f"{DOMAIN}_services"
services = hass.data.get(services_key)
if services: if services:
services.unregister_services() services.unregister_services()
hass.data.pop(f"{DOMAIN}_services", None) hass.data.pop(services_key, None)
hass.data.pop(DOMAIN, None) hass.data.pop(DOMAIN, None)
return unload_ok return unload_ok
@@ -108,7 +112,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
class AdGuardControlHubCoordinator(DataUpdateCoordinator): class AdGuardControlHubCoordinator(DataUpdateCoordinator):
"""AdGuard Control Hub data update coordinator.""" """AdGuard Control Hub data update coordinator."""
def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI): def __init__(self, hass: HomeAssistant, api: AdGuardHomeAPI) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
hass, hass,
@@ -134,7 +138,7 @@ class AdGuardControlHubCoordinator(DataUpdateCoordinator):
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
clients, statistics, status = results clients, statistics, status = results
# Update stored data (use empty dict if fetch failed) # Update stored data (use previous data if fetch failed)
if not isinstance(clients, Exception): if not isinstance(clients, Exception):
self._clients = { self._clients = {
client["name"]: client client["name"]: client

View File

@@ -13,22 +13,18 @@ _LOGGER = logging.getLogger(__name__)
class AdGuardHomeError(Exception): class AdGuardHomeError(Exception):
"""Base exception for AdGuard Home API.""" """Base exception for AdGuard Home API."""
pass
class AdGuardConnectionError(AdGuardHomeError): class AdGuardConnectionError(AdGuardHomeError):
"""Exception for connection errors.""" """Exception for connection errors."""
pass
class AdGuardAuthError(AdGuardHomeError): class AdGuardAuthError(AdGuardHomeError):
"""Exception for authentication errors.""" """Exception for authentication errors."""
pass
class AdGuardNotFoundError(AdGuardHomeError): class AdGuardNotFoundError(AdGuardHomeError):
"""Exception for not found errors.""" """Exception for not found errors."""
pass
class AdGuardHomeAPI: class AdGuardHomeAPI:
@@ -43,7 +39,7 @@ class AdGuardHomeAPI:
ssl: bool = False, ssl: bool = False,
session: Optional[aiohttp.ClientSession] = None, session: Optional[aiohttp.ClientSession] = None,
timeout: int = 10, timeout: int = 10,
): ) -> None:
"""Initialize the API wrapper.""" """Initialize the API wrapper."""
self.host = host self.host = host
self.port = port self.port = port
@@ -107,11 +103,13 @@ class AdGuardHomeAPI:
return {"response": text} return {"response": text}
except asyncio.TimeoutError as err: except asyncio.TimeoutError as err:
raise AdGuardConnectionError(f"Timeout: {err}") raise AdGuardConnectionError(f"Timeout: {err}") from err
except ClientError as err: except ClientError as err:
raise AdGuardConnectionError(f"Client error: {err}") raise AdGuardConnectionError(f"Client error: {err}") from err
except Exception as err: except Exception as err:
raise AdGuardHomeError(f"Unexpected error: {err}") if isinstance(err, AdGuardHomeError):
raise
raise AdGuardHomeError(f"Unexpected error: {err}") from err
async def test_connection(self) -> bool: async def test_connection(self) -> bool:
"""Test the connection to AdGuard Home.""" """Test the connection to AdGuard Home."""

View File

@@ -1,6 +1,7 @@
"""Constants for the AdGuard Control Hub integration.""" """Constants for the AdGuard Control Hub integration."""
from typing import Final from typing import Final
# Integration details
DOMAIN: Final = "adguard_hub" DOMAIN: Final = "adguard_hub"
MANUFACTURER: Final = "AdGuard Control Hub" MANUFACTURER: Final = "AdGuard Control Hub"

View File

@@ -1,14 +1,14 @@
{ {
"domain": "adguard_hub", "domain": "adguard_hub",
"name": "AdGuard Control Hub", "name": "AdGuard Control Hub",
"codeowners": ["@sq4ind"], "codeowners": ["@sq4ind"],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://git.sq4ind.eu/sq4ind/adguard-control-hub", "documentation": "https://git.sq4ind.eu/sq4ind/adguard-control-hub",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": [ "requirements": [
"aiohttp>=3.8.0" "aiohttp>=3.8.0"
], ],
"version": "1.0.0" "version": "1.0.0"
} }

View File

@@ -63,7 +63,7 @@ class AdGuardQueriesCounterSensor(AdGuardBaseSensor):
self._attr_native_unit_of_measurement = "queries" self._attr_native_unit_of_measurement = "queries"
@property @property
def native_value(self) -> int | None: def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
stats = self.coordinator.statistics stats = self.coordinator.statistics
return stats.get("num_dns_queries", 0) return stats.get("num_dns_queries", 0)
@@ -77,12 +77,12 @@ class AdGuardBlockedCounterSensor(AdGuardBaseSensor):
super().__init__(coordinator, api) super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_blocked_queries" self._attr_unique_id = f"{api.host}_{api.port}_blocked_queries"
self._attr_name = "AdGuard Blocked Queries" self._attr_name = "AdGuard Blocked Queries"
self._attr_icon = "mdi:shield-check" self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._attr_state_class = SensorStateClass.TOTAL_INCREASING
self._attr_native_unit_of_measurement = "queries" self._attr_native_unit_of_measurement = "queries"
@property @property
def native_value(self) -> int | None: def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
stats = self.coordinator.statistics stats = self.coordinator.statistics
return stats.get("num_blocked_filtering", 0) return stats.get("num_blocked_filtering", 0)
@@ -96,19 +96,19 @@ class AdGuardBlockingPercentageSensor(AdGuardBaseSensor):
super().__init__(coordinator, api) super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_blocking_percentage" self._attr_unique_id = f"{api.host}_{api.port}_blocking_percentage"
self._attr_name = "AdGuard Blocking Percentage" self._attr_name = "AdGuard Blocking Percentage"
self._attr_icon = "mdi:percent" self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = PERCENTAGE self._attr_native_unit_of_measurement = PERCENTAGE
@property @property
def native_value(self) -> float | None: def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
stats = self.coordinator.statistics stats = self.coordinator.statistics
total_queries = stats.get("num_dns_queries", 0) total_queries = stats.get("num_dns_queries", 0)
blocked_queries = stats.get("num_blocked_filtering", 0) blocked_queries = stats.get("num_blocked_filtering", 0)
if total_queries == 0: if total_queries == 0:
return 0 return 0.0
percentage = (blocked_queries / total_queries) * 100 percentage = (blocked_queries / total_queries) * 100
return round(percentage, 2) return round(percentage, 2)
@@ -122,11 +122,11 @@ class AdGuardClientCountSensor(AdGuardBaseSensor):
super().__init__(coordinator, api) super().__init__(coordinator, api)
self._attr_unique_id = f"{api.host}_{api.port}_clients_count" self._attr_unique_id = f"{api.host}_{api.port}_clients_count"
self._attr_name = "AdGuard Clients Count" self._attr_name = "AdGuard Clients Count"
self._attr_icon = "mdi:account-multiple" self._attr_icon = ICON_STATISTICS
self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = "clients" self._attr_native_unit_of_measurement = "clients"
@property @property
def native_value(self) -> int | None: def native_value(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return len(self.coordinator.clients) return len(self.coordinator.clients)

View File

@@ -48,16 +48,10 @@ class AdGuardControlHubServices:
self.hass.services.register( self.hass.services.register(
DOMAIN, "emergency_unblock", self.emergency_unblock, schema=SCHEMA_EMERGENCY_UNBLOCK DOMAIN, "emergency_unblock", self.emergency_unblock, schema=SCHEMA_EMERGENCY_UNBLOCK
) )
self.hass.services.register(DOMAIN, "add_client", self.add_client)
self.hass.services.register(DOMAIN, "remove_client", self.remove_client)
self.hass.services.register(DOMAIN, "bulk_update_clients", self.bulk_update_clients)
def unregister_services(self) -> None: def unregister_services(self) -> None:
"""Unregister all services.""" """Unregister all services."""
services = [ services = ["block_services", "unblock_services", "emergency_unblock"]
"block_services", "unblock_services", "emergency_unblock",
"add_client", "remove_client", "bulk_update_clients"
]
for service in services: for service in services:
if self.hass.services.has_service(DOMAIN, service): if self.hass.services.has_service(DOMAIN, service):
@@ -125,29 +119,3 @@ class AdGuardControlHubServices:
asyncio.create_task(delayed_enable()) asyncio.create_task(delayed_enable())
except Exception as err: except Exception as err:
_LOGGER.error("Failed to execute emergency unblock: %s", err) _LOGGER.error("Failed to execute emergency unblock: %s", err)
async def add_client(self, call: ServiceCall) -> None:
"""Add a new client."""
client_data = dict(call.data)
for entry_data in self.hass.data[DOMAIN].values():
api: AdGuardHomeAPI = entry_data["api"]
try:
await api.add_client(client_data)
_LOGGER.info("Successfully added client: %s", client_data.get("name"))
except Exception as err:
_LOGGER.error("Failed to add client: %s", err)
async def remove_client(self, call: ServiceCall) -> None:
"""Remove a client."""
client_name = call.data.get("name")
for entry_data in self.hass.data[DOMAIN].values():
api: AdGuardHomeAPI = entry_data["api"]
try:
await api.delete_client(client_name)
_LOGGER.info("Successfully removed client: %s", client_name)
except Exception as err:
_LOGGER.error("Failed to remove client: %s", err)
async def bulk_update_clients(self, call: ServiceCall) -> None:
"""Bulk update clients."""
_LOGGER.info("Bulk update clients called")

View File

@@ -1,27 +1,27 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "AdGuard Control Hub", "title": "AdGuard Control Hub",
"description": "Configure your AdGuard Home connection", "description": "Configure your AdGuard Home connection",
"data": { "data": {
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"ssl": "Use SSL", "ssl": "Use SSL",
"verify_ssl": "Verify SSL Certificate" "verify_ssl": "Verify SSL Certificate"
}
}
},
"error": {
"cannot_connect": "Failed to connect to AdGuard Home",
"invalid_auth": "Invalid username or password",
"timeout": "Connection timeout",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AdGuard Control Hub is already configured"
} }
}
},
"error": {
"cannot_connect": "Failed to connect to AdGuard Home",
"invalid_auth": "Invalid username or password",
"timeout": "Connection timeout",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AdGuard Control Hub is already configured"
} }
}
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "AdGuard Control Hub", "name": "AdGuard Control Hub",
"content_in_root": false, "content_in_root": false,
"filename": "adguard_hub", "filename": "adguard_hub",
"homeassistant": "2025.1.0", "homeassistant": "2024.12.0",
"iot_class": "Local Polling" "iot_class": "Local Polling"
} }

31
info.md
View File

@@ -1,11 +1,26 @@
# AdGuard Control Hub # 🛡️ AdGuard Control Hub
Complete Home Assistant integration for AdGuard Home network management. **The ultimate Home Assistant integration for AdGuard Home**
## Features Transform your AdGuard Home into a smart network management powerhouse with comprehensive Home Assistant integration.
- Smart client management
- Service blocking controls
- Real-time statistics
- Emergency unblock capabilities
Install via HACS or manually extract to `custom_components/adguard_hub/` ## ✨ Features
- **Smart Client Management**: Automatic discovery and control
- **Service Blocking**: Per-client service restrictions
- **Real-time Monitoring**: DNS statistics and performance metrics
- **Home Assistant Integration**: Full entity support and automations
## 🚀 Installation
### HACS Installation (Recommended)
1. Add custom repository: `https://git.sq4ind.eu/sq4ind/adguard-control-hub`
2. Install "AdGuard Control Hub"
3. Restart Home Assistant
4. Configure via UI
## 📋 Requirements
- Home Assistant 2024.12.0+
- AdGuard Home with API access
Made with ❤️ for the Home Assistant community!

View File

@@ -1,11 +1,22 @@
[tool.black] [tool.black]
line-length = 127 line-length = 127
target-version = ['py313'] target-version = ['py311', 'py312', 'py313']
[tool.isort] [tool.isort]
profile = "black" profile = "black"
line_length = 127 line_length = 127
[tool.mypy] [tool.mypy]
python_version = "3.13" python_version = "3.11"
ignore_missing_imports = true ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--cov=custom_components.adguard_hub",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-fail-under=80",
"--asyncio-mode=auto",
"-v"
]

View File

@@ -1,9 +1,12 @@
# Development dependencies # Development dependencies
black==24.3.0 black>=24.3.0
flake8==7.0.0 flake8>=7.0.0
isort==5.13.2 isort>=5.13.2
mypy==1.9.0 mypy>=1.9.0
pytest==8.1.1 pytest>=8.1.1
pytest-homeassistant-custom-component==0.13.281 pytest-homeassistant-custom-component>=0.13.281
pytest-cov==5.0.0 pytest-cov>=5.0.0
pytest-asyncio>=0.23.0
homeassistant==2025.9.4 homeassistant==2025.9.4
bandit>=1.7.5
safety>=3.0.0

View File

@@ -1,5 +1,6 @@
homeassistant==2025.9.4 homeassistant==2025.9.4
pytest pytest>=8.1.1
pytest-homeassistant-custom-component pytest-homeassistant-custom-component>=0.13.281
pytest-asyncio pytest-asyncio>=0.23.0
pytest-cov pytest-cov>=5.0.0
aiohttp>=3.8.0

View File

@@ -1 +1 @@
"""Tests for AdGuard Control Hub integration.""" """Tests for AdGuard Control Hub."""

View File

@@ -1,5 +1,12 @@
"""Test configuration and fixtures.""" """Test configuration and fixtures."""
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from custom_components.adguard_hub.api import AdGuardHomeAPI
from custom_components.adguard_hub.const import DOMAIN
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -8,9 +15,76 @@ def auto_enable_custom_integrations(enable_custom_integrations):
yield yield
@pytest.fixture(autouse=True) @pytest.fixture
def mock_platform_setup(): def mock_config_entry():
"""Mock platform setup to avoid actual platform loading.""" """Mock config entry for testing."""
from unittest.mock import patch return ConfigEntry(
with patch("homeassistant.config_entries.ConfigEntry.async_forward_entry_setups"): version=1,
yield minor_version=1,
domain=DOMAIN,
title="Test AdGuard Control Hub",
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 3000,
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
options={},
source=SOURCE_USER,
entry_id="test_entry_id",
unique_id="192.168.1.100:3000",
)
@pytest.fixture
def mock_api():
"""Mock AdGuard Home API."""
api = MagicMock(spec=AdGuardHomeAPI)
api.host = "192.168.1.100"
api.port = 3000
api.test_connection = AsyncMock(return_value=True)
api.get_status = AsyncMock(return_value={
"protection_enabled": True,
"version": "v0.107.0",
"dns_port": 53,
"running": True,
})
api.get_clients = AsyncMock(return_value={
"clients": [
{
"name": "test_client",
"ids": ["192.168.1.50"],
"filtering_enabled": True,
"blocked_services": {"ids": ["youtube"]},
}
]
})
api.get_statistics = AsyncMock(return_value={
"num_dns_queries": 10000,
"num_blocked_filtering": 1500,
"avg_processing_time": 2.5,
"filtering_rules_count": 75000,
})
api.get_client_by_name = AsyncMock(return_value={
"name": "test_client",
"ids": ["192.168.1.50"],
"filtering_enabled": True,
"blocked_services": {"ids": ["youtube"]},
})
api.set_protection = AsyncMock(return_value={"success": True})
return api
@pytest.fixture
def mock_hass():
"""Mock Home Assistant instance."""
hass = MagicMock(spec=HomeAssistant)
hass.data = {}
hass.services = MagicMock()
hass.services.has_service = MagicMock(return_value=False)
hass.services.register = MagicMock()
hass.services.remove = MagicMock()
hass.config_entries = MagicMock()
hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True)
hass.config_entries.async_unload_platforms = AsyncMock(return_value=True)
return hass

View File

@@ -4,80 +4,50 @@ from unittest.mock import AsyncMock, MagicMock
from custom_components.adguard_hub.api import AdGuardHomeAPI from custom_components.adguard_hub.api import AdGuardHomeAPI
@pytest.fixture class TestAdGuardHomeAPI:
def mock_session(): """Test the AdGuard Home API wrapper."""
"""Mock aiohttp session with proper async context manager."""
session = MagicMock()
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = AsyncMock(return_value={"status": "ok"})
response.status = 200
response.content_length = 100
# Properly mock the async context manager def test_api_initialization(self):
context_manager = MagicMock() """Test API initialization."""
context_manager.__aenter__ = AsyncMock(return_value=response) api = AdGuardHomeAPI(
context_manager.__aexit__ = AsyncMock(return_value=None) host="192.168.1.100",
port=3000,
username="admin",
password="password",
ssl=True,
)
session.request = MagicMock(return_value=context_manager) assert api.host == "192.168.1.100"
return session
@pytest.mark.asyncio
async def test_api_connection(mock_session):
"""Test API connection."""
api = AdGuardHomeAPI(
host="test-host",
port=3000,
username="admin",
password="password",
session=mock_session
)
result = await api.test_connection()
assert result is True
mock_session.request.assert_called()
@pytest.mark.asyncio
async def test_api_get_status(mock_session):
"""Test getting status."""
api = AdGuardHomeAPI(
host="test-host",
port=3000,
session=mock_session
)
status = await api.get_status()
assert status == {"status": "ok"}
mock_session.request.assert_called()
@pytest.mark.asyncio
async def test_api_context_manager():
"""Test API as async context manager."""
async with AdGuardHomeAPI(host="test-host", port=3000) as api:
assert api is not None
assert api.host == "test-host"
assert api.port == 3000 assert api.port == 3000
assert api.username == "admin"
assert api.password == "password"
assert api.ssl is True
assert api.base_url == "https://192.168.1.100:3000"
@pytest.mark.asyncio
async def test_api_context_manager(self):
"""Test API as async context manager."""
async with AdGuardHomeAPI(host="192.168.1.100", port=3000) as api:
assert api is not None
assert api.host == "192.168.1.100"
assert api.port == 3000
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_api_error_handling(): async def test_test_connection_success(self):
"""Test API error handling.""" """Test successful connection test."""
# Test with a session that raises an exception session = MagicMock()
session = MagicMock() response = MagicMock()
context_manager = MagicMock() response.status = 200
context_manager.__aenter__ = AsyncMock(side_effect=Exception("Connection error")) response.json = AsyncMock(return_value={"protection_enabled": True})
context_manager.__aexit__ = AsyncMock(return_value=None) response.raise_for_status = MagicMock()
session.request = MagicMock(return_value=context_manager) response.content_length = 100
api = AdGuardHomeAPI( context_manager = MagicMock()
host="test-host", context_manager.__aenter__ = AsyncMock(return_value=response)
port=3000, context_manager.__aexit__ = AsyncMock(return_value=None)
session=session session.request = MagicMock(return_value=context_manager)
)
with pytest.raises(Exception): api = AdGuardHomeAPI(host="192.168.1.100", session=session)
await api.get_status() result = await api.test_connection()
assert result is True

View File

@@ -1,71 +0,0 @@
"""Test config flow for AdGuard Control Hub."""
import pytest
from unittest.mock import AsyncMock, patch
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.adguard_hub.const import DOMAIN
@pytest.mark.asyncio
async def test_config_flow_success(hass: HomeAssistant):
"""Test successful config flow."""
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.return_value = {
"title": "AdGuard Control Hub (192.168.1.100)",
"host": "192.168.1.100",
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
@pytest.mark.asyncio
async def test_config_flow_cannot_connect(hass: HomeAssistant):
"""Test config flow with connection error."""
from custom_components.adguard_hub.config_flow import CannotConnect
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.side_effect = CannotConnect("Connection failed")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={
"host": "invalid-host",
"port": 3000,
"username": "admin",
"password": "password",
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
@pytest.mark.asyncio
async def test_config_flow_invalid_auth(hass: HomeAssistant):
"""Test config flow with authentication error."""
from custom_components.adguard_hub.config_flow import InvalidAuth
with patch("custom_components.adguard_hub.config_flow.validate_input") as mock_validate:
mock_validate.side_effect = InvalidAuth("Auth failed")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data={
"host": "192.168.1.100",
"port": 3000,
"username": "wrong",
"password": "wrong",
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == "invalid_auth"

View File

@@ -1,228 +1,48 @@
"""Test the complete AdGuard Control Hub integration.""" """Test the complete AdGuard Control Hub integration."""
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock, patch
from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from custom_components.adguard_hub import async_setup_entry, async_unload_entry from custom_components.adguard_hub import async_setup_entry, async_unload_entry
from custom_components.adguard_hub.api import AdGuardHomeAPI
from custom_components.adguard_hub.const import DOMAIN from custom_components.adguard_hub.const import DOMAIN
@pytest.fixture class TestIntegrationSetup:
def mock_config_entry(): """Test integration setup and unload."""
"""Mock config entry compatible with HA 2025.9.4."""
return ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Test AdGuard",
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 3000,
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
options={},
source=SOURCE_USER,
entry_id="test_entry_id",
unique_id="192.168.1.100:3000",
discovery_keys=set(),
subentries_data={},
)
@pytest.mark.asyncio
async def test_setup_entry_success(self, mock_hass, mock_config_entry, mock_api):
"""Test successful setup of config entry."""
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), patch("custom_components.adguard_hub.async_get_clientsession"):
@pytest.fixture result = await async_setup_entry(mock_hass, mock_config_entry)
def mock_api():
"""Mock API instance.""" assert result is True
api = MagicMock(spec=AdGuardHomeAPI) assert DOMAIN in mock_hass.data
api.host = "192.168.1.100" assert mock_config_entry.entry_id in mock_hass.data[DOMAIN]
api.port = 3000
api.test_connection = AsyncMock(return_value=True) @pytest.mark.asyncio
api.get_status = AsyncMock(return_value={ async def test_setup_entry_connection_failure(self, mock_hass, mock_config_entry):
"protection_enabled": True, """Test setup failure due to connection error."""
"version": "v0.107.0", mock_api = MagicMock()
"dns_port": 53, mock_api.test_connection = pytest.AsyncMock(return_value=False)
"running": True,
}) with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), patch("custom_components.adguard_hub.async_get_clientsession"):
api.get_clients = AsyncMock(return_value={
"clients": [ with pytest.raises(ConfigEntryNotReady):
{ await async_setup_entry(mock_hass, mock_config_entry)
"name": "test_client",
"ids": ["192.168.1.50"], @pytest.mark.asyncio
"filtering_enabled": True, async def test_unload_entry_success(self, mock_hass, mock_config_entry):
"blocked_services": {"ids": ["youtube"]}, """Test successful unloading of config entry."""
mock_hass.data[DOMAIN] = {
mock_config_entry.entry_id: {
"coordinator": MagicMock(),
"api": MagicMock(),
} }
]
})
api.get_statistics = AsyncMock(return_value={
"num_dns_queries": 1000,
"num_blocked_filtering": 100,
"num_dns_queries_today": 500,
"num_blocked_filtering_today": 50,
"filtering_rules_count": 50000,
"avg_processing_time": 2.5,
})
return api
@pytest.mark.asyncio
async def test_setup_entry_success(hass: HomeAssistant, mock_config_entry, mock_api):
"""Test successful setup of config entry."""
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), \
patch("custom_components.adguard_hub.async_get_clientsession"), \
patch.object(hass.config_entries, "async_forward_entry_setups", return_value=True):
result = await async_setup_entry(hass, mock_config_entry)
assert result is True
assert DOMAIN in hass.data
assert mock_config_entry.entry_id in hass.data[DOMAIN]
assert "coordinator" in hass.data[DOMAIN][mock_config_entry.entry_id]
assert "api" in hass.data[DOMAIN][mock_config_entry.entry_id]
@pytest.mark.asyncio
async def test_setup_entry_connection_failure(hass: HomeAssistant, mock_config_entry):
"""Test setup failure due to connection error."""
mock_api = MagicMock(spec=AdGuardHomeAPI)
mock_api.test_connection = AsyncMock(return_value=False)
with patch("custom_components.adguard_hub.AdGuardHomeAPI", return_value=mock_api), \
patch("custom_components.adguard_hub.async_get_clientsession"):
with pytest.raises(Exception): # Should raise ConfigEntryNotReady
await async_setup_entry(hass, mock_config_entry)
@pytest.mark.asyncio
async def test_unload_entry(hass: HomeAssistant, mock_config_entry):
"""Test unloading of config entry."""
# Set up initial data
hass.data[DOMAIN] = {
mock_config_entry.entry_id: {
"coordinator": MagicMock(),
"api": MagicMock(),
} }
}
with patch.object(hass.config_entries, "async_unload_platforms", return_value=True): result = await async_unload_entry(mock_hass, mock_config_entry)
result = await async_unload_entry(hass, mock_config_entry)
assert result is True assert result is True
assert mock_config_entry.entry_id not in hass.data[DOMAIN] assert mock_config_entry.entry_id not in mock_hass.data[DOMAIN]
@pytest.mark.asyncio
async def test_coordinator_data_update(hass: HomeAssistant, mock_api):
"""Test coordinator data update functionality."""
from custom_components.adguard_hub import AdGuardControlHubCoordinator
coordinator = AdGuardControlHubCoordinator(hass, mock_api)
# Test successful data update
data = await coordinator._async_update_data()
assert "clients" in data
assert "statistics" in data
assert "status" in data
assert "test_client" in data["clients"]
assert data["statistics"]["num_dns_queries"] == 1000
assert data["status"]["protection_enabled"] is True
@pytest.mark.asyncio
async def test_api_error_handling(mock_api):
"""Test API error handling."""
from custom_components.adguard_hub.api import AdGuardConnectionError, AdGuardAuthError
# Test connection error
mock_api.get_status = AsyncMock(side_effect=AdGuardConnectionError("Connection failed"))
with pytest.raises(AdGuardConnectionError):
await mock_api.get_status()
# Test auth error
mock_api.get_clients = AsyncMock(side_effect=AdGuardAuthError("Auth failed"))
with pytest.raises(AdGuardAuthError):
await mock_api.get_clients()
def test_services_registration(hass: HomeAssistant):
"""Test that services are properly registered."""
from custom_components.adguard_hub.services import AdGuardControlHubServices
# Create services without async context
services = AdGuardControlHubServices(hass)
services.register_services()
# Check that services are registered
assert hass.services.has_service(DOMAIN, "block_services")
assert hass.services.has_service(DOMAIN, "unblock_services")
assert hass.services.has_service(DOMAIN, "emergency_unblock")
assert hass.services.has_service(DOMAIN, "add_client")
assert hass.services.has_service(DOMAIN, "remove_client")
assert hass.services.has_service(DOMAIN, "bulk_update_clients")
# Clean up
services.unregister_services()
def test_blocked_services_constants():
"""Test that blocked services are properly defined."""
from custom_components.adguard_hub.const import BLOCKED_SERVICES
assert "youtube" in BLOCKED_SERVICES
assert "netflix" in BLOCKED_SERVICES
assert "gaming" in BLOCKED_SERVICES
assert "facebook" in BLOCKED_SERVICES
# Check friendly names are defined
assert BLOCKED_SERVICES["youtube"] == "YouTube"
assert BLOCKED_SERVICES["netflix"] == "Netflix"
def test_api_endpoints():
"""Test that API endpoints are properly defined."""
from custom_components.adguard_hub.const import API_ENDPOINTS
required_endpoints = [
"status", "clients", "stats", "protection",
"clients_add", "clients_update", "clients_delete"
]
for endpoint in required_endpoints:
assert endpoint in API_ENDPOINTS
assert API_ENDPOINTS[endpoint].startswith("/")
@pytest.mark.asyncio
async def test_client_operations(mock_api):
"""Test client add/update/delete operations."""
# Test add client
client_data = {
"name": "new_client",
"ids": ["192.168.1.200"],
"filtering_enabled": True,
}
mock_api.add_client = AsyncMock(return_value={"success": True})
result = await mock_api.add_client(client_data)
assert result["success"] is True
# Test update client
update_data = {
"name": "new_client",
"data": {"filtering_enabled": False}
}
mock_api.update_client = AsyncMock(return_value={"success": True})
result = await mock_api.update_client(update_data)
assert result["success"] is True
# Test delete client
mock_api.delete_client = AsyncMock(return_value={"success": True})
result = await mock_api.delete_client("new_client")
assert result["success"] is True