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,969 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: piphi-runtime-testkit-python
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Pytest-first helpers for testing PiPhi runtime integrations
|
|
5
|
+
Keywords: piphi,pytest,testing,runtime,iot
|
|
6
|
+
Author-Email: KelvinSan <support@piphi.network>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Project-URL: Homepage, https://github.com/PiPhi-io/piphi-runtime-testkit-python#readme
|
|
17
|
+
Project-URL: Repository, https://github.com/PiPhi-io/piphi-runtime-testkit-python
|
|
18
|
+
Project-URL: Issues, https://github.com/PiPhi-io/piphi-runtime-testkit-python/issues
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: pytest<9.0,>=8.3
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# piphi-runtime-testkit-python
|
|
24
|
+
|
|
25
|
+
Pytest-first helpers for testing PiPhi runtime integrations.
|
|
26
|
+
|
|
27
|
+
This package exists to make integration tests easier to write, easier to read,
|
|
28
|
+
and easier to debug. It is designed for developers who are building PiPhi
|
|
29
|
+
runtimes and want a simple way to simulate PiPhi Core, build realistic request
|
|
30
|
+
payloads, and assert that telemetry and events were sent correctly.
|
|
31
|
+
|
|
32
|
+
It is intentionally small. The goal is not to hide pytest or hide your runtime.
|
|
33
|
+
The goal is to give you a few strong building blocks so your tests feel
|
|
34
|
+
predictable instead of repetitive.
|
|
35
|
+
|
|
36
|
+
> Safety note: the IDs and tokens in this README are fake test values. Use placeholders like `test-token` in tests, and never paste real production credentials into fixtures, screenshots, or public docs.
|
|
37
|
+
|
|
38
|
+
## Start Here
|
|
39
|
+
|
|
40
|
+
If you are brand new, read these sections in order:
|
|
41
|
+
|
|
42
|
+
1. [What This Package Helps With](#what-this-package-helps-with)
|
|
43
|
+
2. [Plain-Language Concepts](#plain-language-concepts)
|
|
44
|
+
3. [Install](#install)
|
|
45
|
+
4. [Quick Start](#quick-start)
|
|
46
|
+
5. [Pytest Fixtures](#pytest-fixtures)
|
|
47
|
+
6. [FastAPI End-to-End Example](#fastapi-end-to-end-example)
|
|
48
|
+
|
|
49
|
+
If you already know the basics and just need the API:
|
|
50
|
+
|
|
51
|
+
- [Pytest Fixtures](#pytest-fixtures)
|
|
52
|
+
- [Builder Functions](#builder-functions)
|
|
53
|
+
- [Assertion Helpers](#assertion-helpers)
|
|
54
|
+
- [MockCoreServer API](#mockcoreserver-api)
|
|
55
|
+
|
|
56
|
+
## What This Package Helps With
|
|
57
|
+
|
|
58
|
+
When you test a PiPhi runtime, you usually need to answer questions like:
|
|
59
|
+
|
|
60
|
+
- How do I generate realistic config payloads without rewriting dictionaries in every test?
|
|
61
|
+
- How do I simulate PiPhi Core receiving telemetry and events?
|
|
62
|
+
- How do I assert what my runtime sent to Core?
|
|
63
|
+
- How do I keep the test readable for another developer?
|
|
64
|
+
|
|
65
|
+
This package gives you helpers for exactly those jobs.
|
|
66
|
+
|
|
67
|
+
It includes:
|
|
68
|
+
|
|
69
|
+
- a local mock Core server that captures outbound HTTP requests
|
|
70
|
+
- builder functions for runtime headers, `/config` payloads, and `/config/sync` snapshots
|
|
71
|
+
- readable assertion helpers for telemetry and event delivery
|
|
72
|
+
- a pytest plugin so the fixtures are available automatically
|
|
73
|
+
|
|
74
|
+
It does not try to replace:
|
|
75
|
+
|
|
76
|
+
- pytest
|
|
77
|
+
- FastAPI test clients
|
|
78
|
+
- your runtime SDK
|
|
79
|
+
- your vendor-specific integration logic
|
|
80
|
+
|
|
81
|
+
## Plain-Language Concepts
|
|
82
|
+
|
|
83
|
+
### Mock Core
|
|
84
|
+
|
|
85
|
+
A "mock Core" is a tiny fake PiPhi Core server that your runtime can talk to in
|
|
86
|
+
tests.
|
|
87
|
+
|
|
88
|
+
Instead of sending telemetry to a real Core instance, your runtime sends it to
|
|
89
|
+
the mock server. The mock server captures the request so your test can inspect
|
|
90
|
+
it.
|
|
91
|
+
|
|
92
|
+
Think of it like a mailbox that keeps every letter your runtime mailed, so your
|
|
93
|
+
test can open the mailbox and check what was sent.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
def test_runtime_sent_telemetry(mock_core):
|
|
99
|
+
# Your runtime posts to mock_core.base_url instead of a real Core server.
|
|
100
|
+
assert mock_core.base_url.startswith("http://127.0.0.1:")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Runtime Headers
|
|
104
|
+
|
|
105
|
+
PiPhi runtimes use headers like `X-Container-Id` and
|
|
106
|
+
`X-PiPhi-Integration-Token` to identify themselves when communicating with
|
|
107
|
+
Core. In tests, use fake values rather than real runtime credentials.
|
|
108
|
+
|
|
109
|
+
The `runtime_headers` fixture and `build_runtime_headers(...)` helper build
|
|
110
|
+
those headers for you so you do not need to remember the exact names every
|
|
111
|
+
time.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
def test_headers(runtime_headers):
|
|
117
|
+
headers = runtime_headers(container_id="runtime-123", internal_token="test-token")
|
|
118
|
+
|
|
119
|
+
assert headers["X-Container-Id"] == "runtime-123"
|
|
120
|
+
assert headers["X-PiPhi-Integration-Token"] == "test-token"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Config Payload
|
|
124
|
+
|
|
125
|
+
A config payload is the JSON body your runtime receives on `/config`.
|
|
126
|
+
|
|
127
|
+
It usually contains things like:
|
|
128
|
+
|
|
129
|
+
- `id`
|
|
130
|
+
- `config_id`
|
|
131
|
+
- `device_id`
|
|
132
|
+
- `container_id`
|
|
133
|
+
- `integration_id`
|
|
134
|
+
|
|
135
|
+
The config payload builder gives you a realistic base payload and lets you add
|
|
136
|
+
integration-specific fields such as `host`, `username`, `path`, or anything
|
|
137
|
+
else your runtime needs.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
def test_config_payload(config_payload):
|
|
143
|
+
payload = config_payload(
|
|
144
|
+
config_id="plug-1",
|
|
145
|
+
extra={"host": "10.0.0.20"},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
assert payload["config_id"] == "plug-1"
|
|
149
|
+
assert payload["device_id"] == "plug-1"
|
|
150
|
+
assert payload["host"] == "10.0.0.20"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Config Snapshot
|
|
154
|
+
|
|
155
|
+
A config snapshot is the payload your runtime receives on `/config/sync`.
|
|
156
|
+
|
|
157
|
+
It represents the full current picture of what Core thinks should be configured.
|
|
158
|
+
|
|
159
|
+
Think of a snapshot like a fresh class attendance sheet. Instead of asking
|
|
160
|
+
"who changed?", you get the whole current list and compare it against what you
|
|
161
|
+
already have.
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
def test_config_snapshot(config_payload, config_snapshot):
|
|
167
|
+
one_device = config_payload(config_id="sensor-1", extra={"host": "10.0.0.5"})
|
|
168
|
+
snapshot = config_snapshot(configs=[one_device], generation=3)
|
|
169
|
+
|
|
170
|
+
assert snapshot["generation"] == 3
|
|
171
|
+
assert snapshot["configs"][0]["config_id"] == "sensor-1"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Captured Request
|
|
175
|
+
|
|
176
|
+
When the mock Core receives telemetry or an event, it stores a `CapturedRequest`
|
|
177
|
+
object.
|
|
178
|
+
|
|
179
|
+
That object includes:
|
|
180
|
+
|
|
181
|
+
- the HTTP method
|
|
182
|
+
- the path
|
|
183
|
+
- the headers
|
|
184
|
+
- the raw body
|
|
185
|
+
- the parsed JSON body, if the request body was valid JSON
|
|
186
|
+
|
|
187
|
+
This makes debugging much easier because your test can inspect exactly what was
|
|
188
|
+
sent.
|
|
189
|
+
|
|
190
|
+
Example:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
request = mock_core.telemetry_requests[-1]
|
|
194
|
+
assert request.path == "/api/v2/integrations/telemetry"
|
|
195
|
+
assert request.json_body["device_id"] == "sensor-1"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Assertion Helper
|
|
199
|
+
|
|
200
|
+
An assertion helper is just a small function that checks the captured requests
|
|
201
|
+
for you and raises a readable failure message if nothing matched.
|
|
202
|
+
|
|
203
|
+
Instead of manually looping through requests, you can write:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
mock_core.assert_telemetry_sent(device_id="sensor-1")
|
|
207
|
+
mock_core.assert_event_sent(config_id="sensor-1", event_type="device.configured")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
That makes the test much easier to understand at a glance.
|
|
211
|
+
|
|
212
|
+
## Install
|
|
213
|
+
|
|
214
|
+
### Install from PyPI
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
pip install piphi-runtime-testkit-python
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Local development install
|
|
221
|
+
|
|
222
|
+
If you are working from sibling repositories locally:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
pdm add -d /path/to/piphi-runtime-testkit-python
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### What this package depends on
|
|
229
|
+
|
|
230
|
+
The package itself is intentionally light. Its main runtime dependency is
|
|
231
|
+
pytest.
|
|
232
|
+
|
|
233
|
+
If you want to write full runtime tests for a FastAPI runtime, your integration
|
|
234
|
+
project will also usually need:
|
|
235
|
+
|
|
236
|
+
- `piphi-runtime-kit-python`
|
|
237
|
+
- `fastapi`
|
|
238
|
+
- `httpx`
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
pip install piphi-runtime-testkit-python
|
|
244
|
+
pip install piphi-runtime-kit-python
|
|
245
|
+
pip install fastapi httpx
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Or with sibling repositories during local development:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
pdm add -d /path/to/piphi-runtime-testkit-python
|
|
252
|
+
pdm add -d /path/to/piphi-runtime-kit-python
|
|
253
|
+
pdm add -d fastapi httpx
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Quick Start
|
|
257
|
+
|
|
258
|
+
This is the smallest realistic example:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
def test_builders(config_payload, config_snapshot, runtime_headers):
|
|
262
|
+
payload = config_payload(
|
|
263
|
+
config_id="plug-1",
|
|
264
|
+
extra={"host": "10.0.0.50"},
|
|
265
|
+
)
|
|
266
|
+
snapshot = config_snapshot(configs=[payload], generation=7)
|
|
267
|
+
headers = runtime_headers(container_id="runtime-123")
|
|
268
|
+
|
|
269
|
+
assert payload["config_id"] == "plug-1"
|
|
270
|
+
assert snapshot["generation"] == 7
|
|
271
|
+
assert headers["X-Container-Id"] == "runtime-123"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
What is happening here:
|
|
275
|
+
|
|
276
|
+
- `config_payload(...)` creates a realistic `/config` body
|
|
277
|
+
- `config_snapshot(...)` wraps that config inside a `/config/sync` body
|
|
278
|
+
- `runtime_headers(...)` builds the auth headers Core-style runtimes expect
|
|
279
|
+
|
|
280
|
+
This example does not start a server yet. It is a good first step when you only
|
|
281
|
+
need realistic test data.
|
|
282
|
+
|
|
283
|
+
## How The Pytest Plugin Works
|
|
284
|
+
|
|
285
|
+
This package registers itself as a pytest plugin. That means when pytest loads
|
|
286
|
+
the package, these fixtures become available automatically:
|
|
287
|
+
|
|
288
|
+
- `mock_core`
|
|
289
|
+
- `runtime_headers`
|
|
290
|
+
- `config_payload`
|
|
291
|
+
- `config_snapshot`
|
|
292
|
+
|
|
293
|
+
In most cases you do not need to import the fixtures manually. You can just use
|
|
294
|
+
them as test function arguments.
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
def test_with_fixtures(mock_core, runtime_headers):
|
|
300
|
+
headers = runtime_headers()
|
|
301
|
+
assert mock_core.base_url.startswith("http://127.0.0.1:")
|
|
302
|
+
assert "X-Container-Id" in headers
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Pytest Fixtures
|
|
306
|
+
|
|
307
|
+
### `mock_core`
|
|
308
|
+
|
|
309
|
+
Starts a small local HTTP server in the background and shuts it down after the
|
|
310
|
+
test finishes.
|
|
311
|
+
|
|
312
|
+
The server listens on a random local port and exposes:
|
|
313
|
+
|
|
314
|
+
- `POST /api/v2/integrations/telemetry`
|
|
315
|
+
- `POST /api/v2/events/ingest`
|
|
316
|
+
|
|
317
|
+
Both routes capture requests and return configurable JSON responses.
|
|
318
|
+
|
|
319
|
+
Use this fixture when:
|
|
320
|
+
|
|
321
|
+
- your runtime sends telemetry to Core
|
|
322
|
+
- your runtime sends events to Core
|
|
323
|
+
- you want to inspect what was sent
|
|
324
|
+
- you want to simulate error responses from Core
|
|
325
|
+
|
|
326
|
+
Example:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
def test_mock_core_defaults(mock_core):
|
|
330
|
+
assert mock_core.telemetry_requests == []
|
|
331
|
+
assert mock_core.event_requests == []
|
|
332
|
+
assert mock_core.base_url.startswith("http://127.0.0.1:")
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### `runtime_headers`
|
|
336
|
+
|
|
337
|
+
Returns the `build_runtime_headers(...)` function.
|
|
338
|
+
|
|
339
|
+
Use this fixture when you want to send realistic PiPhi auth headers into your
|
|
340
|
+
runtime routes.
|
|
341
|
+
|
|
342
|
+
Example:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
def test_runtime_headers(runtime_headers):
|
|
346
|
+
headers = runtime_headers(container_id="runtime-123", internal_token="test-token")
|
|
347
|
+
|
|
348
|
+
assert headers["X-Container-Id"] == "runtime-123"
|
|
349
|
+
assert headers["X-PiPhi-Integration-Token"] == "test-token"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `config_payload`
|
|
353
|
+
|
|
354
|
+
Returns the `build_config_payload(...)` function.
|
|
355
|
+
|
|
356
|
+
Use this fixture when you are testing `/config` or when you need a realistic
|
|
357
|
+
single config object for any other workflow.
|
|
358
|
+
|
|
359
|
+
Example:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
def test_config_payload_fixture(config_payload):
|
|
363
|
+
payload = config_payload(
|
|
364
|
+
config_id="sensor-1",
|
|
365
|
+
extra={"host": "127.0.0.1"},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
assert payload["id"] == "sensor-1"
|
|
369
|
+
assert payload["config_id"] == "sensor-1"
|
|
370
|
+
assert payload["host"] == "127.0.0.1"
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### `config_snapshot`
|
|
374
|
+
|
|
375
|
+
Returns the `build_config_snapshot(...)` function.
|
|
376
|
+
|
|
377
|
+
Use this fixture when testing `/config/sync` logic, especially when your
|
|
378
|
+
runtime needs to add missing configs and remove stale ones.
|
|
379
|
+
|
|
380
|
+
Example:
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
def test_config_snapshot_fixture(config_payload, config_snapshot):
|
|
384
|
+
config = config_payload(config_id="sensor-1")
|
|
385
|
+
snapshot = config_snapshot(configs=[config], generation=4)
|
|
386
|
+
|
|
387
|
+
assert snapshot["generation"] == 4
|
|
388
|
+
assert len(snapshot["configs"]) == 1
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Builder Functions
|
|
392
|
+
|
|
393
|
+
You can use the builder functions directly without pytest fixtures if you want:
|
|
394
|
+
|
|
395
|
+
- `build_runtime_headers(...)`
|
|
396
|
+
- `build_config_payload(...)`
|
|
397
|
+
- `build_config_snapshot(...)`
|
|
398
|
+
|
|
399
|
+
This can be useful in helper modules or test utility files.
|
|
400
|
+
|
|
401
|
+
### `build_runtime_headers(...)`
|
|
402
|
+
|
|
403
|
+
Signature:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
build_runtime_headers(
|
|
407
|
+
*,
|
|
408
|
+
container_id: str = "test-container",
|
|
409
|
+
internal_token: str = "test-token",
|
|
410
|
+
extra_headers: dict[str, str] | None = None,
|
|
411
|
+
) -> dict[str, str]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
What it returns:
|
|
415
|
+
|
|
416
|
+
- a dictionary of request headers using the PiPhi header names
|
|
417
|
+
|
|
418
|
+
Example:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
from piphi_runtime_testkit_python import build_runtime_headers
|
|
422
|
+
|
|
423
|
+
headers = build_runtime_headers(
|
|
424
|
+
container_id="runtime-123",
|
|
425
|
+
internal_token="test-token",
|
|
426
|
+
extra_headers={"X-Debug-Mode": "true"},
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### `build_config_payload(...)`
|
|
431
|
+
|
|
432
|
+
Signature:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
build_config_payload(
|
|
436
|
+
*,
|
|
437
|
+
config_id: str = "config-1",
|
|
438
|
+
device_id: str | None = None,
|
|
439
|
+
container_id: str = "test-container",
|
|
440
|
+
integration_id: str = "test-integration",
|
|
441
|
+
include_core_config_id: bool = True,
|
|
442
|
+
extra: dict[str, Any] | None = None,
|
|
443
|
+
) -> dict[str, Any]
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Important behavior:
|
|
447
|
+
|
|
448
|
+
- `id` is set to `config_id`
|
|
449
|
+
- `device_id` defaults to the same value as `config_id`
|
|
450
|
+
- `config_id` is included by default
|
|
451
|
+
- `extra` is merged into the final payload
|
|
452
|
+
|
|
453
|
+
Example:
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
from piphi_runtime_testkit_python import build_config_payload
|
|
457
|
+
|
|
458
|
+
payload = build_config_payload(
|
|
459
|
+
config_id="plug-1",
|
|
460
|
+
device_id="plug-physical-1",
|
|
461
|
+
extra={
|
|
462
|
+
"host": "10.0.0.12",
|
|
463
|
+
"alias": "Kitchen Plug",
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### `build_config_snapshot(...)`
|
|
469
|
+
|
|
470
|
+
Signature:
|
|
471
|
+
|
|
472
|
+
```python
|
|
473
|
+
build_config_snapshot(
|
|
474
|
+
*,
|
|
475
|
+
configs: list[dict[str, Any]] | None = None,
|
|
476
|
+
container_id: str = "test-container",
|
|
477
|
+
integration_id: str = "test-integration",
|
|
478
|
+
generation: int = 1,
|
|
479
|
+
extra: dict[str, Any] | None = None,
|
|
480
|
+
) -> dict[str, Any]
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Important behavior:
|
|
484
|
+
|
|
485
|
+
- `configs` defaults to an empty list
|
|
486
|
+
- `generation` defaults to `1`
|
|
487
|
+
- `extra` is merged into the final payload
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
from piphi_runtime_testkit_python import build_config_payload, build_config_snapshot
|
|
493
|
+
|
|
494
|
+
first = build_config_payload(config_id="sensor-1", extra={"host": "10.0.0.1"})
|
|
495
|
+
second = build_config_payload(config_id="sensor-2", extra={"host": "10.0.0.2"})
|
|
496
|
+
|
|
497
|
+
snapshot = build_config_snapshot(configs=[first, second], generation=9)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
## Assertion Helpers
|
|
501
|
+
|
|
502
|
+
The package exports:
|
|
503
|
+
|
|
504
|
+
- `assert_telemetry_sent(...)`
|
|
505
|
+
- `assert_event_sent(...)`
|
|
506
|
+
|
|
507
|
+
The `MockCoreServer` instance also exposes matching convenience methods:
|
|
508
|
+
|
|
509
|
+
- `mock_core.assert_telemetry_sent(...)`
|
|
510
|
+
- `mock_core.assert_event_sent(...)`
|
|
511
|
+
|
|
512
|
+
### `assert_telemetry_sent(...)`
|
|
513
|
+
|
|
514
|
+
Use this when you want to confirm at least one telemetry request reached the
|
|
515
|
+
mock Core server.
|
|
516
|
+
|
|
517
|
+
If you pass `device_id`, the helper finds a telemetry request for that device.
|
|
518
|
+
|
|
519
|
+
Example:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
telemetry_request = mock_core.assert_telemetry_sent(device_id="sensor-1")
|
|
523
|
+
assert telemetry_request.json_body["device_id"] == "sensor-1"
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Failure behavior:
|
|
527
|
+
|
|
528
|
+
- if no telemetry was captured, the helper raises a readable `AssertionError`
|
|
529
|
+
- if telemetry was captured, but not for the requested `device_id`, the helper
|
|
530
|
+
raises an error listing the captured device ids
|
|
531
|
+
|
|
532
|
+
### `assert_event_sent(...)`
|
|
533
|
+
|
|
534
|
+
Use this when you want to confirm that an event request reached the mock Core
|
|
535
|
+
server.
|
|
536
|
+
|
|
537
|
+
It can filter by:
|
|
538
|
+
|
|
539
|
+
- `device_id`
|
|
540
|
+
- `config_id`
|
|
541
|
+
- `event_type`
|
|
542
|
+
|
|
543
|
+
Example:
|
|
544
|
+
|
|
545
|
+
```python
|
|
546
|
+
event_request = mock_core.assert_event_sent(
|
|
547
|
+
device_id="sensor-1",
|
|
548
|
+
config_id="sensor-1",
|
|
549
|
+
event_type="device.configured",
|
|
550
|
+
)
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Important behavior:
|
|
554
|
+
|
|
555
|
+
This helper understands both common PiPhi event shapes:
|
|
556
|
+
|
|
557
|
+
- local-style event bodies using `event_type`
|
|
558
|
+
- Core-bound event bodies using `type`
|
|
559
|
+
|
|
560
|
+
That means it works well both for:
|
|
561
|
+
|
|
562
|
+
- local event-like payload tests
|
|
563
|
+
- real runtime SDK event delivery tests
|
|
564
|
+
|
|
565
|
+
## MockCoreServer API
|
|
566
|
+
|
|
567
|
+
The `mock_core` fixture gives you a `MockCoreServer` instance.
|
|
568
|
+
|
|
569
|
+
Useful properties:
|
|
570
|
+
|
|
571
|
+
- `mock_core.host`
|
|
572
|
+
- `mock_core.port`
|
|
573
|
+
- `mock_core.base_url`
|
|
574
|
+
- `mock_core.telemetry_url`
|
|
575
|
+
- `mock_core.event_url`
|
|
576
|
+
- `mock_core.telemetry_requests`
|
|
577
|
+
- `mock_core.event_requests`
|
|
578
|
+
|
|
579
|
+
Useful methods:
|
|
580
|
+
|
|
581
|
+
- `mock_core.set_telemetry_response(...)`
|
|
582
|
+
- `mock_core.set_event_response(...)`
|
|
583
|
+
- `mock_core.reset()`
|
|
584
|
+
- `mock_core.shutdown()`
|
|
585
|
+
- `mock_core.captured_telemetry_device_ids()`
|
|
586
|
+
- `mock_core.assert_telemetry_sent(...)`
|
|
587
|
+
- `mock_core.assert_event_sent(...)`
|
|
588
|
+
|
|
589
|
+
### Configure the response Core should return
|
|
590
|
+
|
|
591
|
+
You can simulate different Core behaviors by setting the next responses.
|
|
592
|
+
|
|
593
|
+
Example:
|
|
594
|
+
|
|
595
|
+
```python
|
|
596
|
+
def test_runtime_handles_event_failure(mock_core):
|
|
597
|
+
mock_core.set_event_response(
|
|
598
|
+
status_code=500,
|
|
599
|
+
json_body={"ok": False, "detail": "simulated failure"},
|
|
600
|
+
)
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
This is useful when you want to test:
|
|
604
|
+
|
|
605
|
+
- retry logic
|
|
606
|
+
- log output
|
|
607
|
+
- failure handling
|
|
608
|
+
- typed SDK exceptions
|
|
609
|
+
|
|
610
|
+
### Inspect captured requests manually
|
|
611
|
+
|
|
612
|
+
Example:
|
|
613
|
+
|
|
614
|
+
```python
|
|
615
|
+
request = mock_core.telemetry_requests[-1]
|
|
616
|
+
|
|
617
|
+
assert request.method == "POST"
|
|
618
|
+
assert request.path == "/api/v2/integrations/telemetry"
|
|
619
|
+
assert request.headers["Content-Type"] == "application/json"
|
|
620
|
+
assert request.json_body["device_id"] == "sensor-1"
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### Reset between phases in the same test
|
|
624
|
+
|
|
625
|
+
If one test has multiple phases, you can clear captured requests and restore the
|
|
626
|
+
default success responses.
|
|
627
|
+
|
|
628
|
+
Example:
|
|
629
|
+
|
|
630
|
+
```python
|
|
631
|
+
mock_core.reset()
|
|
632
|
+
assert mock_core.telemetry_requests == []
|
|
633
|
+
assert mock_core.event_requests == []
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
## Basic Example
|
|
637
|
+
|
|
638
|
+
This example only uses the builders and fixtures.
|
|
639
|
+
|
|
640
|
+
```python
|
|
641
|
+
def test_runtime_builders(config_payload, config_snapshot, runtime_headers):
|
|
642
|
+
payload = config_payload(
|
|
643
|
+
config_id="plug-1",
|
|
644
|
+
extra={"host": "10.0.0.50"},
|
|
645
|
+
)
|
|
646
|
+
snapshot = config_snapshot(configs=[payload], generation=7)
|
|
647
|
+
headers = runtime_headers(container_id="runtime-123")
|
|
648
|
+
|
|
649
|
+
assert payload["config_id"] == "plug-1"
|
|
650
|
+
assert snapshot["generation"] == 7
|
|
651
|
+
assert headers["X-Container-Id"] == "runtime-123"
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
This style is great when you want low-overhead tests for:
|
|
655
|
+
|
|
656
|
+
- payload shape
|
|
657
|
+
- config sync logic
|
|
658
|
+
- helper functions
|
|
659
|
+
- request wiring
|
|
660
|
+
|
|
661
|
+
## Mock Core Example
|
|
662
|
+
|
|
663
|
+
This example shows the mock server concept without a full runtime app:
|
|
664
|
+
|
|
665
|
+
```python
|
|
666
|
+
def test_mock_core_captures_requests(mock_core):
|
|
667
|
+
mock_core.set_telemetry_response(status_code=200, json_body={"ok": True})
|
|
668
|
+
|
|
669
|
+
# Point your runtime SDK client at mock_core.base_url, then:
|
|
670
|
+
# mock_core.assert_telemetry_sent(device_id="plug-1")
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
This example is intentionally short. In real tests, your runtime will usually
|
|
674
|
+
perform the `POST` and the test will assert what the mock Core captured.
|
|
675
|
+
|
|
676
|
+
## FastAPI End-to-End Example
|
|
677
|
+
|
|
678
|
+
The strongest example in this package is a real FastAPI round-trip test in
|
|
679
|
+
[`tests/test_fastapi_runtime_example.py`](./tests/test_fastapi_runtime_example.py).
|
|
680
|
+
|
|
681
|
+
That test does all of these things:
|
|
682
|
+
|
|
683
|
+
1. creates a real FastAPI app
|
|
684
|
+
2. creates a real runtime starter from `piphi-runtime-kit-python`
|
|
685
|
+
3. applies a config using realistic headers and payloads
|
|
686
|
+
4. queues telemetry delivery to mock Core
|
|
687
|
+
5. queues event delivery to mock Core
|
|
688
|
+
6. waits for background delivery to complete
|
|
689
|
+
7. asserts what mock Core captured
|
|
690
|
+
|
|
691
|
+
Here is the important flow in shortened form:
|
|
692
|
+
|
|
693
|
+
```python
|
|
694
|
+
payload = config_payload(
|
|
695
|
+
config_id="sensor-1",
|
|
696
|
+
container_id="runtime-123",
|
|
697
|
+
extra={"host": "127.0.0.1"},
|
|
698
|
+
)
|
|
699
|
+
headers = runtime_headers(
|
|
700
|
+
container_id="runtime-123",
|
|
701
|
+
internal_token="test-token",
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
client.post("/config", json=payload, headers=headers)
|
|
705
|
+
client.post("/telemetry/example")
|
|
706
|
+
client.post("/events/example")
|
|
707
|
+
|
|
708
|
+
wait_for(lambda: len(mock_core.telemetry_requests) >= 1)
|
|
709
|
+
wait_for(lambda: len(mock_core.event_requests) >= 1)
|
|
710
|
+
|
|
711
|
+
telemetry_request = mock_core.assert_telemetry_sent(device_id="sensor-1")
|
|
712
|
+
event_request = mock_core.assert_event_sent(
|
|
713
|
+
device_id="sensor-1",
|
|
714
|
+
config_id="sensor-1",
|
|
715
|
+
event_type="device.configured",
|
|
716
|
+
)
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
Why this example matters:
|
|
720
|
+
|
|
721
|
+
- it proves the testkit works with a real FastAPI app
|
|
722
|
+
- it proves the testkit works with the real Python runtime SDK
|
|
723
|
+
- it demonstrates the intended developer workflow
|
|
724
|
+
|
|
725
|
+
If you are building a FastAPI integration, this is the best example to study.
|
|
726
|
+
|
|
727
|
+
## Common Test Patterns
|
|
728
|
+
|
|
729
|
+
### Test `/config` route behavior
|
|
730
|
+
|
|
731
|
+
Use:
|
|
732
|
+
|
|
733
|
+
- `config_payload`
|
|
734
|
+
- `runtime_headers`
|
|
735
|
+
- your framework test client
|
|
736
|
+
|
|
737
|
+
You will usually assert:
|
|
738
|
+
|
|
739
|
+
- response status
|
|
740
|
+
- response body
|
|
741
|
+
- registry update or local state change
|
|
742
|
+
|
|
743
|
+
### Test `/config/sync` route behavior
|
|
744
|
+
|
|
745
|
+
Use:
|
|
746
|
+
|
|
747
|
+
- `config_payload`
|
|
748
|
+
- `config_snapshot`
|
|
749
|
+
|
|
750
|
+
You will usually assert:
|
|
751
|
+
|
|
752
|
+
- new configs were applied
|
|
753
|
+
- stale configs were removed
|
|
754
|
+
- generation or snapshot metadata was stored
|
|
755
|
+
|
|
756
|
+
### Test outbound telemetry delivery
|
|
757
|
+
|
|
758
|
+
Use:
|
|
759
|
+
|
|
760
|
+
- `mock_core`
|
|
761
|
+
- `assert_telemetry_sent(...)`
|
|
762
|
+
|
|
763
|
+
You will usually assert:
|
|
764
|
+
|
|
765
|
+
- telemetry reached the Core endpoint
|
|
766
|
+
- the right `device_id` was used
|
|
767
|
+
- the right headers were sent
|
|
768
|
+
- the metric payload shape is correct
|
|
769
|
+
|
|
770
|
+
### Test outbound event delivery
|
|
771
|
+
|
|
772
|
+
Use:
|
|
773
|
+
|
|
774
|
+
- `mock_core`
|
|
775
|
+
- `assert_event_sent(...)`
|
|
776
|
+
|
|
777
|
+
You will usually assert:
|
|
778
|
+
|
|
779
|
+
- event reached the Core endpoint
|
|
780
|
+
- the right `config_id` and `device_id` were sent
|
|
781
|
+
- the right event type was sent
|
|
782
|
+
|
|
783
|
+
## Common Mistakes
|
|
784
|
+
|
|
785
|
+
### Forgetting to point your runtime client at `mock_core.base_url`
|
|
786
|
+
|
|
787
|
+
Symptom:
|
|
788
|
+
|
|
789
|
+
- your test passes locally without actually exercising outbound delivery
|
|
790
|
+
- or your runtime tries to talk to a real Core instance
|
|
791
|
+
|
|
792
|
+
Fix:
|
|
793
|
+
|
|
794
|
+
- configure your telemetry/event client to use `mock_core.base_url`
|
|
795
|
+
|
|
796
|
+
### Using mismatched `container_id` values in headers and payload
|
|
797
|
+
|
|
798
|
+
Symptom:
|
|
799
|
+
|
|
800
|
+
- auth or routing behavior looks confusing
|
|
801
|
+
- runtime state reflects one container id while requests were sent with another
|
|
802
|
+
|
|
803
|
+
Fix:
|
|
804
|
+
|
|
805
|
+
- keep your test inputs intentional
|
|
806
|
+
- if your runtime uses payload container ids as fallback or source of truth,
|
|
807
|
+
make sure the payload and headers agree unless you are explicitly testing a
|
|
808
|
+
mismatch
|
|
809
|
+
|
|
810
|
+
### Asserting only request count instead of payload shape
|
|
811
|
+
|
|
812
|
+
Symptom:
|
|
813
|
+
|
|
814
|
+
- tests say "a request happened" but do not tell you if it was the correct one
|
|
815
|
+
|
|
816
|
+
Fix:
|
|
817
|
+
|
|
818
|
+
- use `assert_telemetry_sent(device_id=...)`
|
|
819
|
+
- use `assert_event_sent(config_id=..., event_type=...)`
|
|
820
|
+
|
|
821
|
+
### Forgetting background delivery timing
|
|
822
|
+
|
|
823
|
+
Symptom:
|
|
824
|
+
|
|
825
|
+
- flaky tests
|
|
826
|
+
- request count is zero right after calling a route that only queued work
|
|
827
|
+
|
|
828
|
+
Fix:
|
|
829
|
+
|
|
830
|
+
- wait for delivery to complete before asserting
|
|
831
|
+
- use a polling helper like the `wait_for(...)` function in
|
|
832
|
+
[`tests/test_fastapi_runtime_example.py`](./tests/test_fastapi_runtime_example.py)
|
|
833
|
+
|
|
834
|
+
## Troubleshooting
|
|
835
|
+
|
|
836
|
+
### "No telemetry requests were captured"
|
|
837
|
+
|
|
838
|
+
Check:
|
|
839
|
+
|
|
840
|
+
- did the runtime actually try to send telemetry?
|
|
841
|
+
- did the client point to `mock_core.base_url`?
|
|
842
|
+
- was delivery queued in the background and asserted too early?
|
|
843
|
+
|
|
844
|
+
### "Expected event matching filters, but none were captured"
|
|
845
|
+
|
|
846
|
+
Check:
|
|
847
|
+
|
|
848
|
+
- did your runtime send the expected `device_id`?
|
|
849
|
+
- did it send the expected `config_id`?
|
|
850
|
+
- are you filtering by the correct event name?
|
|
851
|
+
- are you dealing with a Core-style event payload using `type` instead of a
|
|
852
|
+
local-style payload using `event_type`?
|
|
853
|
+
|
|
854
|
+
### "json_body is None"
|
|
855
|
+
|
|
856
|
+
This means the request body was not valid JSON.
|
|
857
|
+
|
|
858
|
+
Check:
|
|
859
|
+
|
|
860
|
+
- whether the runtime sent plain text or malformed JSON
|
|
861
|
+
- whether the request body was empty
|
|
862
|
+
|
|
863
|
+
The raw bytes are still available on `CapturedRequest.body`.
|
|
864
|
+
|
|
865
|
+
### Unknown path responses from mock Core
|
|
866
|
+
|
|
867
|
+
The mock Core server only handles:
|
|
868
|
+
|
|
869
|
+
- `/api/v2/integrations/telemetry`
|
|
870
|
+
- `/api/v2/events/ingest`
|
|
871
|
+
|
|
872
|
+
If your runtime posts somewhere else, the mock server returns `404` with:
|
|
873
|
+
|
|
874
|
+
```json
|
|
875
|
+
{"ok": false, "detail": "unknown path"}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
That usually means your runtime is using the wrong endpoint path.
|
|
879
|
+
|
|
880
|
+
## Public API Reference
|
|
881
|
+
|
|
882
|
+
Top-level exports:
|
|
883
|
+
|
|
884
|
+
- `CapturedRequest`
|
|
885
|
+
- `MockCoreServer`
|
|
886
|
+
- `assert_event_sent`
|
|
887
|
+
- `assert_telemetry_sent`
|
|
888
|
+
- `build_config_payload`
|
|
889
|
+
- `build_config_snapshot`
|
|
890
|
+
- `build_runtime_headers`
|
|
891
|
+
|
|
892
|
+
Pytest fixtures:
|
|
893
|
+
|
|
894
|
+
- `mock_core`
|
|
895
|
+
- `runtime_headers`
|
|
896
|
+
- `config_payload`
|
|
897
|
+
- `config_snapshot`
|
|
898
|
+
|
|
899
|
+
## Design Goals
|
|
900
|
+
|
|
901
|
+
This project is trying to stay:
|
|
902
|
+
|
|
903
|
+
- pytest-first
|
|
904
|
+
- easy to read
|
|
905
|
+
- easy to debug
|
|
906
|
+
- low setup
|
|
907
|
+
- friendly to junior developers
|
|
908
|
+
- flexible enough for advanced integration tests
|
|
909
|
+
|
|
910
|
+
The goal is not to create a giant testing framework. The goal is to make the
|
|
911
|
+
common PiPhi runtime testing tasks pleasant and obvious.
|
|
912
|
+
|
|
913
|
+
## Releasing To PyPI
|
|
914
|
+
|
|
915
|
+
This repository includes a manual GitHub Actions release workflow at
|
|
916
|
+
[`release-pypi.yml`](./.github/workflows/release-pypi.yml) so you do not have to
|
|
917
|
+
manually bump the package version or run `twine upload` from your laptop every
|
|
918
|
+
time.
|
|
919
|
+
|
|
920
|
+
Before the first automated release:
|
|
921
|
+
|
|
922
|
+
- configure a Trusted Publisher on TestPyPI for this repository, workflow file
|
|
923
|
+
`.github/workflows/release-pypi.yml`, and environment `testpypi`
|
|
924
|
+
- configure a Trusted Publisher on PyPI for this repository, workflow file
|
|
925
|
+
`.github/workflows/release-pypi.yml`, and environment `pypi`
|
|
926
|
+
- if this is the first ever upload, create a pending publisher on PyPI/TestPyPI
|
|
927
|
+
so the workflow can create the project on first publish
|
|
928
|
+
- make sure your branch protection rules allow `GITHUB_TOKEN` to push the
|
|
929
|
+
version bump commit and tag back to the default branch
|
|
930
|
+
|
|
931
|
+
Recommended flow:
|
|
932
|
+
|
|
933
|
+
1. merge your changes to the default branch
|
|
934
|
+
2. run `Testkit Package Check`
|
|
935
|
+
3. run `Release Testkit Package` with `target_repository=testpypi` for a
|
|
936
|
+
rehearsal or prerelease
|
|
937
|
+
4. run `Release Testkit Package` with `target_repository=pypi` for the real
|
|
938
|
+
release
|
|
939
|
+
|
|
940
|
+
What the workflow does:
|
|
941
|
+
|
|
942
|
+
- checks that the run started from the default branch
|
|
943
|
+
- installs the dev environment and runs `pytest -q`
|
|
944
|
+
- computes the next semantic version with `scripts/release.py`
|
|
945
|
+
- builds and validates the distribution artifacts
|
|
946
|
+
- publishes to TestPyPI or PyPI using Trusted Publishing
|
|
947
|
+
- for real PyPI releases, commits `pyproject.toml`, tags `v<version>`, and
|
|
948
|
+
creates a GitHub release
|
|
949
|
+
|
|
950
|
+
The `release_type` input supports:
|
|
951
|
+
|
|
952
|
+
- `patch`, `minor`, `major` for stable releases
|
|
953
|
+
- `prepatch`, `preminor`, `premajor`, `prerelease` for prereleases like
|
|
954
|
+
`0.1.1-alpha.1`
|
|
955
|
+
- `release` to promote a prerelease like `0.2.0-rc.1` to `0.2.0`
|
|
956
|
+
- `custom` when you need to set an explicit semantic version
|
|
957
|
+
|
|
958
|
+
Manual upload still works if you need it:
|
|
959
|
+
|
|
960
|
+
```bash
|
|
961
|
+
pdm build
|
|
962
|
+
pdm run twine check dist/*
|
|
963
|
+
pdm run twine upload dist/*
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
## Versioning And Release Notes
|
|
967
|
+
|
|
968
|
+
See [VERSIONING.md](./VERSIONING.md) for semver guidance and [CHANGELOG.md](./CHANGELOG.md)
|
|
969
|
+
for published release notes.
|