"""Tests for cursor-based pagination on /analyze/batch GET and /jobs endpoints.""" from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, Mock, patch import pytest from fastapi.testclient import TestClient from SPARC.api import app from SPARC.auth import create_access_token @pytest.fixture def client(): """Create test client.""" return TestClient(app) @pytest.fixture(autouse=True) def mock_auth_db(): """Mock the auth DB so JWT token validation succeeds without a real database.""" db = MagicMock() db.get_user_by_id.return_value = { "id": 1, "email": "user@test.com", "role": "user", "created_at": datetime(2025, 1, 1, tzinfo=timezone.utc), } with patch("SPARC.api.get_db_client", return_value=db), \ patch("SPARC.auth.get_db_client", return_value=db): yield db def _auth_header(): """Create a Bearer auth header for a regular user.""" token = create_access_token(1, "user@test.com", "user") return {"Authorization": f"Bearer {token}"} def _make_analysis_row(id_: int, minutes_ago: int = 0, company: str = "nvidia"): """Create a fake analysis row dict.""" ts = datetime.now() - timedelta(minutes=minutes_ago) return { "id": id_, "company_name": company, "analysis_type": "patent_portfolio", "model": "openai/gpt-4o", "response": f"Analysis for {company}", "timestamp": ts, } def _make_job_row(job_id: str, minutes_ago: int = 0, status: str = "completed"): """Create a fake job row dict.""" ts = datetime.now() - timedelta(minutes=minutes_ago) return { "job_id": job_id, "status": status, "progress": 100 if status == "completed" else 0, "total_companies": 1, "completed_companies": 1 if status == "completed" else 0, "result": None, "error": None, "created_at": ts, } class TestAnalyzeBatchGetPagination: """Test cursor-based pagination on GET /analyze/batch.""" @patch("SPARC.api._get_job_db") def test_returns_items_and_no_cursor_when_less_than_limit(self, mock_get_db, client): """When fewer results than limit, next_cursor should be null.""" db = Mock() db.list_analyses.return_value = [ _make_analysis_row(1, minutes_ago=10), _make_analysis_row(2, minutes_ago=20), ] mock_get_db.return_value = db response = client.get("/analyze/batch?limit=10", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert len(data["items"]) == 2 assert data["next_cursor"] is None @patch("SPARC.api._get_job_db") def test_returns_cursor_when_more_results_exist(self, mock_get_db, client): """When more results exist than limit, next_cursor should be set.""" db = Mock() # Return limit+1 rows to simulate more data rows = [_make_analysis_row(i, minutes_ago=i) for i in range(4)] db.list_analyses.return_value = rows mock_get_db.return_value = db response = client.get("/analyze/batch?limit=3", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert len(data["items"]) == 3 assert data["next_cursor"] is not None @patch("SPARC.api._get_job_db") def test_cursor_passed_to_db(self, mock_get_db, client): """The cursor query param should be forwarded to the database layer.""" db = Mock() db.list_analyses.return_value = [] mock_get_db.return_value = db client.get("/analyze/batch?cursor=2025-01-01T00:00:00|42", headers=_auth_header()) db.list_analyses.assert_called_once() call_kwargs = db.list_analyses.call_args cursor_val = ( call_kwargs.kwargs.get("cursor") or (call_kwargs[1].get("cursor") if len(call_kwargs) > 1 else None) ) assert cursor_val == "2025-01-01T00:00:00|42" @patch("SPARC.api._get_job_db") def test_default_limit_is_50(self, mock_get_db, client): """Default limit should be 50.""" db = Mock() db.list_analyses.return_value = [] mock_get_db.return_value = db client.get("/analyze/batch", headers=_auth_header()) call_kwargs = db.list_analyses.call_args # The endpoint requests limit+1 from DB, so 51 assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51 def test_limit_over_200_rejected(self, client): """Limit > 200 should be rejected with 422.""" response = client.get("/analyze/batch?limit=201", headers=_auth_header()) assert response.status_code == 422 def test_limit_zero_rejected(self, client): """Limit < 1 should be rejected with 422.""" response = client.get("/analyze/batch?limit=0", headers=_auth_header()) assert response.status_code == 422 @patch("SPARC.api._get_job_db") def test_company_name_filter(self, mock_get_db, client): """The company_name filter should be forwarded to the database.""" db = Mock() db.list_analyses.return_value = [] mock_get_db.return_value = db client.get("/analyze/batch?company_name=intel", headers=_auth_header()) call_kwargs = db.list_analyses.call_args company_val = ( call_kwargs.kwargs.get("company_name") or (call_kwargs[1].get("company_name") if len(call_kwargs) > 1 else None) ) assert company_val == "intel" @patch("SPARC.api._get_job_db") def test_empty_result_set(self, mock_get_db, client): """Empty result set returns empty items and null cursor.""" db = Mock() db.list_analyses.return_value = [] mock_get_db.return_value = db response = client.get("/analyze/batch", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert data["items"] == [] assert data["next_cursor"] is None @patch("SPARC.api._get_job_db") def test_subsequent_page_uses_cursor(self, mock_get_db, client): """Passing a cursor should retrieve the next page of results.""" db = Mock() db.list_analyses.return_value = [_make_analysis_row(99, minutes_ago=100)] mock_get_db.return_value = db cursor = "2025-06-01T12:00:00|50" response = client.get(f"/analyze/batch?limit=10&cursor={cursor}", headers=_auth_header()) assert response.status_code == 200 data = response.json() # Only one item returned → last page → no next cursor assert len(data["items"]) == 1 assert data["next_cursor"] is None class TestJobsPagination: """Test cursor-based pagination on GET /jobs.""" @patch("SPARC.api._get_job_db") def test_default_limit_is_50(self, mock_get_db, client): """Default limit should now be 50.""" db = Mock() db.list_jobs.return_value = [] mock_get_db.return_value = db client.get("/jobs", headers=_auth_header()) call_kwargs = db.list_jobs.call_args # Endpoint requests limit+1 from DB, so 51 assert 51 in call_kwargs.args or call_kwargs.kwargs.get("limit") == 51 def test_limit_over_200_rejected(self, client): """Limit > 200 should be rejected with 422.""" response = client.get("/jobs?limit=201", headers=_auth_header()) assert response.status_code == 422 def test_limit_zero_rejected(self, client): """Limit < 1 should be rejected with 422.""" response = client.get("/jobs?limit=0", headers=_auth_header()) assert response.status_code == 422 @patch("SPARC.api._get_job_db") def test_limit_200_accepted(self, mock_get_db, client): """Limit of exactly 200 should be accepted.""" db = Mock() db.list_jobs.return_value = [] mock_get_db.return_value = db response = client.get("/jobs?limit=200", headers=_auth_header()) assert response.status_code == 200 @patch("SPARC.api._get_job_db") def test_first_page_returns_items_and_cursor(self, mock_get_db, client): """First page with more results than limit should return next_cursor.""" db = Mock() # Return limit+1 rows to simulate more data available rows = [_make_job_row(f"job-{i}", minutes_ago=i) for i in range(4)] db.list_jobs.return_value = rows mock_get_db.return_value = db response = client.get("/jobs?limit=3", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert len(data["items"]) == 3 assert data["next_cursor"] is not None @patch("SPARC.api._get_job_db") def test_last_page_returns_no_cursor(self, mock_get_db, client): """When fewer results than limit, next_cursor should be null (last page).""" db = Mock() rows = [ _make_job_row("job-a", minutes_ago=5), _make_job_row("job-b", minutes_ago=10), ] db.list_jobs.return_value = rows mock_get_db.return_value = db response = client.get("/jobs?limit=10", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert len(data["items"]) == 2 assert data["next_cursor"] is None @patch("SPARC.api._get_job_db") def test_cursor_forwarded_to_db(self, mock_get_db, client): """The cursor query param should be forwarded to the database layer.""" db = Mock() db.list_jobs.return_value = [] mock_get_db.return_value = db client.get("/jobs?cursor=2025-01-01T00:00:00|job-99", headers=_auth_header()) db.list_jobs.assert_called_once() call_kwargs = db.list_jobs.call_args cursor_val = ( call_kwargs.kwargs.get("cursor") or (call_kwargs[1].get("cursor") if len(call_kwargs) > 1 else None) ) assert cursor_val == "2025-01-01T00:00:00|job-99" @patch("SPARC.api._get_job_db") def test_empty_result_set(self, mock_get_db, client): """Empty result set returns empty items list and null next_cursor.""" db = Mock() db.list_jobs.return_value = [] mock_get_db.return_value = db response = client.get("/jobs", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert data["items"] == [] assert data["next_cursor"] is None @patch("SPARC.api._get_job_db") def test_status_filter_forwarded(self, mock_get_db, client): """The status filter should be forwarded to the database layer.""" db = Mock() db.list_jobs.return_value = [] mock_get_db.return_value = db client.get("/jobs?status=completed", headers=_auth_header()) db.list_jobs.assert_called_once() call_kwargs = db.list_jobs.call_args status_val = ( call_kwargs.kwargs.get("status") or (call_kwargs[1].get("status") if len(call_kwargs) > 1 else None) ) assert status_val == "completed" @patch("SPARC.api._get_job_db") def test_response_has_paginated_shape(self, mock_get_db, client): """Response must have 'items' and 'next_cursor' fields (paginated shape).""" db = Mock() db.list_jobs.return_value = [_make_job_row("job-x")] mock_get_db.return_value = db response = client.get("/jobs?limit=10", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert "items" in data assert "next_cursor" in data @patch("SPARC.api._get_job_db") def test_subsequent_page_uses_cursor(self, mock_get_db, client): """Passing cursor returns the next page; last page has null next_cursor.""" db = Mock() db.list_jobs.return_value = [_make_job_row("job-last", minutes_ago=200)] mock_get_db.return_value = db cursor = "2025-06-01T12:00:00|job-50" response = client.get(f"/jobs?limit=10&cursor={cursor}", headers=_auth_header()) assert response.status_code == 200 data = response.json() assert len(data["items"]) == 1 assert data["next_cursor"] is None