-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_api_integration.py
More file actions
463 lines (385 loc) · 16.8 KB
/
test_api_integration.py
File metadata and controls
463 lines (385 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
"""
API Integration Test Suite for RoadSense AI
Tests against the live dev API: https://api.roadsense.dev
Endpoints tested:
- POST /ingest-signal
- GET /incidents
- GET /confidence/{id}
- GET /export
Run with: pytest tests/test_api_integration.py -v --tb=short
Set environment variable ROADSENSE_API_URL to override API base URL.
"""
import os
import pytest
import requests
import uuid
from datetime import datetime, timezone
from hypothesis import given, strategies as st, settings
# ── Config ────────────────────────────────────────────────────────────────────
API_BASE_URL = os.getenv("ROADSENSE_API_URL", "https://api.roadsense.dev")
# Skip integration tests if API is not reachable
def api_is_reachable():
try:
response = requests.get(f"{API_BASE_URL}/incidents", timeout=5)
return response.status_code in [200, 401, 403] # Any response means it's up
except requests.RequestException:
return False
# Conditional skip marker
requires_api = pytest.mark.skipif(
not api_is_reachable(),
reason=f"API not reachable at {API_BASE_URL}"
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def valid_signal():
"""A valid signal payload."""
return {
"signal_id": str(uuid.uuid4()),
"original_content": "Large pothole spotted on MG Road causing traffic",
"translated_content": "Large pothole spotted on MG Road causing traffic",
"detected_language": "en",
"source": "reddit",
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"location": {
"latitude": 12.9750,
"longitude": 77.6060,
"address": "MG Road"
}
}
@pytest.fixture
def hindi_signal():
"""A Hindi signal payload."""
return {
"signal_id": str(uuid.uuid4()),
"original_content": "सड़क पर बड़ा गड्ढा है MG Road में",
"translated_content": "There is a large pothole on MG Road",
"detected_language": "hi",
"source": "reddit",
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"location": {
"latitude": 12.9750,
"longitude": 77.6060,
"address": "MG Road"
}
}
@pytest.fixture
def minimal_signal():
"""A minimal valid signal (required fields only)."""
return {
"signal_id": str(uuid.uuid4()),
"original_content": "Road damage reported",
"source": "news",
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
# ── Test Signal Ingestion ─────────────────────────────────────────────────────
@requires_api
class TestIngestSignal:
"""Tests for POST /ingest-signal endpoint."""
def test_ingest_valid_signal(self, valid_signal):
"""Should accept a valid signal with 201."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code == 201
def test_ingest_hindi_signal(self, hindi_signal):
"""Should accept a Hindi signal."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=hindi_signal,
timeout=10
)
assert response.status_code == 201
def test_ingest_minimal_signal(self, minimal_signal):
"""Should accept minimal required fields."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=minimal_signal,
timeout=10
)
assert response.status_code == 201
def test_ingest_missing_signal_id(self, valid_signal):
"""Should reject signal without signal_id."""
del valid_signal["signal_id"]
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code == 400
def test_ingest_missing_content(self, valid_signal):
"""Should reject signal without original_content."""
del valid_signal["original_content"]
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code == 400
def test_ingest_missing_source(self, valid_signal):
"""Should reject signal without source."""
del valid_signal["source"]
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code == 400
def test_ingest_invalid_source(self, valid_signal):
"""Should reject invalid source value."""
valid_signal["source"] = "invalid_source"
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code == 400
def test_ingest_invalid_coordinates(self, valid_signal):
"""Should reject invalid coordinates."""
valid_signal["location"]["latitude"] = 999
valid_signal["location"]["longitude"] = -999
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
# May accept but should handle gracefully
assert response.status_code in [201, 400]
def test_ingest_empty_content(self, valid_signal):
"""Should handle empty content."""
valid_signal["original_content"] = ""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.status_code in [201, 400]
def test_ingest_idempotent(self, valid_signal):
"""Ingesting same signal twice should be handled."""
response1 = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
response2 = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
# Second request may succeed (idempotent) or fail (duplicate)
assert response1.status_code == 201
assert response2.status_code in [201, 409, 400]
# ── Test List Incidents ───────────────────────────────────────────────────────
@requires_api
class TestGetIncidents:
"""Tests for GET /incidents endpoint."""
def test_get_incidents_returns_list(self):
"""Should return a list of incidents."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_incident_schema(self):
"""Each incident should have required fields."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
incidents = response.json()
for incident in incidents[:5]: # Check first 5
assert "incident_id" in incident
assert "confidence_score" in incident
assert "status" in incident
def test_confidence_score_in_range(self):
"""Confidence scores must be 0-100."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
incidents = response.json()
for incident in incidents:
score = incident.get("confidence_score", 0)
assert 0 <= score <= 100, f"Invalid confidence: {score}"
def test_status_valid_enum(self):
"""Status must be active or archived."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
incidents = response.json()
for incident in incidents:
status = incident.get("status")
assert status in ["active", "archived", "monitoring", None]
def test_active_incidents_above_threshold(self):
"""Active incidents should have confidence > 30."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
incidents = response.json()
for incident in incidents:
if incident.get("status") == "active":
assert incident.get("confidence_score", 0) >= 30
# ── Test Get Confidence ───────────────────────────────────────────────────────
@requires_api
class TestGetConfidence:
"""Tests for GET /confidence/{id} endpoint."""
def test_get_confidence_existing(self):
"""Should return confidence for existing incident."""
# First get an incident
incidents_response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
if incidents_response.status_code == 200 and incidents_response.json():
incident_id = incidents_response.json()[0]["incident_id"]
response = requests.get(
f"{API_BASE_URL}/confidence/{incident_id}",
timeout=10
)
assert response.status_code == 200
data = response.json()
assert "confidence_score" in data
assert 0 <= data["confidence_score"] <= 100
def test_get_confidence_nonexistent(self):
"""Should return 404 for non-existent incident."""
fake_id = str(uuid.uuid4())
response = requests.get(
f"{API_BASE_URL}/confidence/{fake_id}",
timeout=10
)
assert response.status_code == 404
# ── Test Export ───────────────────────────────────────────────────────────────
@requires_api
class TestExport:
"""Tests for GET /export endpoint."""
def test_export_returns_json(self):
"""Should return JSON array."""
response = requests.get(f"{API_BASE_URL}/export", timeout=30)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_export_content_type(self):
"""Response should be application/json."""
response = requests.get(f"{API_BASE_URL}/export", timeout=30)
assert response.status_code == 200
assert "application/json" in response.headers.get("Content-Type", "")
# ── Schema Validation Tests ───────────────────────────────────────────────────
@requires_api
class TestSchemaValidation:
"""Tests for schema compliance based on OpenAPI spec."""
def test_signal_validates_location_schema(self, valid_signal):
"""Location object should match schema."""
location = valid_signal["location"]
assert "latitude" in location
assert "longitude" in location
assert -90 <= location["latitude"] <= 90
assert -180 <= location["longitude"] <= 180
def test_incident_location_schema(self):
"""Incident location should match schema."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
if response.status_code == 200:
for incident in response.json()[:5]:
location = incident.get("location")
if location:
lat = location.get("latitude", 0)
lng = location.get("longitude", 0)
assert -90 <= lat <= 90 or lat in [999, -999] # Allow edge cases
assert -180 <= lng <= 180 or lng in [999, -999]
# ── Performance Tests ─────────────────────────────────────────────────────────
@requires_api
class TestApiPerformance:
"""Basic performance checks."""
def test_incidents_response_time(self):
"""GET /incidents should respond within 5s."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=5)
assert response.elapsed.total_seconds() < 5
def test_ingest_response_time(self, valid_signal):
"""POST /ingest-signal should respond within 10s."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert response.elapsed.total_seconds() < 10
# ── Property-based API Tests ──────────────────────────────────────────────────
@requires_api
class TestApiProperties:
"""Property-based tests against the API."""
@given(st.sampled_from(["reddit", "news", "weather"]))
@settings(max_examples=3)
def test_valid_sources_accepted(self, source):
"""All valid source values should be accepted."""
signal = {
"signal_id": str(uuid.uuid4()),
"original_content": f"Test content from {source}",
"source": source,
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=signal,
timeout=10
)
assert response.status_code == 201
@given(st.sampled_from(["en", "hi", "ta", "te"]))
@settings(max_examples=4)
def test_valid_languages_accepted(self, language):
"""All supported languages should be accepted."""
signal = {
"signal_id": str(uuid.uuid4()),
"original_content": "Test multilingual content",
"detected_language": language,
"source": "reddit",
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=signal,
timeout=10
)
assert response.status_code == 201
# ── Negative Tests ────────────────────────────────────────────────────────────
@requires_api
class TestApiNegativeCases:
"""Negative test cases for error handling."""
def test_malformed_json(self):
"""Should reject malformed JSON."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
data="not valid json",
headers={"Content-Type": "application/json"},
timeout=10
)
assert response.status_code in [400, 415]
def test_wrong_content_type(self, valid_signal):
"""Should reject wrong content type."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
data=str(valid_signal),
headers={"Content-Type": "text/plain"},
timeout=10
)
assert response.status_code in [400, 415]
def test_empty_body(self):
"""Should reject empty request body."""
response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json={},
timeout=10
)
assert response.status_code == 400
# ── Smoke Test ────────────────────────────────────────────────────────────────
@requires_api
class TestApiSmoke:
"""Smoke tests to verify API is functioning."""
def test_health_check_via_incidents(self):
"""API should respond to basic requests."""
response = requests.get(f"{API_BASE_URL}/incidents", timeout=10)
assert response.status_code == 200
def test_full_workflow(self, valid_signal):
"""Test complete ingest -> retrieve workflow."""
# 1. Ingest a signal
ingest_response = requests.post(
f"{API_BASE_URL}/ingest-signal",
json=valid_signal,
timeout=10
)
assert ingest_response.status_code == 201
# 2. Retrieve incidents (signal may not create incident immediately)
incidents_response = requests.get(
f"{API_BASE_URL}/incidents",
timeout=10
)
assert incidents_response.status_code == 200
assert isinstance(incidents_response.json(), list)