-
-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathconftest.py
More file actions
346 lines (272 loc) · 12.1 KB
/
conftest.py
File metadata and controls
346 lines (272 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"""Setup common test helpers."""
import asyncio
from collections import deque
from contextlib import suppress
import logging
from typing import TYPE_CHECKING
from aiohttp import ClientSession, web
import pytest
from axis.device import AxisDevice
from axis.models.configuration import Configuration
from tests.http_route_mock import HttpRouteMock, start_http_route_mock_server
from tests.mock_device_binding import bind_device_port
from tests.mock_response_builder import build_response
if TYPE_CHECKING:
from collections.abc import Callable
LOGGER = logging.getLogger(__name__)
HOST = "127.0.0.1"
USER = "root"
PASS = "pass"
RTSP_PORT = 8888
# ---------------------------------------------------------------------------
# Session fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def session() -> ClientSession:
"""Return a reusable aiohttp session for tests."""
session = ClientSession()
yield session
await session.close()
# ---------------------------------------------------------------------------
# Device fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def axis_device(session: ClientSession) -> AxisDevice:
"""Return an AxisDevice backed by aiohttp ClientSession."""
return AxisDevice(Configuration(session, HOST, username=USER, password=PASS))
@pytest.fixture
async def axis_companion_device(session: ClientSession) -> AxisDevice:
"""Return a companion AxisDevice backed by aiohttp ClientSession."""
return AxisDevice(
Configuration(
session,
HOST,
username=USER,
password=PASS,
is_companion=True,
)
)
# ---------------------------------------------------------------------------
# HTTP mocking infrastructure
#
# Three layers, each suited to a different case:
# aiohttp_mock_server - direct handler or static-payload tests
# http_route_mock - route-registration tests (single device)
# http_route_mock_factory - route-registration tests (multi-device or explicit)
#
# Selection guidance:
# - Prefer http_route_mock when tests interact through vapix route methods.
# - Use http_route_mock_factory when a test needs explicit mock lifetime control
# or binds routes to more than one AxisDevice instance.
# - Use aiohttp_mock_server for low-level handler assertions, payload capture,
# or custom request processing not modeled by route registration.
# ---------------------------------------------------------------------------
class TcpServerProtocol(asyncio.Protocol):
"""Simple socket server that responds with preset responses."""
def __init__(self) -> None:
"""Initialize TCP protocol server."""
self._response_queue: deque[str] = deque()
self.requests: list[str] = []
self.next_request_received = asyncio.Event()
def register_response(self, response: str) -> None:
"""Take a single response as an argument and queue it."""
self._response_queue.append(response)
def register_responses(self, responses: list[str]) -> None:
"""Take a list of responses as an argument and queue them."""
self._response_queue.extend(responses)
def connection_made(self, transport) -> None:
"""Successful connection."""
peername = transport.get_extra_info("peername")
LOGGER.info("Server connection from %s", peername)
self.transport = transport
def data_received(self, data: bytes) -> None:
"""Received a request from a client.
If test is waiting on next request to be received it can now continue.
"""
message = data.decode()
self.requests.append(message)
LOGGER.info("Server received: %s", repr(message))
self.next_request_received.set()
def send_response(self, response: str) -> None:
"""Send response to client.
Clear event so test can wait on next request to be received.
"""
LOGGER.info("Server response: %s", repr(response))
self.transport.write(response.encode())
self.next_request_received.clear()
def step_response(self) -> None:
"""Send next response in queue."""
response = self._response_queue.popleft()
self.send_response(response)
@property
def last_request(self) -> str:
"""Return last request."""
return self.requests[-1]
def stop(self) -> None:
"""Stop server."""
self.transport.close()
@pytest.fixture
def aiohttp_mock_server(aiohttp_server):
"""Consolidated mock server factory eliminating boilerplate app/router setup.
Supports single/multiple routes, request capture, automatic device port binding.
Consolidates 53+ instances of web.Application() boilerplate across test suite.
Usage examples:
# Simple single route with request capture:
server, requests = await aiohttp_mock_server(
"/api/endpoint",
handler=async_handler_func
)
# Multiple routes:
server, requests = await aiohttp_mock_server(
{"/path1": async_handler1, "/path2": async_handler2}
)
# With automatic device config binding (accepts AxisDevice or Vapix):
server, requests = await aiohttp_mock_server(
"/api/method",
handler=async_handler_func,
device=axis_device, # auto-sets device.config.port
)
# Response specs (replaces manual handler definition):
server, requests = await aiohttp_mock_server(
{
"/api/list": {"method": "POST", "response": {"data": []}},
"/api/status": {"method": "GET", "response": "ok"},
}
)
"""
async def _create_mock_server(
routes: dict[str, dict[str, object]] | str,
*,
handler: (Callable[[web.Request], web.Response] | None) = None,
method: str = "POST",
response: dict[str, object] | str | bytes | None = None,
status: int = 200,
headers: dict[str, str] | None = None,
device: object | None = None,
capture_requests: bool = True,
capture_payload: bool = False,
capture_body: bool = False,
):
"""Create consolidated mock server with route specs and optional request capture.
Args:
routes: single path str or dict of {path: spec_dict or callable}
handler: callable handler (if routes is string path)
method: HTTP method for single route (default POST)
response: response data for auto-handler (JSON dict, text str, or bytes)
status: HTTP status code
headers: response headers
device: optional AxisDevice or Vapix to auto-bind server.port
capture_requests: if True, return captured requests list
capture_payload: if True, capture request body/JSON (eliminates manual payload reading)
capture_body: if True, capture raw request bytes as "body"
Returns:
(server, requests_list) or (server, None) if capture_requests=False
"""
requests: list[dict[str, object]] | None = [] if capture_requests else None
def make_auto_handler(resp_data, resp_status, resp_headers):
"""Create handler from response spec (eliminates manual handler code)."""
async def _auto_handler(request: web.Request) -> web.Response:
if requests is not None:
req_entry: dict[str, object] = {
"method": request.method,
"path": request.path,
"query": request.query_string or "",
}
if capture_body and request.method in ("POST", "PUT", "PATCH"):
with suppress(ValueError, RuntimeError):
req_entry["body"] = await request.read()
# Capture request payload if enabled
if capture_payload and request.method in ("POST", "PUT", "PATCH"):
try:
if request.content_type == "application/json":
req_entry["payload"] = await request.json()
else:
# For other content types, capture as text
req_entry["payload"] = await request.text()
except ValueError, RuntimeError:
# Skip payload if reading fails (e.g., already consumed)
pass
requests.append(req_entry)
return build_response(
resp_data,
status=resp_status,
headers=resp_headers,
)
return _auto_handler
app = web.Application()
# Handle single path string with handler arg
if isinstance(routes, str):
path = routes
if handler is not None:
app.router.add_route(method.upper(), path, handler)
elif response is not None:
app.router.add_route(
method.upper(),
path,
make_auto_handler(response, status, headers),
)
# Handle dict of routes
else:
for path, route_spec in routes.items():
if callable(route_spec):
# route_spec is a handler function
app.router.add_post(path, route_spec)
elif isinstance(route_spec, dict):
# route_spec is {method, response, status, headers}
route_method = route_spec.get("method", "POST").upper()
route_response = route_spec.get("response")
route_status = route_spec.get("status", 200)
route_headers = route_spec.get("headers")
if route_response is not None:
app.router.add_route(
route_method,
path,
make_auto_handler(
route_response, route_status, route_headers
),
)
server = await aiohttp_server(app)
if device is not None:
bind_device_port(device, server.port)
return server, requests
return _create_mock_server
@pytest.fixture
def http_route_mock_factory(aiohttp_mock_server) -> HttpRouteMock:
"""Return an HttpRouteMock factory bound to one or more devices.
Use for multi-device tests or when http_route_mock (single-device) is insufficient.
Example::
async def test_multi(http_route_mock_factory, device_a, device_b):
mock = await http_route_mock_factory(device_a, device_b)
mock.post("/axis-cgi/example.cgi").respond(json={"data": []})
"""
async def _factory(*devices) -> HttpRouteMock:
return await start_http_route_mock_server(aiohttp_mock_server, *devices)
return _factory
@pytest.fixture
async def http_route_mock(
http_route_mock_factory, axis_device: AxisDevice
) -> HttpRouteMock:
"""Single-device HttpRouteMock auto-bound to axis_device.
Use for common route-registration tests against a single device.
For multi-device tests use http_route_mock_factory instead.
Example::
async def test_example(http_route_mock):
http_route_mock.post("/axis-cgi/example.cgi").respond(json={"data": []})
"""
return await http_route_mock_factory(axis_device)
# ---------------------------------------------------------------------------
# Network protocol fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
async def rtsp_server() -> TcpServerProtocol:
"""Return the RTSP server."""
loop = asyncio.get_running_loop()
mock_server = TcpServerProtocol()
server = await loop.create_server(lambda: mock_server, HOST, RTSP_PORT)
async def run_server():
"""Run server until transport is closed."""
async with server:
await server.serve_forever()
server_task = loop.create_task(run_server())
yield mock_server
server_task.cancel()