test_full_pipeline.py
1 """ 2 Full pipeline integration tests for the curator validation endpoint. 3 4 Tests the complete /validate/kind-2003 pipeline from event submission through 5 rules evaluation to decision output, including error cases and concurrent requests. 6 """ 7 8 from typing import Any 9 10 import pytest 11 12 13 def create_valid_nip35_event() -> dict[str, Any]: 14 """Create a valid NIP-35 event that passes Pydantic validation.""" 15 return { 16 "id": "0000000000000000000000000000000000000000000000000000000000000001", 17 "pubkey": "0000000000000000000000000000000000000000000000000000000000000002", 18 "created_at": 1234567890, 19 "kind": 2003, 20 "tags": [ 21 ["title", "Test Torrent"], 22 ["x", "abc123abc123abc123abc123abc123abc123abc1"], # 40-char hex btih 23 ["file", "test.mkv", "1073741824"], # name, size in bytes 24 ], 25 "content": "Test torrent description", 26 "sig": "0" * 128, # 128-char hex signature (64 bytes) 27 } 28 29 30 def create_invalid_event_missing_infohash() -> dict[str, Any]: 31 """Create event missing required 'x' tag (infohash).""" 32 event = create_valid_nip35_event() 33 event["tags"] = [tag for tag in event["tags"] if tag[0] != "x"] 34 return event 35 36 37 def create_event_missing_title() -> dict[str, Any]: 38 """Create event missing required 'title' tag.""" 39 event = create_valid_nip35_event() 40 event["tags"] = [tag for tag in event["tags"] if tag[0] != "title"] 41 return event 42 43 44 @pytest.mark.integration 45 @pytest.mark.slow 46 def test_full_pipeline_valid_event(): 47 """ 48 Test complete /validate pipeline with well-formed torrent event. 49 50 Flow: POST event -> orchestrator -> rules execution -> decision response 51 """ 52 try: 53 from fastapi.testclient import TestClient 54 55 from main import app 56 except Exception as e: 57 pytest.skip(f"Failed to import app (venv generation issue?): {e}") 58 59 client = TestClient(app) 60 event = create_valid_nip35_event() 61 62 response = client.put("/validate/kind-2003", json=event) 63 64 # Verify HTTP 200 response 65 assert response.status_code == 200 66 67 data = response.json() 68 69 # Verify response contains decision field 70 assert "passed" in data 71 assert isinstance(data["passed"], bool) 72 73 # Verify response contains rules breakdown 74 assert "rules" in data 75 assert isinstance(data["rules"], dict) 76 77 # Verify each rule result has correct structure 78 for rule_id, rule_result in data["rules"].items(): 79 assert isinstance(rule_result, dict) 80 81 # Deterministic rules (D-*) should have "passed" field 82 # Probabilistic rules (P-*) should have "passed" and "score" fields 83 if rule_id.startswith("D-"): 84 assert "passed" in rule_result 85 assert isinstance(rule_result["passed"], bool) 86 elif rule_id.startswith("P-"): 87 assert "passed" in rule_result 88 assert "score" in rule_result 89 assert isinstance(rule_result["score"], (int, float)) 90 assert 0.0 <= rule_result["score"] <= 1.0 91 92 93 @pytest.mark.integration 94 def test_full_pipeline_invalid_event_missing_fields(): 95 """ 96 Test /validate with invalid event (missing required fields). 97 98 Expected: 422 Unprocessable Entity with error message identifying missing fields. 99 """ 100 try: 101 from fastapi.testclient import TestClient 102 103 from main import app 104 except Exception as e: 105 pytest.skip(f"Failed to import app: {e}") 106 107 client = TestClient(app) 108 event = create_event_missing_title() 109 110 response = client.put("/validate/kind-2003", json=event) 111 112 # Verify appropriate error response 113 assert response.status_code == 422 114 115 # Verify error message identifies missing fields 116 error_detail = response.json().get("detail", "") 117 assert "title" in str(error_detail).lower() or "missing" in str(error_detail).lower() 118 119 120 @pytest.mark.integration 121 def test_full_pipeline_empty_body(): 122 """ 123 Test /validate with empty request body. 124 125 Expected: 422 Unprocessable Entity. 126 """ 127 try: 128 from fastapi.testclient import TestClient 129 130 from main import app 131 except Exception as e: 132 pytest.skip(f"Failed to import app: {e}") 133 134 client = TestClient(app) 135 136 response = client.put("/validate/kind-2003", json={}) 137 138 # Verify 422 response 139 assert response.status_code == 422 140 141 142 @pytest.mark.integration 143 def test_health_endpoint(): 144 """ 145 Test /health endpoint returns healthy status. 146 """ 147 try: 148 from fastapi.testclient import TestClient 149 150 from main import app 151 except Exception as e: 152 pytest.skip(f"Failed to import app: {e}") 153 154 client = TestClient(app) 155 156 response = client.get("/health") 157 158 # Verify 200 response with status 159 assert response.status_code == 200 160 data = response.json() 161 assert "healthy" in data 162 assert data["healthy"] is True 163 164 165 @pytest.mark.integration 166 def test_root_endpoint(): 167 """ 168 Test root / endpoint healthcheck. 169 """ 170 try: 171 from fastapi.testclient import TestClient 172 173 from main import app 174 except Exception as e: 175 pytest.skip(f"Failed to import app: {e}") 176 177 client = TestClient(app) 178 179 response = client.get("/") 180 181 assert response.status_code == 200 182 data = response.json() 183 assert "healthy" in data 184 assert data["healthy"] is True 185 186 187 @pytest.mark.integration 188 @pytest.mark.slow 189 def test_concurrent_requests(): 190 """ 191 Test /validate handles concurrent requests without errors. 192 193 Send 5 simultaneous requests and verify all return valid responses. 194 """ 195 try: 196 from fastapi.testclient import TestClient 197 198 from main import app 199 except Exception as e: 200 pytest.skip(f"Failed to import app: {e}") 201 202 client = TestClient(app) 203 204 # Create 5 different events to simulate concurrent validation 205 events = [create_valid_nip35_event() for _ in range(5)] 206 207 # Modify each event slightly to make them unique 208 for i, event in enumerate(events): 209 event["id"] = str(i) * 64 210 211 # Send concurrent requests (TestClient is synchronous, but this tests handler concurrency) 212 responses = [] 213 for event in events: 214 response = client.put("/validate/kind-2003", json=event) 215 responses.append(response) 216 217 # Verify all return valid responses (no internal server errors) 218 for i, response in enumerate(responses): 219 assert response.status_code == 200, f"Request {i} failed with status {response.status_code}" 220 data = response.json() 221 assert "passed" in data 222 assert "rules" in data 223 224 225 @pytest.mark.integration 226 @pytest.mark.slow 227 def test_specific_rule_rejection(): 228 """ 229 Test event that triggers D-rule rejection (missing file list). 230 231 This tests that the orchestrator correctly propagates rule failures. 232 """ 233 try: 234 from fastapi.testclient import TestClient 235 236 from main import app 237 except Exception as e: 238 pytest.skip(f"Failed to import app: {e}") 239 240 client = TestClient(app) 241 event = create_invalid_event_missing_infohash() 242 243 response = client.put("/validate/kind-2003", json=event) 244 245 # Event should be rejected by Pydantic validation (422) 246 # OR pass Pydantic but fail in rules (200 with passed=false) 247 if response.status_code == 422: 248 # Pydantic rejected it early 249 pytest.skip("Event rejected by Pydantic validation before rules") 250 else: 251 assert response.status_code == 200 252 data = response.json() 253 254 # Without infohash, D-SCHEMA-03 should fail 255 if "D-SCHEMA-03" in data["rules"]: 256 assert data["rules"]["D-SCHEMA-03"]["passed"] is False 257 258 259 @pytest.mark.integration 260 @pytest.mark.slow 261 def test_well_formed_standard_torrent_passes(): 262 """ 263 Test well-formed standard torrent passes all rules. 264 265 This is the "happy path" integration test. 266 """ 267 try: 268 from fastapi.testclient import TestClient 269 270 from main import app 271 except Exception as e: 272 pytest.skip(f"Failed to import app: {e}") 273 274 client = TestClient(app) 275 event = create_valid_nip35_event() 276 277 response = client.put("/validate/kind-2003", json=event) 278 279 assert response.status_code == 200 280 data = response.json() 281 282 # Well-formed event should pass (if rules are lenient enough) 283 # At minimum, should not crash or return errors 284 assert "passed" in data 285 assert "rules" in data 286 287 # Verify no rule returned error structure 288 for rule_id, rule_result in data["rules"].items(): 289 assert isinstance(rule_result, dict) 290 # Should not have "error" field if rule executed successfully 291 if "error" in rule_result: 292 pytest.fail(f"Rule {rule_id} returned error: {rule_result['error']}") 293 294 295 @pytest.mark.integration 296 def test_wrong_http_method(): 297 """ 298 Test /validate endpoint rejects wrong HTTP method. 299 300 Endpoint expects PUT, not POST or GET. 301 """ 302 try: 303 from fastapi.testclient import TestClient 304 305 from main import app 306 except Exception as e: 307 pytest.skip(f"Failed to import app: {e}") 308 309 client = TestClient(app) 310 event = create_valid_nip35_event() 311 312 # Try POST instead of PUT 313 response = client.post("/validate/kind-2003", json=event) 314 315 # Should return 405 Method Not Allowed 316 assert response.status_code == 405 317 318 319 @pytest.mark.integration 320 def test_malformed_json(): 321 """ 322 Test /validate handles malformed JSON gracefully. 323 """ 324 try: 325 from fastapi.testclient import TestClient 326 327 from main import app 328 except Exception as e: 329 pytest.skip(f"Failed to import app: {e}") 330 331 client = TestClient(app) 332 333 # Send invalid JSON structure 334 response = client.put( 335 "/validate/kind-2003", 336 data="{invalid json}", 337 headers={"Content-Type": "application/json"} 338 ) 339 340 # Should return 422 Unprocessable Entity 341 assert response.status_code == 422 342 343 344 @pytest.mark.integration 345 def test_extra_fields_ignored(): 346 """ 347 Test that extra fields in event are handled gracefully. 348 """ 349 try: 350 from fastapi.testclient import TestClient 351 352 from main import app 353 except Exception as e: 354 pytest.skip(f"Failed to import app: {e}") 355 356 client = TestClient(app) 357 event = create_valid_nip35_event() 358 359 # Add extra field not in Pydantic model 360 event["extra_field"] = "should be ignored" 361 362 response = client.put("/validate/kind-2003", json=event) 363 364 # Pydantic might reject or ignore depending on config 365 # Either way, should not crash 366 assert response.status_code in [200, 422]