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