piphi-runtime-testkit-python 0.1.1__tar.gz → 0.1.3__tar.gz

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.
Files changed (17) hide show
  1. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/PKG-INFO +1 -1
  2. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/pyproject.toml +2 -2
  3. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/src/piphi_runtime_testkit_python/__init__.py +9 -2
  4. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/src/piphi_runtime_testkit_python/builders.py +39 -0
  5. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/src/piphi_runtime_testkit_python/mock_core.py +37 -0
  6. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_mock_core.py +54 -1
  7. piphi_runtime_testkit_python-0.1.3/tests/test_runtime_config_contract.py +56 -0
  8. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/LICENSE +0 -0
  9. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/README.md +0 -0
  10. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/src/piphi_runtime_testkit_python/assertions.py +0 -0
  11. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/src/piphi_runtime_testkit_python/pytest_plugin.py +0 -0
  12. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/__init__.py +0 -0
  13. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_assertions.py +0 -0
  14. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_builders.py +0 -0
  15. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_fastapi_runtime_example.py +0 -0
  16. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_pytest_plugin.py +0 -0
  17. {piphi_runtime_testkit_python-0.1.1 → piphi_runtime_testkit_python-0.1.3}/tests/test_runtime_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-testkit-python
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Pytest-first helpers for testing PiPhi runtime integrations
5
5
  Keywords: piphi,pytest,testing,runtime,iot
6
6
  Author-Email: KelvinSan <support@piphi.network>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "piphi-runtime-testkit-python"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Pytest-first helpers for testing PiPhi runtime integrations"
5
5
  authors = [
6
6
  { name = "KelvinSan", email = "support@piphi.network" },
@@ -55,7 +55,7 @@ release-version = "python scripts/release.py"
55
55
  dev = [
56
56
  "fastapi>=0.115,<1.0",
57
57
  "httpx>=0.27,<1.0",
58
- "piphi-runtime-kit-python[fastapi]==0.4.2",
58
+ "piphi-runtime-kit-python[fastapi]==0.4.5",
59
59
  "pytest>=8.3,<9.0",
60
60
  "pytest-asyncio>=0.24,<1.0",
61
61
  "twine>=6.2.0",
@@ -1,16 +1,23 @@
1
1
  """Pytest-first helpers for testing PiPhi runtime integrations."""
2
2
 
3
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
4
+ from .builders import (
5
+ build_config_payload,
6
+ build_config_snapshot,
7
+ build_core_config_row,
8
+ build_runtime_headers,
9
+ )
10
+ from .mock_core import CapturedRequest, MockCoreServer, RUNTIME_CONFIG_FETCH_PATH
6
11
 
7
12
  __all__ = [
8
13
  "CapturedRequest",
9
14
  "MockCoreServer",
15
+ "RUNTIME_CONFIG_FETCH_PATH",
10
16
  "assert_entities_response",
11
17
  "assert_event_sent",
12
18
  "assert_telemetry_sent",
13
19
  "build_config_payload",
14
20
  "build_config_snapshot",
21
+ "build_core_config_row",
15
22
  "build_runtime_headers",
16
23
  ]
@@ -50,16 +50,55 @@ def build_config_snapshot(
50
50
  container_id: str = "test-container",
51
51
  integration_id: str = "test-integration",
52
52
  generation: int = 1,
53
+ deleted_config_ids: list[str] | None = None,
54
+ config_hash: str | None = None,
55
+ schema_version: int = 1,
56
+ updated_at: str | None = None,
53
57
  extra: dict[str, Any] | None = None,
54
58
  ) -> dict[str, Any]:
55
59
  """Build a snapshot payload for `/config/sync` style tests."""
56
60
 
57
61
  payload: dict[str, Any] = {
62
+ "schema_version": schema_version,
58
63
  "container_id": container_id,
59
64
  "integration_id": integration_id,
60
65
  "generation": generation,
61
66
  "configs": configs or [],
67
+ "deleted_config_ids": deleted_config_ids or [],
62
68
  }
69
+ if config_hash is not None:
70
+ payload["config_hash"] = config_hash
71
+ if updated_at is not None:
72
+ payload["updated_at"] = updated_at
63
73
  if extra:
64
74
  payload.update(extra)
65
75
  return payload
76
+
77
+
78
+ def build_core_config_row(
79
+ *,
80
+ config_data: dict[str, Any] | None = None,
81
+ config_id: str = "config-1",
82
+ container_id: str = "test-container",
83
+ integration_id: str = "test-integration",
84
+ generation: int = 1,
85
+ extra: dict[str, Any] | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Build a Core database-shaped config row for startup rehydrate tests."""
88
+
89
+ payload = build_config_payload(
90
+ config_id=config_id,
91
+ container_id=container_id,
92
+ integration_id=integration_id,
93
+ extra=config_data,
94
+ )
95
+ row: dict[str, Any] = {
96
+ "id": config_id,
97
+ "container_id": container_id,
98
+ "integration_id": integration_id,
99
+ "generation": generation,
100
+ "config_data": payload,
101
+ }
102
+ if extra:
103
+ row.update(extra)
104
+ return row
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  TELEMETRY_PATH = "/api/v2/integrations/telemetry"
11
11
  EVENT_PATH = "/api/v2/events/ingest"
12
+ RUNTIME_CONFIG_FETCH_PATH = "/api/v2/integrations/config/fetch/all/by/container/internal"
12
13
 
13
14
 
14
15
  @dataclass(slots=True)
@@ -24,23 +25,48 @@ class _RequestStore:
24
25
  def __init__(self) -> None:
25
26
  self.telemetry_requests: list[CapturedRequest] = []
26
27
  self.event_requests: list[CapturedRequest] = []
28
+ self.runtime_config_requests: list[CapturedRequest] = []
27
29
  self.telemetry_status = 200
28
30
  self.event_status = 200
31
+ self.runtime_config_status = 200
29
32
  self.telemetry_json: Any = {"ok": True}
30
33
  self.event_json: Any = {"ok": True}
34
+ self.runtime_config_json: Any = []
31
35
 
32
36
  def reset(self) -> None:
33
37
  self.telemetry_requests.clear()
34
38
  self.event_requests.clear()
39
+ self.runtime_config_requests.clear()
35
40
  self.telemetry_status = 200
36
41
  self.event_status = 200
42
+ self.runtime_config_status = 200
37
43
  self.telemetry_json = {"ok": True}
38
44
  self.event_json = {"ok": True}
45
+ self.runtime_config_json = []
39
46
 
40
47
 
41
48
  class _MockCoreRequestHandler(BaseHTTPRequestHandler):
42
49
  store: _RequestStore
43
50
 
51
+ def do_GET(self) -> None: # noqa: N802
52
+ request = CapturedRequest(
53
+ method="GET",
54
+ path=self.path,
55
+ headers={key: value for key, value in self.headers.items()},
56
+ body=b"",
57
+ json_body=None,
58
+ )
59
+
60
+ if self.path.split("?", 1)[0] == RUNTIME_CONFIG_FETCH_PATH:
61
+ self.store.runtime_config_requests.append(request)
62
+ self._send_json(
63
+ self.store.runtime_config_status,
64
+ self.store.runtime_config_json,
65
+ )
66
+ return
67
+
68
+ self._send_json(404, {"ok": False, "detail": "unknown path"})
69
+
44
70
  def do_POST(self) -> None: # noqa: N802
45
71
  content_length = int(self.headers.get("Content-Length", "0"))
46
72
  body = self.rfile.read(content_length)
@@ -119,6 +145,10 @@ class MockCoreServer:
119
145
  def event_requests(self) -> list[CapturedRequest]:
120
146
  return self._store.event_requests
121
147
 
148
+ @property
149
+ def runtime_config_requests(self) -> list[CapturedRequest]:
150
+ return self._store.runtime_config_requests
151
+
122
152
  def set_telemetry_response(self, *, status_code: int = 200, json_body: Any = None) -> None:
123
153
  self._store.telemetry_status = status_code
124
154
  self._store.telemetry_json = {"ok": status_code < 400} if json_body is None else json_body
@@ -127,6 +157,13 @@ class MockCoreServer:
127
157
  self._store.event_status = status_code
128
158
  self._store.event_json = {"ok": status_code < 400} if json_body is None else json_body
129
159
 
160
+ def set_runtime_config_response(self, *, status_code: int = 200, json_body: Any = None) -> None:
161
+ self._store.runtime_config_status = status_code
162
+ self._store.runtime_config_json = [] if json_body is None else json_body
163
+
164
+ def set_runtime_config_rows(self, rows: list[dict[str, Any]]) -> None:
165
+ self.set_runtime_config_response(status_code=200, json_body=rows)
166
+
130
167
  def reset(self) -> None:
131
168
  self._store.reset()
132
169
 
@@ -4,7 +4,13 @@ import json
4
4
  from urllib import error
5
5
  from urllib import request
6
6
 
7
- from piphi_runtime_testkit_python.mock_core import EVENT_PATH, TELEMETRY_PATH, MockCoreServer
7
+ from piphi_runtime_testkit_python import build_core_config_row, build_runtime_headers
8
+ from piphi_runtime_testkit_python.mock_core import (
9
+ EVENT_PATH,
10
+ RUNTIME_CONFIG_FETCH_PATH,
11
+ TELEMETRY_PATH,
12
+ MockCoreServer,
13
+ )
8
14
 
9
15
 
10
16
  def _post_json(url: str, payload: dict) -> tuple[int, dict]:
@@ -19,6 +25,16 @@ def _post_json(url: str, payload: dict) -> tuple[int, dict]:
19
25
  return response.status, json.loads(response.read().decode("utf-8"))
20
26
 
21
27
 
28
+ def _get_json(url: str, headers: dict[str, str] | None = None) -> tuple[int, list]:
29
+ req = request.Request(
30
+ url,
31
+ headers=headers or {},
32
+ method="GET",
33
+ )
34
+ with request.urlopen(req) as response: # noqa: S310
35
+ return response.status, json.loads(response.read().decode("utf-8"))
36
+
37
+
22
38
  def test_mock_core_captures_telemetry_and_events():
23
39
  mock_core = MockCoreServer()
24
40
  try:
@@ -170,3 +186,40 @@ def test_mock_core_invalid_json_is_captured_with_none_json_body():
170
186
  assert mock_core.telemetry_requests[0].body == b"{invalid-json"
171
187
  finally:
172
188
  mock_core.shutdown()
189
+
190
+
191
+ def test_mock_core_serves_runtime_config_rows_and_captures_auth_headers():
192
+ mock_core = MockCoreServer()
193
+ try:
194
+ mock_core.set_runtime_config_rows(
195
+ [
196
+ build_core_config_row(
197
+ config_id="config-1",
198
+ container_id="runtime-123",
199
+ integration_id="test-integration",
200
+ config_data={"host": "10.0.0.10"},
201
+ )
202
+ ]
203
+ )
204
+ headers = build_runtime_headers(
205
+ container_id="runtime-123",
206
+ internal_token="secret-token",
207
+ )
208
+
209
+ status, rows = _get_json(
210
+ f"{mock_core.base_url}{RUNTIME_CONFIG_FETCH_PATH}?container_id=runtime-123",
211
+ headers=headers,
212
+ )
213
+
214
+ assert status == 200
215
+ assert rows[0]["config_data"]["id"] == "config-1"
216
+ assert rows[0]["config_data"]["host"] == "10.0.0.10"
217
+ assert len(mock_core.runtime_config_requests) == 1
218
+ captured_headers = {
219
+ key.lower(): value
220
+ for key, value in mock_core.runtime_config_requests[0].headers.items()
221
+ }
222
+ assert captured_headers["x-container-id"] == "runtime-123"
223
+ assert captured_headers["x-piphi-integration-token"] == "secret-token"
224
+ finally:
225
+ mock_core.shutdown()
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from piphi_runtime_kit_python import (
7
+ RuntimeConfigSnapshot,
8
+ RuntimeContext,
9
+ rehydrate_runtime_configs,
10
+ )
11
+ from piphi_runtime_testkit_python import (
12
+ build_core_config_row,
13
+ )
14
+
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_mock_core_supports_sdk_startup_rehydrate_contract(mock_core) -> None:
18
+ mock_core.set_runtime_config_rows(
19
+ [
20
+ build_core_config_row(
21
+ config_id="sensor-1",
22
+ container_id="runtime-123",
23
+ integration_id="test-integration",
24
+ config_data={"device_id": "sensor-1", "host": "10.0.0.42"},
25
+ )
26
+ ]
27
+ )
28
+ runtime = RuntimeContext()
29
+ runtime.auth.update(container_id="runtime-123", internal_token="secret-token")
30
+ applied_snapshots: list[RuntimeConfigSnapshot] = []
31
+
32
+ async def apply_snapshot(snapshot: RuntimeConfigSnapshot) -> None:
33
+ applied_snapshots.append(snapshot)
34
+
35
+ async with httpx.AsyncClient() as client:
36
+ result = await rehydrate_runtime_configs(
37
+ runtime_context=runtime,
38
+ client=client,
39
+ apply_snapshot=apply_snapshot,
40
+ core_base_url=mock_core.base_url,
41
+ )
42
+
43
+ assert result.core_applied is True
44
+ assert result.core_config_count == 1
45
+ assert applied_snapshots[0].container_id == "runtime-123"
46
+ assert applied_snapshots[0].configs[0].id == "sensor-1"
47
+ assert applied_snapshots[0].configs[0].device_id == "sensor-1"
48
+
49
+ captured_request = mock_core.runtime_config_requests[0]
50
+ captured_headers = {
51
+ key.lower(): value
52
+ for key, value in captured_request.headers.items()
53
+ }
54
+ assert "container_id=runtime-123" in captured_request.path
55
+ assert captured_headers["x-container-id"] == "runtime-123"
56
+ assert captured_headers["x-piphi-integration-token"] == "secret-token"