Skip to content

Commit a7deac1

Browse files
authored
test: port session and video tests off sync io (#851)
1 parent 93119ff commit a7deac1

3 files changed

Lines changed: 61 additions & 49 deletions

File tree

tests/conftest.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing import Any
1515
from unittest.mock import AsyncMock, Mock
1616

17+
import aiofiles
1718
import aiohttp
1819
import av
1920
import pytest
@@ -38,27 +39,7 @@
3839
# Tests that perform sync IO inside the asyncio event loop and trip
3940
# blockbuster. Marked xfail so CI is green; pop entries as they get
4041
# fixed so the underlying blocking call is gone for good.
41-
_KNOWN_BLOCKING: frozenset[str] = frozenset(
42-
{
43-
"tests/test_api.py::test_authenticate_with_session_storage",
44-
"tests/test_api.py::test_clear_all_sessions_handles_file_disappearing",
45-
"tests/test_api.py::test_clear_all_sessions_removes_file",
46-
"tests/test_api.py::test_clear_methods_do_nothing_when_sessions_disabled[clear_all_sessions]",
47-
"tests/test_api.py::test_clear_methods_do_nothing_when_sessions_disabled[clear_session]",
48-
"tests/test_api.py::test_clear_methods_handle_missing_file[clear_all_sessions]",
49-
"tests/test_api.py::test_clear_methods_handle_missing_file[clear_session]",
50-
"tests/test_api.py::test_clear_session_removes_specific_session",
51-
"tests/test_api.py::test_clear_session_when_session_not_in_config",
52-
"tests/test_api.py::test_clear_session_with_invalid_config_file",
53-
"tests/test_api.py::test_get_camera_video",
54-
"tests/test_api.py::test_invalid_token_triggers_reauthentication",
55-
"tests/test_api.py::test_load_session_accepts_valid_csrf_token",
56-
"tests/test_api.py::test_load_session_rejects_missing_csrf_token",
57-
"tests/test_api.py::test_load_session_with_invalid_token",
58-
"tests/test_api.py::test_load_session_with_token_two_segments",
59-
"tests/test_utils.py::test_write_json",
60-
}
61-
)
42+
_KNOWN_BLOCKING: frozenset[str] = frozenset()
6243

6344

6445
def pytest_collection_modifyitems(
@@ -150,6 +131,30 @@ def read_json_file(name: str) -> Any:
150131
CONSTANTS.data() # force the sample_constants.json read out of any loop
151132

152133

134+
async def async_write_bytes(path: Path, data: bytes) -> None:
135+
"""Write ``data`` to ``path`` without blocking the event loop."""
136+
async with aiofiles.open(path, "wb") as f:
137+
await f.write(data)
138+
139+
140+
async def async_read_bytes(path: Path) -> bytes:
141+
"""Read ``path`` as bytes without blocking the event loop."""
142+
async with aiofiles.open(path, "rb") as f:
143+
return await f.read()
144+
145+
146+
async def async_write_text(path: Path, text: str) -> None:
147+
"""Write ``text`` to ``path`` without blocking the event loop."""
148+
async with aiofiles.open(path, "w") as f:
149+
await f.write(text)
150+
151+
152+
async def async_read_text(path: Path) -> str:
153+
"""Read ``path`` as text without blocking the event loop."""
154+
async with aiofiles.open(path) as f:
155+
return await f.read()
156+
157+
153158
def read_bootstrap_json_file():
154159
# tests expect global recording settings to be off
155160
bootstrap = read_json_file("sample_bootstrap")

tests/test_api.py

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import aiohttp
1616
import orjson
1717
import pytest
18+
from aiofiles import os as aos
1819
from PIL import Image
1920

2021
from tests.conftest import (
@@ -31,6 +32,9 @@
3132
TEST_VIDEO_EXISTS,
3233
TEST_VIEWPORT_EXISTS,
3334
MockDatetime,
35+
async_read_bytes,
36+
async_write_bytes,
37+
async_write_text,
3438
compare_objs,
3539
get_time,
3640
validate_video_file,
@@ -1093,7 +1097,7 @@ async def test_get_package_camera_snapshot_args(protect_client: ProtectApiClient
10931097
@patch("uiprotect.api.datetime", MockDatetime)
10941098
@patch("uiprotect.api.time.time", get_time)
10951099
@pytest.mark.asyncio()
1096-
async def test_get_camera_video(protect_client: ProtectApiClient, now, tmp_binary_file):
1100+
async def test_get_camera_video(protect_client: ProtectApiClient, now, tmp_path: Path):
10971101
camera = next(iter(protect_client.bootstrap.cameras.values()))
10981102
start = now - timedelta(seconds=CONSTANTS["camera_video_length"])
10991103

@@ -1111,10 +1115,11 @@ async def test_get_camera_video(protect_client: ProtectApiClient, now, tmp_binar
11111115
raise_exception=False,
11121116
)
11131117

1114-
tmp_binary_file.write(data)
1115-
tmp_binary_file.close()
1116-
1117-
validate_video_file(tmp_binary_file.name, CONSTANTS["camera_video_length"])
1118+
out_path = tmp_path / "video.mp4"
1119+
await async_write_bytes(out_path, data)
1120+
await asyncio.to_thread(
1121+
validate_video_file, out_path, CONSTANTS["camera_video_length"]
1122+
)
11181123

11191124

11201125
@pytest.mark.asyncio()
@@ -1738,7 +1743,7 @@ async def test_load_session_rejects_missing_csrf_token(tmp_path: Path) -> None:
17381743
}
17391744

17401745
config_file = tmp_path / "unifi_protect.json"
1741-
config_file.write_bytes(orjson.dumps(config))
1746+
await async_write_bytes(config_file, orjson.dumps(config))
17421747

17431748
# Try to load the session
17441749
cookie = await client._read_auth_config()
@@ -1775,7 +1780,7 @@ async def test_load_session_accepts_valid_csrf_token(tmp_path: Path) -> None:
17751780
}
17761781

17771782
config_file = tmp_path / "unifi_protect.json"
1778-
config_file.write_bytes(orjson.dumps(config))
1783+
await async_write_bytes(config_file, orjson.dumps(config))
17791784

17801785
# Try to load the session
17811786
cookie = await client._read_auth_config()
@@ -1815,7 +1820,7 @@ async def test_load_session_with_invalid_token(tmp_path: Path) -> None:
18151820
}
18161821

18171822
config_file = tmp_path / "unifi_protect.json"
1818-
config_file.write_bytes(orjson.dumps(config))
1823+
await async_write_bytes(config_file, orjson.dumps(config))
18191824

18201825
# Load the session
18211826
cookie = await client._read_auth_config()
@@ -1861,7 +1866,7 @@ async def test_load_session_with_token_two_segments(tmp_path: Path) -> None:
18611866
}
18621867

18631868
config_file = tmp_path / "unifi_protect.json"
1864-
config_file.write_bytes(orjson.dumps(config))
1869+
await async_write_bytes(config_file, orjson.dumps(config))
18651870

18661871
# Load the session
18671872
cookie = await client._read_auth_config()
@@ -1899,7 +1904,7 @@ async def test_invalid_token_triggers_reauthentication(
18991904
}
19001905

19011906
config_file = tmp_path / "unifi_protect.json"
1902-
config_file.write_bytes(orjson.dumps(invalid_config))
1907+
await async_write_bytes(config_file, orjson.dumps(invalid_config))
19031908

19041909
# Setup mock for authentication
19051910
mock_auth_response = AsyncMock()
@@ -1929,7 +1934,7 @@ async def test_invalid_token_triggers_reauthentication(
19291934
assert client._is_authenticated is True
19301935

19311936
# Verify the session file was updated with the new valid token
1932-
updated_config = orjson.loads(config_file.read_bytes())
1937+
updated_config = orjson.loads(await async_read_bytes(config_file))
19331938
session_data = updated_config["sessions"][session_hash]
19341939
assert session_data["csrf"] == "new-csrf-token-12345"
19351940
# The new token should be a valid JWT format (3 segments)
@@ -1968,12 +1973,12 @@ async def test_clear_session_removes_specific_session(tmp_path: Path) -> None:
19681973
}
19691974

19701975
config_file = tmp_path / "unifi_protect.json"
1971-
config_file.write_bytes(orjson.dumps(config))
1976+
await async_write_bytes(config_file, orjson.dumps(config))
19721977

19731978
await client.clear_session()
19741979

19751980
# File should still exist with the other session intact
1976-
updated_config = orjson.loads(config_file.read_bytes())
1981+
updated_config = orjson.loads(await async_read_bytes(config_file))
19771982
assert session_hash not in updated_config["sessions"]
19781983
assert "other_session_hash" in updated_config["sessions"]
19791984
assert client._is_authenticated is False
@@ -2013,11 +2018,11 @@ async def test_clear_all_sessions_removes_file(
20132018
}
20142019

20152020
config_file = tmp_path / "unifi_protect.json"
2016-
config_file.write_bytes(orjson.dumps(config))
2021+
await async_write_bytes(config_file, orjson.dumps(config))
20172022

20182023
await client.clear_all_sessions()
20192024

2020-
assert not config_file.exists()
2025+
assert not await aos.path.exists(config_file)
20212026
assert client._is_authenticated is False
20222027
assert client._last_token_cookie is None
20232028
assert client._last_token_cookie_decode is None
@@ -2041,13 +2046,13 @@ async def test_clear_methods_do_nothing_when_sessions_disabled(
20412046
)
20422047

20432048
config_file = tmp_path / "unifi_protect.json"
2044-
config_file.write_bytes(orjson.dumps({"sessions": {}}))
2049+
await async_write_bytes(config_file, orjson.dumps({"sessions": {}}))
20452050

20462051
await getattr(client, clear_method)()
20472052

20482053
# File should still exist since sessions are disabled
2049-
assert config_file.exists()
2050-
config = orjson.loads(config_file.read_bytes())
2054+
assert await aos.path.exists(config_file)
2055+
config = orjson.loads(await async_read_bytes(config_file))
20512056
assert config == {"sessions": {}}
20522057

20532058

@@ -2066,13 +2071,13 @@ async def test_clear_session_with_invalid_config_file(tmp_path: Path) -> None:
20662071

20672072
# Create a config file with invalid JSON
20682073
config_file = tmp_path / "unifi_protect.json"
2069-
config_file.write_text("invalid json content {{{")
2074+
await async_write_text(config_file, "invalid json content {{{")
20702075

20712076
# Call clear_session - should handle the exception gracefully
20722077
await client.clear_session()
20732078

20742079
# File should still exist
2075-
assert config_file.exists()
2080+
assert await aos.path.exists(config_file)
20762081

20772082

20782083
@pytest.mark.asyncio()
@@ -2099,7 +2104,7 @@ async def test_clear_methods_handle_missing_file(
20992104

21002105
# No file should exist and no error should be raised
21012106
config_file = tmp_path / "unifi_protect.json"
2102-
assert not config_file.exists()
2107+
assert not await aos.path.exists(config_file)
21032108
# Client state should NOT be reset since no file was found
21042109
assert client._is_authenticated is True
21052110
assert client._last_token_cookie == "some_token" # noqa: S105
@@ -2133,12 +2138,12 @@ async def test_clear_session_when_session_not_in_config(tmp_path: Path) -> None:
21332138
}
21342139

21352140
config_file = tmp_path / "unifi_protect.json"
2136-
config_file.write_bytes(orjson.dumps(config))
2141+
await async_write_bytes(config_file, orjson.dumps(config))
21372142

21382143
await client.clear_session()
21392144

21402145
# File should still have the original session
2141-
updated_config = orjson.loads(config_file.read_bytes())
2146+
updated_config = orjson.loads(await async_read_bytes(config_file))
21422147
assert "different_hash" in updated_config["sessions"]
21432148
# Client state should NOT be reset since no session was actually removed
21442149
assert client._is_authenticated is True
@@ -2166,7 +2171,7 @@ async def test_clear_all_sessions_handles_file_disappearing(
21662171

21672172
# Create config file so path.exists() check passes
21682173
config_file = tmp_path / "unifi_protect.json"
2169-
config_file.write_bytes(orjson.dumps({"sessions": {}}))
2174+
await async_write_bytes(config_file, orjson.dumps({"sessions": {}}))
21702175

21712176
# Mock aos.remove to raise FileNotFoundError (race condition simulation)
21722177
mock_remove.side_effect = FileNotFoundError(
@@ -2217,9 +2222,9 @@ async def test_authenticate_with_session_storage(
22172222

22182223
# Verify session was saved to file
22192224
config_file = tmp_path / "unifi_protect.json"
2220-
assert config_file.exists()
2225+
assert await aos.path.exists(config_file)
22212226

2222-
config = orjson.loads(config_file.read_bytes())
2227+
config = orjson.loads(await async_read_bytes(config_file))
22232228
assert "sessions" in config
22242229
sessions = config["sessions"]
22252230
assert len(sessions) == 1

tests/test_utils.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414

1515
import jwt
1616
import pytest
17+
from aiofiles import os as aos
1718
from pydantic.fields import FieldInfo
1819

1920
import uiprotect.utils as utils_module
21+
from tests.conftest import async_read_text
2022
from uiprotect.data import EventType
2123
from uiprotect.data.bootstrap import WSStat
2224
from uiprotect.data.types import (
@@ -700,8 +702,8 @@ def test_print_ws_stat_summary():
700702
async def test_write_json(tmp_path: Path):
701703
path = tmp_path / "test.json"
702704
await write_json(path, {"key": "value"})
703-
assert path.exists()
704-
assert '"key": "value"' in path.read_text()
705+
assert await aos.path.exists(path)
706+
assert '"key": "value"' in await async_read_text(path)
705707

706708

707709
# --- log_event tests ---

0 commit comments

Comments
 (0)