Files
supervisor/tests/plugins/test_audio.py
T
Mike Degatano 2d153ac497 Add one-shot mode and docker-given totals to stats API (#7168)
* Add one-shot mode and docker-given totals to stats API

- Add one-shot query option to container stats to skip the calculated
  CPU percentage window, returning docker-given totals directly.
- Refactor container_stats() with translatable, structured Docker
  errors and timeout handling instead of generic Docker exceptions.
- Translate low-level Docker errors into component-flavored errors
  (NotRunningError/StatsTimeoutError/UnknownError) for Home Assistant,
  Supervisor, and each plugin (Audio, Cli, CoreDNS, Multicast,
  Observer) as well as apps, mirroring the existing App.stats()
  pattern for consistent, informative API responses.
- Add a shared api_return_stats() helper to remove duplicated stats
  handling logic across the 8 stats API endpoints.
- DockerContainerNotFoundError now inherits from DockerNotFound.

* Contain aiodocker _query access and split v1/v2 stats response model

- Contain the reach into aiodocker's protected _query internals (needed
  for one-shot stats until aio-libs/aiodocker#1054 lands) to a single
  DockerAPI._query_one_shot_stats() helper.
- Split the stats API response model between v1 (legacy, windowed CPU
  percentage still available via opt-in one_shot query string) and v2
  (always one-shot, no query string, cpu_percent dropped since it would
  never carry a meaningful value). Both share the same
  api_return_stats(stats, legacy=...) helper so the response model
  stays in one place.
- DockerAPI.container_stats() keeps its existing one_shot keyword only;
  the legacy/v1 vs v2 distinction is handled entirely in the API layer.

* Detect stopped/restarting containers in one-shot stats response

Docker returns a stub response containing only the container's id/name
(no cpu_stats/memory_stats/networks data) for a stopped or restarting
container instead of an error, even in one-shot mode - the not-running
check in daemon.ContainerStats happens before the one-shot branch, so
requesting one-shot stats doesn't change this. Detect the missing
online_cpus field (only ever present while running) and raise
DockerContainerNotRunningError instead of returning a payload that
DockerStats would otherwise turn into a successful all-zero response.

* Add tests for stats error paths introduced in one-shot/totals PR

- Cover DockerAPI.container_stats/_query_one_shot_stats edge cases: empty
  one-shot response, empty windowed response, and the raw _query_one_shot_stats
  helper.
- Add stats() error-translation tests (not-running, timeout, unknown) for the
  audio, cli, multicast and observer plugins, none of which had any coverage.
- Add stats endpoint tests (v1 + v2) for the cli, audio, dns, multicast and
  observer REST APIs, plus a v1 success-path test for the apps stats endpoint.

These close the codecov-flagged gaps for newly introduced code in this PR.

* Unify one-shot and windowed container_stats() code paths

Both modes now share the same error handling and not-running detection:
- The windowed path no longer inspects the container first to check its
  running state. Docker returns the same id/name-only stub response (no
  online_cpus in cpu_stats) for a stopped or restarting container in
  windowed mode as it does in one-shot mode, so the existing online_cpus
  presence check now covers both, closing a race window where a container
  stopping between the inspect and the stats call would previously reach
  the DockerStats parser with an incomplete payload.
- The windowed path builds its container handle via containers.container(),
  which constructs a DockerContainer from the name with no I/O, instead of
  containers.get(), which performs an unnecessary inspect call just to
  fetch the same information the stats call already returns.

* Fix protected access pylint issue
2026-08-31 17:11:38 +02:00

142 lines
4.0 KiB
Python

"""Test audio plugin."""
import errno
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from supervisor.const import LogLevel
from supervisor.coresys import CoreSys
from supervisor.docker.audio import DockerAudio
from supervisor.exceptions import (
AudioNotRunningError,
AudioStatsTimeoutError,
AudioUnknownError,
DockerContainerNotFoundError,
DockerContainerNotRunningError,
DockerError,
DockerStatsTimeoutError,
)
@pytest.fixture(name="docker_interface")
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
"""Mock docker interface methods."""
with (
patch.object(DockerAudio, "run") as run,
patch.object(DockerAudio, "restart") as restart,
):
yield (run, restart)
@pytest.fixture(name="write_json")
async def fixture_write_json() -> Mock:
"""Mock json file writer."""
with patch("supervisor.plugins.audio.write_json_file") as write_json_file:
yield write_json_file
async def test_config_write(
coresys: CoreSys,
docker_interface: tuple[AsyncMock, AsyncMock],
write_json: Mock,
):
"""Test config write on audio start and restart."""
await coresys.plugins.audio.start()
docker_interface[0].assert_called_once()
docker_interface[1].assert_not_called()
write_json.assert_called_once_with(
Path("/data/audio/pulse_audio.json"),
{
"debug": False,
},
)
docker_interface[0].reset_mock()
write_json.reset_mock()
coresys.config.logging = LogLevel.DEBUG
await coresys.plugins.audio.restart()
docker_interface[0].assert_not_called()
docker_interface[1].assert_called_once()
write_json.assert_called_once_with(
Path("/data/audio/pulse_audio.json"),
{
"debug": True,
},
)
async def test_load_error(
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
):
"""Test error reading config file during load."""
with (
patch(
"supervisor.plugins.audio.Path.read_text", side_effect=(err := OSError())
),
patch("supervisor.plugins.audio.shutil.copy", side_effect=err),
):
err.errno = errno.EBUSY
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.plugins.audio.load()
assert "Can't read pulse-client.tmpl" in caplog.text
assert "Can't create default asound" in caplog.text
assert coresys.core.healthy is False
async def test_stats_not_running(coresys: CoreSys):
"""Test stats raises AudioNotRunningError when the container isn't running."""
with (
patch.object(
DockerAudio,
"stats",
AsyncMock(side_effect=DockerContainerNotRunningError(name="hassio_audio")),
),
pytest.raises(AudioNotRunningError),
):
await coresys.plugins.audio.stats()
with (
patch.object(
DockerAudio,
"stats",
AsyncMock(side_effect=DockerContainerNotFoundError(name="hassio_audio")),
),
pytest.raises(AudioNotRunningError),
):
await coresys.plugins.audio.stats()
async def test_stats_timeout(coresys: CoreSys):
"""Test stats raises AudioStatsTimeoutError on timeout."""
with (
patch.object(
DockerAudio,
"stats",
AsyncMock(side_effect=DockerStatsTimeoutError(name="hassio_audio")),
),
pytest.raises(AudioStatsTimeoutError),
):
await coresys.plugins.audio.stats()
async def test_stats_unknown_error(coresys: CoreSys):
"""Test stats raises AudioUnknownError on an unexpected Docker error."""
with (
patch.object(DockerAudio, "stats", AsyncMock(side_effect=DockerError("boom"))),
pytest.raises(AudioUnknownError),
):
await coresys.plugins.audio.stats()