- pooled_devices primary key changed from device_id alone to (host_id, device_id), so two hosts reporting the same local device_id no longer crash sync_host_devices() with an uncaught sqlite3.IntegrityError. - Device gained a capability_tags field so DevicePool.sync_host_devices() can actually populate PooledDevice.capability_tags from a real host sync instead of always falling back to an empty list. openspec: device-pool capability, archived change cloud-runtime
188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
"""Unit tests for cloud.pool.DevicePool (task 3.4)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from cloud.config import CloudConfig
|
|
from cloud.pool import DevicePool
|
|
from cloud.store import CloudStore
|
|
from core.models import Device
|
|
|
|
|
|
def _config(**overrides) -> CloudConfig:
|
|
base = {
|
|
"sync_interval_seconds": 30,
|
|
"stale_after_seconds": 60,
|
|
"max_queue_depth": 100,
|
|
"default_assignment_strategy": "fifo_match",
|
|
"api_version_prefix": "/v1",
|
|
"db_path": "cloud/cloud.sqlite3",
|
|
}
|
|
base.update(overrides)
|
|
return CloudConfig(**base)
|
|
|
|
|
|
def _device(
|
|
device_id: str,
|
|
*,
|
|
status: str = "idle",
|
|
driver_type: str = "wda",
|
|
capability_tags: list[str] | None = None,
|
|
) -> Device:
|
|
return Device(
|
|
id=device_id,
|
|
status=status, # type: ignore[arg-type]
|
|
driver_type=driver_type,
|
|
capability_tags=list(capability_tags or []),
|
|
)
|
|
|
|
|
|
def test_new_host_sync_creates_registration_and_devices(tmp_path) -> None:
|
|
pool = DevicePool(
|
|
CloudStore(tmp_path / "cloud.sqlite3"),
|
|
_config(),
|
|
)
|
|
|
|
pool.sync_host_devices(
|
|
"host-a",
|
|
[_device("dev-1"), _device("dev-2", status="busy")],
|
|
address="10.0.0.1:8000",
|
|
)
|
|
|
|
hosts = pool.list_hosts()
|
|
assert [h.host_id for h in hosts] == ["host-a"]
|
|
assert hosts[0].address == "10.0.0.1:8000"
|
|
|
|
devices = pool.list_devices()
|
|
assert {d.device_id for d in devices} == {"dev-1", "dev-2"}
|
|
by_id = {d.device_id: d for d in devices}
|
|
assert by_id["dev-1"].status == "idle"
|
|
assert by_id["dev-2"].status == "busy"
|
|
assert all(d.host_id == "host-a" for d in devices)
|
|
|
|
|
|
def test_resync_updates_last_seen_and_replaces_devices(tmp_path) -> None:
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
|
|
pool.sync_host_devices("host-a", [_device("dev-1"), _device("dev-2")])
|
|
first_hosts = pool.list_hosts()
|
|
first_seen = first_hosts[0].last_seen_at
|
|
|
|
# Force time forward by directly mutating the stored timestamp.
|
|
pool.store.upsert_host(
|
|
"host-a",
|
|
address=None,
|
|
last_seen_at=datetime.now(UTC) - timedelta(seconds=10),
|
|
)
|
|
|
|
pool.sync_host_devices("host-a", [_device("dev-3")])
|
|
hosts = pool.list_hosts()
|
|
devices = pool.list_devices()
|
|
|
|
assert [h.host_id for h in hosts] == ["host-a"]
|
|
assert {d.device_id for d in devices} == {"dev-3"}
|
|
assert hosts[0].last_seen_at > first_seen
|
|
|
|
|
|
def test_stale_host_devices_reported_unreachable(tmp_path) -> None:
|
|
pool = DevicePool(
|
|
CloudStore(tmp_path / "cloud.sqlite3"),
|
|
_config(stale_after_seconds=60),
|
|
)
|
|
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
|
|
|
|
# Push the host's last_seen_at beyond the staleness threshold.
|
|
pool.store.upsert_host(
|
|
"host-a",
|
|
address=None,
|
|
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
|
|
)
|
|
|
|
devices = pool.list_devices()
|
|
assert len(devices) == 1
|
|
assert devices[0].status == "unreachable"
|
|
|
|
fetched = pool.get_device("dev-1")
|
|
assert fetched is not None
|
|
assert fetched.status == "unreachable"
|
|
|
|
|
|
def test_resync_after_stale_clears_unreachable(tmp_path) -> None:
|
|
pool = DevicePool(
|
|
CloudStore(tmp_path / "cloud.sqlite3"),
|
|
_config(stale_after_seconds=60),
|
|
)
|
|
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
|
|
pool.store.upsert_host(
|
|
"host-a",
|
|
address=None,
|
|
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
|
|
)
|
|
|
|
# Stale right now.
|
|
assert pool.list_devices()[0].status == "unreachable"
|
|
|
|
# Host resyncs with a fresh snapshot.
|
|
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
|
|
devices = pool.list_devices()
|
|
assert devices[0].status == "idle"
|
|
|
|
|
|
def test_unknown_device_returns_none(tmp_path) -> None:
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
assert pool.get_device("does-not-exist") is None
|
|
|
|
|
|
def test_empty_pool_returns_empty_list(tmp_path) -> None:
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
assert pool.list_devices() == []
|
|
assert pool.list_hosts() == []
|
|
|
|
|
|
def test_two_hosts_aggregate_into_one_listing(tmp_path) -> None:
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
pool.sync_host_devices("host-a", [_device("a-dev-1")], address="a")
|
|
pool.sync_host_devices("host-b", [_device("b-dev-1"), _device("b-dev-2")], address="b")
|
|
|
|
devices = pool.list_devices()
|
|
assert {d.device_id: d.host_id for d in devices} == {
|
|
"a-dev-1": "host-a",
|
|
"b-dev-1": "host-b",
|
|
"b-dev-2": "host-b",
|
|
}
|
|
|
|
|
|
def test_two_hosts_syncing_same_local_device_id_do_not_crash(tmp_path) -> None:
|
|
"""Two hosts legitimately reporting the same local device_id are distinct
|
|
devices in the pool; syncing both must succeed and yield two entries."""
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")], address="a")
|
|
pool.sync_host_devices("host-b", [_device("dev-1", status="busy")], address="b")
|
|
|
|
devices = pool.list_devices()
|
|
assert len(devices) == 2
|
|
by_host = {d.host_id: d for d in devices}
|
|
assert set(by_host) == {"host-a", "host-b"}
|
|
assert by_host["host-a"].device_id == "dev-1"
|
|
assert by_host["host-b"].device_id == "dev-1"
|
|
assert by_host["host-a"].status == "idle"
|
|
assert by_host["host-b"].status == "busy"
|
|
|
|
|
|
def test_capability_tags_flow_from_device_to_pooled_device(tmp_path) -> None:
|
|
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
|
pool.sync_host_devices(
|
|
"host-a",
|
|
[_device("dev-1", capability_tags=["ios", "physical"])],
|
|
address="a",
|
|
)
|
|
|
|
devices = pool.list_devices()
|
|
assert len(devices) == 1
|
|
assert devices[0].capability_tags == ["ios", "physical"]
|
|
|
|
fetched = pool.get_device("dev-1")
|
|
assert fetched is not None
|
|
assert fetched.capability_tags == ["ios", "physical"]
|