piphi-runtime-testkit-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- piphi_runtime_testkit_python/__init__.py +16 -0
- piphi_runtime_testkit_python/assertions.py +163 -0
- piphi_runtime_testkit_python/builders.py +65 -0
- piphi_runtime_testkit_python/mock_core.py +164 -0
- piphi_runtime_testkit_python/pytest_plugin.py +41 -0
- piphi_runtime_testkit_python-0.1.1.dist-info/METADATA +969 -0
- piphi_runtime_testkit_python-0.1.1.dist-info/RECORD +10 -0
- piphi_runtime_testkit_python-0.1.1.dist-info/WHEEL +4 -0
- piphi_runtime_testkit_python-0.1.1.dist-info/entry_points.txt +7 -0
- piphi_runtime_testkit_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Pytest-first helpers for testing PiPhi runtime integrations."""
|
|
2
|
+
|
|
3
|
+
from .assertions import assert_entities_response, assert_event_sent, assert_telemetry_sent
|
|
4
|
+
from .builders import build_config_payload, build_config_snapshot, build_runtime_headers
|
|
5
|
+
from .mock_core import CapturedRequest, MockCoreServer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CapturedRequest",
|
|
9
|
+
"MockCoreServer",
|
|
10
|
+
"assert_entities_response",
|
|
11
|
+
"assert_event_sent",
|
|
12
|
+
"assert_telemetry_sent",
|
|
13
|
+
"build_config_payload",
|
|
14
|
+
"build_config_snapshot",
|
|
15
|
+
"build_runtime_headers",
|
|
16
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .mock_core import CapturedRequest, MockCoreServer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pick_alias(mapping: dict[str, Any], *names: str) -> Any:
|
|
9
|
+
for name in names:
|
|
10
|
+
if name in mapping:
|
|
11
|
+
return mapping[name]
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _assert_string_list(value: Any, *, label: str) -> None:
|
|
16
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
17
|
+
raise AssertionError(f"Expected {label} to be a list[str], but received {value!r}.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _assert_optional_string(mapping: dict[str, Any], *, names: tuple[str, ...], label: str) -> None:
|
|
21
|
+
value = _pick_alias(mapping, *names)
|
|
22
|
+
if value is not None and not isinstance(value, str):
|
|
23
|
+
raise AssertionError(f"Expected {label} to be a string when provided, but received {value!r}.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _assert_optional_object(mapping: dict[str, Any], *, names: tuple[str, ...], label: str) -> dict[str, Any] | None:
|
|
27
|
+
value = _pick_alias(mapping, *names)
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
if not isinstance(value, dict):
|
|
31
|
+
raise AssertionError(f"Expected {label} to be an object when provided, but received {value!r}.")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _assert_entity_shape(entity: Any, *, index: int) -> dict[str, Any]:
|
|
36
|
+
if not isinstance(entity, dict):
|
|
37
|
+
raise AssertionError(f"Expected entities[{index}] to be an object, but received {entity!r}.")
|
|
38
|
+
if not isinstance(entity.get("id"), str) or not entity["id"]:
|
|
39
|
+
raise AssertionError(f"Expected entities[{index}].id to be a non-empty string, but received {entity.get('id')!r}.")
|
|
40
|
+
if not isinstance(entity.get("name"), str) or not entity["name"]:
|
|
41
|
+
raise AssertionError(
|
|
42
|
+
f"Expected entities[{index}].name to be a non-empty string, but received {entity.get('name')!r}."
|
|
43
|
+
)
|
|
44
|
+
_assert_string_list(entity.get("capabilities"), label=f"entities[{index}].capabilities")
|
|
45
|
+
_assert_optional_string(entity, names=("config_id", "configId"), label=f"entities[{index}].config_id")
|
|
46
|
+
_assert_optional_string(entity, names=("device_id", "deviceId"), label=f"entities[{index}].device_id")
|
|
47
|
+
_assert_optional_string(entity, names=("device_type", "deviceType"), label=f"entities[{index}].device_type")
|
|
48
|
+
_assert_optional_string(entity, names=("device_class", "deviceClass"), label=f"entities[{index}].device_class")
|
|
49
|
+
_assert_optional_string(entity, names=("entity_type", "entityType"), label=f"entities[{index}].entity_type")
|
|
50
|
+
|
|
51
|
+
dashboard = _assert_optional_object(entity, names=("dashboard",), label=f"entities[{index}].dashboard")
|
|
52
|
+
if dashboard is not None:
|
|
53
|
+
allowed_widgets = _pick_alias(dashboard, "allowed_widgets", "allowedWidgets")
|
|
54
|
+
if allowed_widgets is not None:
|
|
55
|
+
_assert_string_list(allowed_widgets, label=f"entities[{index}].dashboard.allowed_widgets")
|
|
56
|
+
recommended_widgets = _pick_alias(dashboard, "recommended_widgets", "recommendedWidgets")
|
|
57
|
+
if recommended_widgets is not None:
|
|
58
|
+
_assert_string_list(recommended_widgets, label=f"entities[{index}].dashboard.recommended_widgets")
|
|
59
|
+
_assert_optional_string(
|
|
60
|
+
dashboard,
|
|
61
|
+
names=("default_widget", "defaultWidget"),
|
|
62
|
+
label=f"entities[{index}].dashboard.default_widget",
|
|
63
|
+
)
|
|
64
|
+
return entity
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _find_request(
|
|
68
|
+
requests: list[CapturedRequest],
|
|
69
|
+
*,
|
|
70
|
+
device_id: str | None = None,
|
|
71
|
+
config_id: str | None = None,
|
|
72
|
+
event_type: str | None = None,
|
|
73
|
+
) -> CapturedRequest | None:
|
|
74
|
+
for request in requests:
|
|
75
|
+
body = request.json_body
|
|
76
|
+
if body is None:
|
|
77
|
+
continue
|
|
78
|
+
body_event_type = body.get("event_type") if isinstance(body, dict) else None
|
|
79
|
+
if body_event_type is None and isinstance(body, dict):
|
|
80
|
+
body_event_type = body.get("type")
|
|
81
|
+
if device_id is not None:
|
|
82
|
+
if isinstance(body, dict) and body.get("device_id") != device_id:
|
|
83
|
+
continue
|
|
84
|
+
if config_id is not None:
|
|
85
|
+
if isinstance(body, dict) and body.get("config_id") != config_id:
|
|
86
|
+
continue
|
|
87
|
+
if event_type is not None:
|
|
88
|
+
if body_event_type != event_type:
|
|
89
|
+
continue
|
|
90
|
+
return request
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def assert_telemetry_sent(
|
|
95
|
+
mock_core: MockCoreServer,
|
|
96
|
+
*,
|
|
97
|
+
device_id: str | None = None,
|
|
98
|
+
) -> CapturedRequest:
|
|
99
|
+
"""Assert that at least one telemetry request was captured."""
|
|
100
|
+
|
|
101
|
+
if not mock_core.telemetry_requests:
|
|
102
|
+
raise AssertionError("Expected telemetry to be sent to mock Core, but no telemetry requests were captured.")
|
|
103
|
+
|
|
104
|
+
if device_id is None:
|
|
105
|
+
return mock_core.telemetry_requests[-1]
|
|
106
|
+
|
|
107
|
+
request = _find_request(mock_core.telemetry_requests, device_id=device_id)
|
|
108
|
+
if request is None:
|
|
109
|
+
raise AssertionError(f"Expected telemetry for device_id={device_id!r}, but captured device_ids were {mock_core.captured_telemetry_device_ids()!r}.")
|
|
110
|
+
return request
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def assert_event_sent(
|
|
114
|
+
mock_core: MockCoreServer,
|
|
115
|
+
*,
|
|
116
|
+
device_id: str | None = None,
|
|
117
|
+
config_id: str | None = None,
|
|
118
|
+
event_type: str | None = None,
|
|
119
|
+
) -> CapturedRequest:
|
|
120
|
+
"""Assert that at least one event request was captured."""
|
|
121
|
+
|
|
122
|
+
if not mock_core.event_requests:
|
|
123
|
+
raise AssertionError("Expected an event to be sent to mock Core, but no event requests were captured.")
|
|
124
|
+
|
|
125
|
+
if device_id is None and config_id is None and event_type is None:
|
|
126
|
+
return mock_core.event_requests[-1]
|
|
127
|
+
|
|
128
|
+
request = _find_request(
|
|
129
|
+
mock_core.event_requests,
|
|
130
|
+
device_id=device_id,
|
|
131
|
+
config_id=config_id,
|
|
132
|
+
event_type=event_type,
|
|
133
|
+
)
|
|
134
|
+
if request is None:
|
|
135
|
+
raise AssertionError(
|
|
136
|
+
"Expected event matching filters, but none were captured. "
|
|
137
|
+
f"device_id={device_id!r} config_id={config_id!r} event_type={event_type!r}"
|
|
138
|
+
)
|
|
139
|
+
return request
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def assert_entities_response(payload: Any) -> dict[str, Any]:
|
|
143
|
+
"""Assert that a runtime /entities payload matches the PiPhi contract."""
|
|
144
|
+
|
|
145
|
+
if not isinstance(payload, dict):
|
|
146
|
+
raise AssertionError(f"Expected /entities payload to be an object, but received {payload!r}.")
|
|
147
|
+
|
|
148
|
+
entities = payload.get("entities")
|
|
149
|
+
if not isinstance(entities, list):
|
|
150
|
+
raise AssertionError(f"Expected /entities payload to include an entities list, but received {entities!r}.")
|
|
151
|
+
|
|
152
|
+
for index, entity in enumerate(entities):
|
|
153
|
+
_assert_entity_shape(entity, index=index)
|
|
154
|
+
|
|
155
|
+
capabilities = payload.get("capabilities")
|
|
156
|
+
if capabilities is not None and not isinstance(capabilities, dict):
|
|
157
|
+
raise AssertionError(f"Expected /entities payload capabilities to be an object, but received {capabilities!r}.")
|
|
158
|
+
|
|
159
|
+
commands = payload.get("commands")
|
|
160
|
+
if commands is not None and not isinstance(commands, dict):
|
|
161
|
+
raise AssertionError(f"Expected /entities payload commands to be an object, but received {commands!r}.")
|
|
162
|
+
|
|
163
|
+
return payload
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_runtime_headers(
|
|
7
|
+
*,
|
|
8
|
+
container_id: str = "test-container",
|
|
9
|
+
internal_token: str = "test-token",
|
|
10
|
+
extra_headers: dict[str, str] | None = None,
|
|
11
|
+
) -> dict[str, str]:
|
|
12
|
+
"""Build standard PiPhi runtime auth headers for tests."""
|
|
13
|
+
|
|
14
|
+
headers = {
|
|
15
|
+
"X-Container-Id": container_id,
|
|
16
|
+
"X-PiPhi-Integration-Token": internal_token,
|
|
17
|
+
}
|
|
18
|
+
if extra_headers:
|
|
19
|
+
headers.update(extra_headers)
|
|
20
|
+
return headers
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_config_payload(
|
|
24
|
+
*,
|
|
25
|
+
config_id: str = "config-1",
|
|
26
|
+
device_id: str | None = None,
|
|
27
|
+
container_id: str = "test-container",
|
|
28
|
+
integration_id: str = "test-integration",
|
|
29
|
+
include_core_config_id: bool = True,
|
|
30
|
+
extra: dict[str, Any] | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Build a single config payload for `/config` tests."""
|
|
33
|
+
|
|
34
|
+
payload: dict[str, Any] = {
|
|
35
|
+
"id": config_id,
|
|
36
|
+
"device_id": device_id or config_id,
|
|
37
|
+
"container_id": container_id,
|
|
38
|
+
"integration_id": integration_id,
|
|
39
|
+
}
|
|
40
|
+
if include_core_config_id:
|
|
41
|
+
payload["config_id"] = config_id
|
|
42
|
+
if extra:
|
|
43
|
+
payload.update(extra)
|
|
44
|
+
return payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_config_snapshot(
|
|
48
|
+
*,
|
|
49
|
+
configs: list[dict[str, Any]] | None = None,
|
|
50
|
+
container_id: str = "test-container",
|
|
51
|
+
integration_id: str = "test-integration",
|
|
52
|
+
generation: int = 1,
|
|
53
|
+
extra: dict[str, Any] | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Build a snapshot payload for `/config/sync` style tests."""
|
|
56
|
+
|
|
57
|
+
payload: dict[str, Any] = {
|
|
58
|
+
"container_id": container_id,
|
|
59
|
+
"integration_id": integration_id,
|
|
60
|
+
"generation": generation,
|
|
61
|
+
"configs": configs or [],
|
|
62
|
+
}
|
|
63
|
+
if extra:
|
|
64
|
+
payload.update(extra)
|
|
65
|
+
return payload
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from threading import Thread
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TELEMETRY_PATH = "/api/v2/integrations/telemetry"
|
|
11
|
+
EVENT_PATH = "/api/v2/events/ingest"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class CapturedRequest:
|
|
16
|
+
method: str
|
|
17
|
+
path: str
|
|
18
|
+
headers: dict[str, str]
|
|
19
|
+
body: bytes
|
|
20
|
+
json_body: Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _RequestStore:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.telemetry_requests: list[CapturedRequest] = []
|
|
26
|
+
self.event_requests: list[CapturedRequest] = []
|
|
27
|
+
self.telemetry_status = 200
|
|
28
|
+
self.event_status = 200
|
|
29
|
+
self.telemetry_json: Any = {"ok": True}
|
|
30
|
+
self.event_json: Any = {"ok": True}
|
|
31
|
+
|
|
32
|
+
def reset(self) -> None:
|
|
33
|
+
self.telemetry_requests.clear()
|
|
34
|
+
self.event_requests.clear()
|
|
35
|
+
self.telemetry_status = 200
|
|
36
|
+
self.event_status = 200
|
|
37
|
+
self.telemetry_json = {"ok": True}
|
|
38
|
+
self.event_json = {"ok": True}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _MockCoreRequestHandler(BaseHTTPRequestHandler):
|
|
42
|
+
store: _RequestStore
|
|
43
|
+
|
|
44
|
+
def do_POST(self) -> None: # noqa: N802
|
|
45
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
46
|
+
body = self.rfile.read(content_length)
|
|
47
|
+
try:
|
|
48
|
+
parsed_json = json.loads(body.decode("utf-8")) if body else None
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
parsed_json = None
|
|
51
|
+
|
|
52
|
+
request = CapturedRequest(
|
|
53
|
+
method="POST",
|
|
54
|
+
path=self.path,
|
|
55
|
+
headers={key: value for key, value in self.headers.items()},
|
|
56
|
+
body=body,
|
|
57
|
+
json_body=parsed_json,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if self.path == TELEMETRY_PATH:
|
|
61
|
+
self.store.telemetry_requests.append(request)
|
|
62
|
+
self._send_json(self.store.telemetry_status, self.store.telemetry_json)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if self.path == EVENT_PATH:
|
|
66
|
+
self.store.event_requests.append(request)
|
|
67
|
+
self._send_json(self.store.event_status, self.store.event_json)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
self._send_json(404, {"ok": False, "detail": "unknown path"})
|
|
71
|
+
|
|
72
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def _send_json(self, status_code: int, payload: Any) -> None:
|
|
76
|
+
data = json.dumps(payload).encode("utf-8")
|
|
77
|
+
self.send_response(status_code)
|
|
78
|
+
self.send_header("Content-Type", "application/json")
|
|
79
|
+
self.send_header("Content-Length", str(len(data)))
|
|
80
|
+
self.end_headers()
|
|
81
|
+
self.wfile.write(data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MockCoreServer:
|
|
85
|
+
"""Small local HTTP server that simulates PiPhi Core callbacks."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._store = _RequestStore()
|
|
89
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), _MockCoreRequestHandler)
|
|
90
|
+
self._server.RequestHandlerClass.store = self._store
|
|
91
|
+
self._thread = Thread(target=self._server.serve_forever, daemon=True)
|
|
92
|
+
self._thread.start()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def host(self) -> str:
|
|
96
|
+
return "127.0.0.1"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def port(self) -> int:
|
|
100
|
+
return int(self._server.server_address[1])
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def base_url(self) -> str:
|
|
104
|
+
return f"http://{self.host}:{self.port}"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def telemetry_url(self) -> str:
|
|
108
|
+
return f"{self.base_url}{TELEMETRY_PATH}"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def event_url(self) -> str:
|
|
112
|
+
return f"{self.base_url}{EVENT_PATH}"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def telemetry_requests(self) -> list[CapturedRequest]:
|
|
116
|
+
return self._store.telemetry_requests
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def event_requests(self) -> list[CapturedRequest]:
|
|
120
|
+
return self._store.event_requests
|
|
121
|
+
|
|
122
|
+
def set_telemetry_response(self, *, status_code: int = 200, json_body: Any = None) -> None:
|
|
123
|
+
self._store.telemetry_status = status_code
|
|
124
|
+
self._store.telemetry_json = {"ok": status_code < 400} if json_body is None else json_body
|
|
125
|
+
|
|
126
|
+
def set_event_response(self, *, status_code: int = 200, json_body: Any = None) -> None:
|
|
127
|
+
self._store.event_status = status_code
|
|
128
|
+
self._store.event_json = {"ok": status_code < 400} if json_body is None else json_body
|
|
129
|
+
|
|
130
|
+
def reset(self) -> None:
|
|
131
|
+
self._store.reset()
|
|
132
|
+
|
|
133
|
+
def captured_telemetry_device_ids(self) -> list[Any]:
|
|
134
|
+
return [
|
|
135
|
+
request.json_body.get("device_id")
|
|
136
|
+
for request in self.telemetry_requests
|
|
137
|
+
if isinstance(request.json_body, dict)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def shutdown(self) -> None:
|
|
141
|
+
self._server.shutdown()
|
|
142
|
+
self._server.server_close()
|
|
143
|
+
self._thread.join(timeout=2)
|
|
144
|
+
|
|
145
|
+
def assert_telemetry_sent(self, *, device_id: str | None = None) -> CapturedRequest:
|
|
146
|
+
from .assertions import assert_telemetry_sent
|
|
147
|
+
|
|
148
|
+
return assert_telemetry_sent(self, device_id=device_id)
|
|
149
|
+
|
|
150
|
+
def assert_event_sent(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
device_id: str | None = None,
|
|
154
|
+
config_id: str | None = None,
|
|
155
|
+
event_type: str | None = None,
|
|
156
|
+
) -> CapturedRequest:
|
|
157
|
+
from .assertions import assert_event_sent
|
|
158
|
+
|
|
159
|
+
return assert_event_sent(
|
|
160
|
+
self,
|
|
161
|
+
device_id=device_id,
|
|
162
|
+
config_id=config_id,
|
|
163
|
+
event_type=event_type,
|
|
164
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from .builders import build_config_payload, build_config_snapshot, build_runtime_headers
|
|
9
|
+
from .mock_core import MockCoreServer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mock_core() -> MockCoreServer:
|
|
14
|
+
"""Run a small local mock Core server and capture outbound requests."""
|
|
15
|
+
|
|
16
|
+
server = MockCoreServer()
|
|
17
|
+
try:
|
|
18
|
+
yield server
|
|
19
|
+
finally:
|
|
20
|
+
server.shutdown()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def runtime_headers() -> Callable[..., dict[str, str]]:
|
|
25
|
+
"""Build standard PiPhi runtime auth headers."""
|
|
26
|
+
|
|
27
|
+
return build_runtime_headers
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def config_payload() -> Callable[..., dict[str, Any]]:
|
|
32
|
+
"""Build a single config payload for `/config` tests."""
|
|
33
|
+
|
|
34
|
+
return build_config_payload
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def config_snapshot() -> Callable[..., dict[str, Any]]:
|
|
39
|
+
"""Build a snapshot payload for `/config/sync` tests."""
|
|
40
|
+
|
|
41
|
+
return build_config_snapshot
|