/ tests / integration / test_full_pipeline.py
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]