Skip to content

Commit e2c4116

Browse files
committed
test(bootstrap): add SensorML roundtrip integration test
Four offline tests cover SML_ONLY_FIELDS membership and the _warn_if_sml_fields_in_stub guardrail in both lenient and strict modes. Two network-gated tests POST a procedure / deployment with marker keywords + identifiers, GET them back as application/sml+json, and assert the marker fields survive. Enable by setting OS4CSAPI_TEST_BASE_URL / OS4CSAPI_TEST_USER / OS4CSAPI_TEST_PASS. Verified: 6/6 passing against https://129-80-248-53.sslip.io/csapi-go-upstream/. Refs: #5
1 parent 2ef4e95 commit e2c4116

1 file changed

Lines changed: 243 additions & 0 deletions

File tree

tests/test_bootstrap_roundtrip.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
test_bootstrap_roundtrip.py — integration test for the encoding-correct
3+
two-step bootstrap pattern.
4+
5+
Verifies that ``ensure_procedure`` and ``ensure_deployment`` preserve
6+
SensorML metadata end-to-end:
7+
8+
POST geo+json stub → PUT application/sml+json body → GET application/sml+json
9+
→ asserts ``keywords`` (and other SensorML-only fields) round-trip.
10+
11+
Also exercises the ``_warn_if_sml_fields_in_stub`` guardrail unconditionally
12+
(no network).
13+
14+
Configuration (set in env to enable the network portion):
15+
16+
OS4CSAPI_TEST_BASE_URL — e.g. https://129-80-248-53.sslip.io/csapi-go-upstream
17+
OS4CSAPI_TEST_USER — basic-auth username
18+
OS4CSAPI_TEST_PASS — basic-auth password
19+
OS4CSAPI_STRICT_BOOTSTRAP=1 — recommended; turns guardrail warnings into errors
20+
21+
When the network env is missing, the network-dependent tests are skipped
22+
but the offline guardrail tests still run.
23+
"""
24+
from __future__ import annotations
25+
26+
import os
27+
import time
28+
import uuid
29+
30+
import pytest
31+
32+
from publishers.bootstrap_helpers import (
33+
SML_ONLY_FIELDS,
34+
_auth_header,
35+
_warn_if_sml_fields_in_stub,
36+
api_delete,
37+
api_get,
38+
ensure_deployment,
39+
ensure_procedure,
40+
)
41+
42+
43+
# ─────────────────────────────────────────────────────────────────────
44+
# Offline tests — no network required
45+
# ─────────────────────────────────────────────────────────────────────
46+
47+
def test_sml_only_fields_includes_expected_set():
48+
for field in ("keywords", "identifiers", "classifiers", "contacts",
49+
"documentation", "documents", "history",
50+
"characteristics", "capabilities",
51+
"securityConstraints", "legalConstraints"):
52+
assert field in SML_ONLY_FIELDS, field
53+
54+
55+
def test_warn_if_sml_fields_passes_clean_stub():
56+
clean_stub = {
57+
"type": "Feature",
58+
"geometry": None,
59+
"properties": {
60+
"uid": "urn:test:clean",
61+
"featureType": "sosa:ObservingProcedure",
62+
"name": "Clean stub",
63+
"description": "Only geo+json fields under properties.",
64+
"validTime": ["2024-01-01T00:00:00Z", ".."],
65+
},
66+
}
67+
# No exception, no warning expected.
68+
_warn_if_sml_fields_in_stub(clean_stub, "test")
69+
70+
71+
def test_warn_if_sml_fields_strict_mode_raises_on_leak(monkeypatch):
72+
"""In strict mode, SensorML fields under properties MUST raise."""
73+
monkeypatch.setenv("OS4CSAPI_STRICT_BOOTSTRAP", "1")
74+
75+
# Re-import to pick up the env at module load? The helper reads env
76+
# at import. Patch the module-level flag for this test.
77+
import publishers.bootstrap_helpers as bh
78+
monkeypatch.setattr(bh, "_STRICT_BOOTSTRAP", True)
79+
80+
leaky_stub = {
81+
"type": "Feature",
82+
"properties": {
83+
"uid": "urn:test:leaky",
84+
"name": "Leaky stub",
85+
"keywords": ["this", "leaks"],
86+
},
87+
}
88+
with pytest.raises(RuntimeError, match="ENCODING-CONTRACT"):
89+
bh._warn_if_sml_fields_in_stub(leaky_stub, "test-leaky")
90+
91+
92+
def test_warn_if_sml_fields_lenient_mode_warns_on_leak(monkeypatch, capsys):
93+
"""In lenient (default) mode, leaks emit a [WARN] line."""
94+
import publishers.bootstrap_helpers as bh
95+
monkeypatch.setattr(bh, "_STRICT_BOOTSTRAP", False)
96+
bh._warn_if_sml_fields_in_stub(
97+
{"properties": {"uid": "x", "contacts": [{}]}}, "leak-test")
98+
out = capsys.readouterr().out
99+
assert "[WARN]" in out
100+
assert "ENCODING-CONTRACT" in out
101+
assert "contacts" in out
102+
103+
104+
# ─────────────────────────────────────────────────────────────────────
105+
# Network tests — require live CSAPI server
106+
# ─────────────────────────────────────────────────────────────────────
107+
108+
_BASE_URL = os.environ.get("OS4CSAPI_TEST_BASE_URL", "").rstrip("/")
109+
_USER = os.environ.get("OS4CSAPI_TEST_USER", "")
110+
_PASS = os.environ.get("OS4CSAPI_TEST_PASS", "")
111+
112+
_HAS_NETWORK_CONFIG = bool(_BASE_URL and _USER and _PASS)
113+
_skip_no_net = pytest.mark.skipif(
114+
not _HAS_NETWORK_CONFIG,
115+
reason=("Set OS4CSAPI_TEST_BASE_URL / OS4CSAPI_TEST_USER / OS4CSAPI_TEST_PASS "
116+
"to enable bootstrap roundtrip integration tests."),
117+
)
118+
119+
120+
def _unique_uid(kind: str) -> str:
121+
return f"urn:os4csapi:test:{kind}:{uuid.uuid4().hex[:12]}"
122+
123+
124+
def _expected_keywords() -> list[str]:
125+
return ["alpha", "bravo", "roundtrip", f"ts-{int(time.time())}"]
126+
127+
128+
@_skip_no_net
129+
def test_procedure_roundtrip_preserves_sensorml():
130+
"""Create a procedure with SensorML metadata; GET it back; assert keywords survive."""
131+
auth = _auth_header(_USER, _PASS)
132+
uid = _unique_uid("procedure")
133+
keywords = _expected_keywords()
134+
135+
stub = {
136+
"type": "Feature",
137+
"geometry": None,
138+
"properties": {
139+
"uid": uid,
140+
"featureType": "sosa:ObservingProcedure",
141+
"name": "Roundtrip Test Procedure",
142+
"description": "Created by tests/test_bootstrap_roundtrip.py.",
143+
},
144+
}
145+
sml = {
146+
"type": "SimpleProcess",
147+
"id": uid,
148+
"uniqueId": uid,
149+
"label": "Roundtrip Test Procedure",
150+
"description": "SensorML body for roundtrip integration test.",
151+
"keywords": keywords,
152+
"identifiers": [{
153+
"definition": "http://sensorml.com/ont/swe/property/ShortName",
154+
"label": "Short Name",
155+
"value": "Roundtrip Test",
156+
}],
157+
}
158+
159+
new_id = None
160+
try:
161+
new_id = ensure_procedure(_BASE_URL, auth, uid, stub, sml)
162+
assert new_id, "ensure_procedure returned no id"
163+
164+
# GET as SensorML
165+
from urllib.request import Request, urlopen
166+
import json
167+
req = Request(f"{_BASE_URL}/procedures/{new_id}", headers={
168+
"Authorization": auth,
169+
"Accept": "application/sml+json",
170+
})
171+
with urlopen(req, timeout=15) as resp:
172+
doc = json.loads(resp.read().decode())
173+
174+
got_keywords = doc.get("keywords") or []
175+
for kw in keywords:
176+
assert kw in got_keywords, (
177+
f"keyword {kw!r} did not round-trip; got {got_keywords!r} "
178+
f"(SensorML fields probably stripped — encoding-contract bug regressed)"
179+
)
180+
181+
assert any(i.get("value") == "Roundtrip Test"
182+
for i in (doc.get("identifiers") or [])), \
183+
f"identifiers did not round-trip; got {doc.get('identifiers')!r}"
184+
185+
finally:
186+
if new_id:
187+
try:
188+
api_delete(_BASE_URL, f"procedures/{new_id}", auth, cascade=True)
189+
except Exception:
190+
pass
191+
192+
193+
@_skip_no_net
194+
def test_deployment_roundtrip_preserves_sensorml():
195+
"""Same contract for deployments."""
196+
auth = _auth_header(_USER, _PASS)
197+
uid = _unique_uid("deployment")
198+
keywords = _expected_keywords()
199+
200+
stub = {
201+
"type": "Feature",
202+
"geometry": {"type": "Point", "coordinates": [-95.0, 37.0]},
203+
"properties": {
204+
"uid": uid,
205+
"featureType": "sosa:Deployment",
206+
"name": "Roundtrip Test Deployment",
207+
"description": "Created by tests/test_bootstrap_roundtrip.py.",
208+
},
209+
}
210+
sml = {
211+
"type": "Deployment",
212+
"id": uid,
213+
"uniqueId": uid,
214+
"label": "Roundtrip Test Deployment",
215+
"description": "SensorML body for roundtrip integration test.",
216+
"keywords": keywords,
217+
}
218+
219+
new_id = None
220+
try:
221+
new_id = ensure_deployment(_BASE_URL, auth, uid, stub, sml)
222+
assert new_id
223+
224+
from urllib.request import Request, urlopen
225+
import json
226+
req = Request(f"{_BASE_URL}/deployments/{new_id}", headers={
227+
"Authorization": auth,
228+
"Accept": "application/sml+json",
229+
})
230+
with urlopen(req, timeout=15) as resp:
231+
doc = json.loads(resp.read().decode())
232+
233+
got_keywords = doc.get("keywords") or []
234+
for kw in keywords:
235+
assert kw in got_keywords, (
236+
f"keyword {kw!r} did not round-trip; got {got_keywords!r}"
237+
)
238+
finally:
239+
if new_id:
240+
try:
241+
api_delete(_BASE_URL, f"deployments/{new_id}", auth, cascade=True)
242+
except Exception:
243+
pass

0 commit comments

Comments
 (0)