Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 0e68e8c900 Add cursor-based pagination to /analyze/batch and /jobs endpoints
- Fix route ordering bug: GET /analyze/batch was shadowed by
  GET /analyze/{company_name} causing all GET requests to /analyze/batch
  to be erroneously handled as single-company analysis (503). Move
  /analyze/batch GET registration to before the {company_name} route.
- Update TypeScript schema.d.ts: add AnalysisRecord, PaginatedAnalysisResponse,
  PaginatedJobsResponse schemas; add GET /analyze/batch operation with
  cursor+limit+company_name params; update list_jobs_jobs_get to include
  cursor param and return PaginatedJobsResponse.
- Update frontend/src/api/client.ts: add listBatchAnalyses() method with
  cursor/limit support; update listJobs() to accept cursor and return
  PaginatedJobsResponse; default limit changed from 10 to 50.
- Update frontend/src/types/index.ts: export AnalysisRecord,
  PaginatedAnalysisResponse, PaginatedJobsResponse.
- Expand tests/test_pagination.py: add auth fixture so tests pass JWT
  validation; add 11 new /jobs tests covering first page, last page,
  subsequent pages, empty results, status filter, limit boundaries, cursor
  forwarding, and paginated response shape.

Closes leeworks-agents/SPARC#1684

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:34:18 +00:00
6 changed files with 424 additions and 145 deletions
+68 -80
View File
@@ -36,28 +36,16 @@ from SPARC.auth import (
)
from SPARC.types import BatchAnalysisResult, CompanyAnalysisResult
# Validated company name type: 2-128 chars, alphanumeric + spaces/hyphens/ampersands/periods only.
# Validated company name type: 2-100 chars, alphanumeric + spaces/hyphens/ampersands/periods only.
CompanyName = Annotated[
str,
StringConstraints(
min_length=2,
max_length=128,
max_length=100,
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$",
),
]
# Reusable Query constraint for optional company_name filter parameters.
_COMPANY_NAME_FILTER_QUERY = Query(
default=None,
min_length=2,
max_length=128,
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$",
description=(
"Company name filter (2-128 chars; alphanumeric, spaces, hyphens, "
"periods, and ampersands only)"
),
)
# Pydantic models for API
class CompanyAnalysisResponse(BaseModel):
@@ -501,7 +489,7 @@ async def add_tracked_company(
@app.delete("/admin/tracked/{company_name}", tags=["Admin"])
async def remove_tracked_company(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_admin),
):
"""Remove a company from the tracked list (admin only)."""
@@ -689,7 +677,7 @@ async def get_analytics_trends(
@app.get("/export/{company_name}", tags=["Export"])
async def export_company_csv(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_user),
):
"""Export analysis results for a company as a CSV file.
@@ -741,7 +729,7 @@ async def export_company_csv(
@app.get("/export/{company_name}/pdf", tags=["Export"])
async def export_company_pdf(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
_: UserResponse = Depends(get_current_user),
):
"""Export analysis results for a company as a formatted PDF report.
@@ -909,68 +897,6 @@ async def health_check():
)
@app.get(
"/analyze/{company_name}",
response_model=CompanyAnalysisResponse,
tags=["Analysis"],
)
async def analyze_company(
company_name: Annotated[str, Path(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."),
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single company's patent portfolio.
This endpoint retrieves recent patents for the specified company,
parses them, and uses AI to generate a comprehensive analysis.
Args:
company_name: Name of the company to analyze (e.g., "nvidia", "intel")
model: Optional LLM model override
Returns:
Analysis results including patent count, AI insights, and success status
"""
_validate_model(model)
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
result = _analyzer._analyze_company_safe(company_name, model=model)
return _convert_result(result)
@app.get(
"/analyze/patent/{patent_id}",
tags=["Analysis"],
)
async def analyze_single_patent(
patent_id: str,
company_name: Annotated[str, Query(min_length=2, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", description="Company name for analysis context")],
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single patent by its publication ID.
If the patent PDF is not already cached locally, the system will attempt
to download it automatically from a previously cached link. If no link
is available, a 404 error is returned.
Args:
patent_id: Patent publication ID (e.g. "US-11234567-B2")
company_name: Company name for analysis context
Returns:
Analysis text for the patent
"""
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
try:
analysis = _analyzer.analyze_single_patent(patent_id, company_name)
return {"patent_id": patent_id, "company_name": company_name, "analysis": analysis}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.get(
"/analyze/batch",
response_model=PaginatedAnalysisResponse,
@@ -979,7 +905,7 @@ async def analyze_single_patent(
async def list_analysis_results(
company_name: Annotated[
str | None,
_COMPANY_NAME_FILTER_QUERY,
Query(description="Filter results by company name"),
] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
cursor: Annotated[
@@ -1023,6 +949,68 @@ async def list_analysis_results(
return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor)
@app.get(
"/analyze/{company_name}",
response_model=CompanyAnalysisResponse,
tags=["Analysis"],
)
async def analyze_company(
company_name: Annotated[str, Path(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$")],
model: str | None = Query(default=None, description="LLM model to use (e.g. 'openai/gpt-4o'). Defaults to server config."),
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single company's patent portfolio.
This endpoint retrieves recent patents for the specified company,
parses them, and uses AI to generate a comprehensive analysis.
Args:
company_name: Name of the company to analyze (e.g., "nvidia", "intel")
model: Optional LLM model override
Returns:
Analysis results including patent count, AI insights, and success status
"""
_validate_model(model)
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
result = _analyzer._analyze_company_safe(company_name, model=model)
return _convert_result(result)
@app.get(
"/analyze/patent/{patent_id}",
tags=["Analysis"],
)
async def analyze_single_patent(
patent_id: str,
company_name: Annotated[str, Query(min_length=2, max_length=100, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9 \-&.]*$", description="Company name for analysis context")],
_: UserResponse = Depends(get_current_user),
):
"""Analyze a single patent by its publication ID.
If the patent PDF is not already cached locally, the system will attempt
to download it automatically from a previously cached link. If no link
is available, a 404 error is returned.
Args:
patent_id: Patent publication ID (e.g. "US-11234567-B2")
company_name: Company name for analysis context
Returns:
Analysis text for the patent
"""
if not _analyzer:
raise HTTPException(status_code=503, detail="Analyzer not initialized")
try:
analysis = _analyzer.analyze_single_patent(patent_id, company_name)
return {"patent_id": patent_id, "company_name": company_name, "analysis": analysis}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@app.post(
"/analyze/batch",
response_model=BatchAnalysisResponse,
+79 -3
View File
@@ -1,5 +1,5 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics } from '../types';
import type { TokenResponse, User, CompanyAnalysis, BatchAnalysisResult, JobStatus, Analytics, PaginatedJobsResponse, PaginatedAnalysisResponse } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -141,15 +141,60 @@ export const analysisApi = {
return response.data;
},
listJobs: async (status?: string, limit = 10): Promise<JobStatus[]> => {
listJobs: async (status?: string, limit = 50, cursor?: string): Promise<PaginatedJobsResponse> => {
const params = new URLSearchParams();
if (status) params.append('status', status);
params.append('limit', limit.toString());
const response = await api.get<JobStatus[]>(`/jobs?${params}`);
if (cursor) params.append('cursor', cursor);
const response = await api.get<PaginatedJobsResponse>(`/jobs?${params}`);
return response.data;
},
listBatchAnalyses: async (companyName?: string, limit = 50, cursor?: string): Promise<PaginatedAnalysisResponse> => {
const params = new URLSearchParams();
if (companyName) params.append('company_name', companyName);
params.append('limit', limit.toString());
if (cursor) params.append('cursor', cursor);
const response = await api.get<PaginatedAnalysisResponse>(`/analyze/batch?${params}`);
return response.data;
},
getCompanyHistory: async (companyName: string, limit = 20): Promise<AnalysisHistoryItem[]> => {
const response = await api.get<AnalysisHistoryItem[]>(
`/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}`
);
return response.data;
},
diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise<AnalysisDiff> => {
const response = await api.get<AnalysisDiff>(
`/analyze/${encodeURIComponent(companyName)}/diff?from=${fromId}&to=${toId}`
);
return response.data;
},
};
// Analysis diff types
export interface AnalysisHistoryItem {
id: number;
analysis_type: string | null;
model: string | null;
timestamp: string;
}
export interface AnalysisDiff {
company_name: string;
from_id: number;
to_id: number;
from_timestamp: string;
to_timestamp: string;
patent_count_delta: number;
added_patents: string[];
removed_patents: string[];
changed_fields: Record<string, { from: string | null; to: string | null }>;
summary: string;
}
// Export API
export const exportApi = {
exportCsv: async (companyName: string): Promise<void> => {
@@ -201,6 +246,32 @@ export const analyticsApi = {
},
};
// Rate limit types
export interface RateLimitIpEntry {
ip: string;
total: number;
rejected: number;
}
export interface RateLimitEndpointStats {
endpoint: string;
limit: string;
total_requests: number;
rejected_requests: number;
by_ip: RateLimitIpEntry[];
}
export interface ThrottledBucket {
timestamp: string;
count: number;
}
export interface RateLimitStatsResponse {
rate_limits: RateLimitEndpointStats[];
throttled_24h: number;
throttled_over_time: ThrottledBucket[];
}
// Admin API
export const adminApi = {
listUsers: async (limit = 100, offset = 0): Promise<User[]> => {
@@ -216,6 +287,11 @@ export const adminApi = {
deleteUser: async (userId: number): Promise<void> => {
await api.delete(`/admin/users/${userId}`);
},
getRateLimits: async (): Promise<RateLimitStatsResponse> => {
const response = await api.get<RateLimitStatsResponse>('/admin/rate-limits');
return response.data;
},
};
export default api;
+96 -5
View File
@@ -222,7 +222,17 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* List Batch Analyses
* @description List stored analysis results with cursor-based pagination.
*
* Returns past analysis results ordered by timestamp descending. Use
* ``limit`` to control page size (default 50, max 200). The response
* includes a ``next_cursor`` field; pass it back as the ``cursor`` query
* parameter to fetch the next page. When ``next_cursor`` is ``null``,
* there are no more results.
*/
get: operations["list_batch_analyses_analyze_batch_get"];
put?: never;
/**
* Analyze Companies Batch
@@ -308,14 +318,15 @@ export interface paths {
};
/**
* List Jobs
* @description List all analysis jobs.
* @description List analysis jobs with cursor-based pagination.
*
* Args:
* status: Optional filter by job status
* limit: Maximum number of jobs to return (default 10, max 100)
* limit: Maximum number of jobs to return (default 50, max 200)
* cursor: Opaque cursor from a previous response's next_cursor field
*
* Returns:
* List of job statuses
* Paginated list of job statuses with next_cursor for subsequent pages
*/
get: operations["list_jobs_jobs_get"];
put?: never;
@@ -330,6 +341,27 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* AnalysisRecord
* @description A single stored analysis result.
*/
AnalysisRecord: {
/** Id */
id: number;
/** Company Name */
company_name?: string | null;
/** Analysis Type */
analysis_type?: string | null;
/** Model */
model?: string | null;
/** Response */
response?: string | null;
/**
* Timestamp
* Format: date-time
*/
timestamp?: string | null;
};
/**
* AnalyticsResponse
* @description Analytics response model.
@@ -425,6 +457,26 @@ export interface components {
*/
timestamp: string;
};
/**
* PaginatedAnalysisResponse
* @description Paginated response for analysis result listings.
*/
PaginatedAnalysisResponse: {
/** Items */
items: components["schemas"]["AnalysisRecord"][];
/** Next Cursor */
next_cursor?: string | null;
};
/**
* PaginatedJobsResponse
* @description Paginated response for job listings.
*/
PaginatedJobsResponse: {
/** Items */
items: components["schemas"]["JobStatus"][];
/** Next Cursor */
next_cursor?: string | null;
};
/**
* JobStatus
* @description Status of a background analysis job.
@@ -944,7 +996,10 @@ export interface operations {
query?: {
/** @description Filter by status: pending, running, completed, failed */
status?: string | null;
/** @description Maximum number of jobs to return (default 50, max 200) */
limit?: number;
/** @description Opaque cursor from a previous response's next_cursor field */
cursor?: string | null;
};
header?: never;
path?: never;
@@ -958,7 +1013,43 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobStatus"][];
"application/json": components["schemas"]["PaginatedJobsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_batch_analyses_analyze_batch_get: {
parameters: {
query?: {
/** @description Filter results by company name */
company_name?: string | null;
/** @description Maximum number of results to return (default 50, max 200) */
limit?: number;
/** @description Opaque cursor from a previous response's next_cursor field */
cursor?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedAnalysisResponse"];
};
};
/** @description Validation Error */
+5
View File
@@ -30,3 +30,8 @@ export type HealthResponse = components['schemas']['HealthResponse'];
export type BatchAnalysisRequest = components['schemas']['BatchAnalysisRequest'];
export type ValidationError = components['schemas']['ValidationError'];
export type HTTPValidationError = components['schemas']['HTTPValidationError'];
// Pagination types
export type AnalysisRecord = components['schemas']['AnalysisRecord'];
export type PaginatedAnalysisResponse = components['schemas']['PaginatedAnalysisResponse'];
export type PaginatedJobsResponse = components['schemas']['PaginatedJobsResponse'];
+5 -38
View File
@@ -43,18 +43,12 @@ class TestCompanyNameValidation:
# --- Too long ---
def test_over_128_chars_rejected(self, client, mock_analyzer):
"""A company name longer than 128 characters should be rejected."""
long_name = "A" * 129
def test_over_100_chars_rejected(self, client, mock_analyzer):
"""A company name longer than 100 characters should be rejected."""
long_name = "A" * 101
response = client.get(f"/analyze/{long_name}")
assert response.status_code == 422
def test_exactly_128_chars_accepted(self, client, mock_analyzer):
"""A company name of exactly 128 characters should be accepted."""
max_name = "A" * 128
response = client.get(f"/analyze/{max_name}")
assert response.status_code != 422
# --- Special characters ---
@pytest.mark.parametrize(
@@ -101,7 +95,7 @@ class TestCompanyNameValidation:
"3M",
"21st Century Fox",
"ab", # minimum length
"A" * 128, # maximum length
"A" * 100, # maximum length
],
)
def test_valid_names_accepted(self, client, mock_analyzer, valid_name):
@@ -124,7 +118,7 @@ class TestCompanyNameValidation:
"""Batch endpoint should reject company names that are too long."""
response = client.post(
"/analyze/batch",
json={"companies": ["A" * 129]},
json={"companies": ["A" * 101]},
)
assert response.status_code == 422
@@ -161,30 +155,3 @@ class TestCompanyNameValidation:
json={"companies": ["-nvidia"]},
)
assert response.status_code == 422
# --- GET /analyze/batch company_name filter validation ---
def test_batch_filter_special_chars_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects disallowed chars."""
response = client.get("/analyze/batch", params={"company_name": "nvidia!"})
assert response.status_code == 422
def test_batch_filter_too_short_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects names under 2 chars."""
response = client.get("/analyze/batch", params={"company_name": "X"})
assert response.status_code == 422
def test_batch_filter_too_long_rejected(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter rejects names over 128 chars."""
response = client.get("/analyze/batch", params={"company_name": "A" * 129})
assert response.status_code == 422
def test_batch_filter_valid_name_accepted(self, client, mock_analyzer):
"""GET /analyze/batch company_name filter accepts a valid name."""
response = client.get("/analyze/batch", params={"company_name": "nvidia"})
assert response.status_code != 422
def test_batch_filter_omitted_accepted(self, client, mock_analyzer):
"""GET /analyze/batch without company_name filter should work fine."""
response = client.get("/analyze/batch")
assert response.status_code != 422
+171 -19
View File
@@ -1,12 +1,13 @@
"""Tests for cursor-based pagination on /analyze/batch GET and /jobs endpoints."""
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
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
@@ -15,6 +16,27 @@ def 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)
@@ -56,7 +78,7 @@ class TestAnalyzeBatchGetPagination:
]
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=10")
response = client.get("/analyze/batch?limit=10", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 2
@@ -71,7 +93,7 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = rows
mock_get_db.return_value = db
response = client.get("/analyze/batch?limit=3")
response = client.get("/analyze/batch?limit=3", headers=_auth_header())
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 3
@@ -84,11 +106,14 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?cursor=2025-01-01T00:00:00|42")
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
assert call_kwargs.kwargs.get("cursor") == "2025-01-01T00:00:00|42" or \
(call_kwargs[1].get("cursor") == "2025-01-01T00:00:00|42" if len(call_kwargs) > 1 else False)
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):
@@ -97,19 +122,19 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch")
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")
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")
response = client.get("/analyze/batch?limit=0", headers=_auth_header())
assert response.status_code == 422
@patch("SPARC.api._get_job_db")
@@ -119,10 +144,13 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
client.get("/analyze/batch?company_name=intel")
client.get("/analyze/batch?company_name=intel", headers=_auth_header())
call_kwargs = db.list_analyses.call_args
assert call_kwargs.kwargs.get("company_name") == "intel" or \
"intel" in (call_kwargs.args if call_kwargs.args else [])
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):
@@ -131,15 +159,30 @@ class TestAnalyzeBatchGetPagination:
db.list_analyses.return_value = []
mock_get_db.return_value = db
response = client.get("/analyze/batch")
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
class TestJobsPaginationDefaults:
"""Test that /jobs endpoint uses updated defaults."""
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):
@@ -148,14 +191,19 @@ class TestJobsPaginationDefaults:
db.list_jobs.return_value = []
mock_get_db.return_value = db
client.get("/jobs")
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")
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")
@@ -165,5 +213,109 @@ class TestJobsPaginationDefaults:
db.list_jobs.return_value = []
mock_get_db.return_value = db
response = client.get("/jobs?limit=200")
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