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.
@@ -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