diff --git a/SPARC/api.py b/SPARC/api.py index 1b29d38..c3749bb 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -897,6 +897,58 @@ async def health_check(): ) +@app.get( + "/analyze/batch", + response_model=PaginatedAnalysisResponse, + tags=["Analysis"], +) +async def list_analysis_results( + company_name: Annotated[ + str | None, + Query(description="Filter results by company name"), + ] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + cursor: Annotated[ + str | None, + Query(description="Opaque cursor from a previous response's next_cursor field"), + ] = None, + _: UserResponse = Depends(get_current_user), +): + """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. + + Args: + company_name: Optional filter by company name + limit: Maximum number of results to return (default 50, max 200) + cursor: Opaque pagination cursor from a previous response + + Returns: + Paginated list of analysis results + """ + db = _get_job_db() + rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor) + + has_next = len(rows) > limit + if has_next: + rows = rows[:limit] + + items = [AnalysisRecord(**row) for row in rows] + + next_cursor = None + if has_next and rows: + last = rows[-1] + ts = last["timestamp"] + ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts) + next_cursor = f"{ts_str}|{last['id']}" + + return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor) + + @app.get( "/analyze/{company_name}", response_model=CompanyAnalysisResponse, @@ -959,58 +1011,6 @@ async def analyze_single_patent( raise HTTPException(status_code=404, detail=str(e)) -@app.get( - "/analyze/batch", - response_model=PaginatedAnalysisResponse, - tags=["Analysis"], -) -async def list_analysis_results( - company_name: Annotated[ - str | None, - Query(description="Filter results by company name"), - ] = None, - limit: Annotated[int, Query(ge=1, le=200)] = 50, - cursor: Annotated[ - str | None, - Query(description="Opaque cursor from a previous response's next_cursor field"), - ] = None, - _: UserResponse = Depends(get_current_user), -): - """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. - - Args: - company_name: Optional filter by company name - limit: Maximum number of results to return (default 50, max 200) - cursor: Opaque pagination cursor from a previous response - - Returns: - Paginated list of analysis results - """ - db = _get_job_db() - rows = db.list_analyses(company_name=company_name, limit=limit + 1, cursor=cursor) - - has_next = len(rows) > limit - if has_next: - rows = rows[:limit] - - items = [AnalysisRecord(**row) for row in rows] - - next_cursor = None - if has_next and rows: - last = rows[-1] - ts = last["timestamp"] - ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts) - next_cursor = f"{ts_str}|{last['id']}" - - return PaginatedAnalysisResponse(items=items, next_cursor=next_cursor) - - @app.post( "/analyze/batch", response_model=BatchAnalysisResponse, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 09a4ae6..1bc9d0c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 => { + listJobs: async (status?: string, limit = 50, cursor?: string): Promise => { const params = new URLSearchParams(); if (status) params.append('status', status); params.append('limit', limit.toString()); - const response = await api.get(`/jobs?${params}`); + if (cursor) params.append('cursor', cursor); + const response = await api.get(`/jobs?${params}`); + return response.data; + }, + + listBatchAnalyses: async (companyName?: string, limit = 50, cursor?: string): Promise => { + 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(`/analyze/batch?${params}`); + return response.data; + }, + + getCompanyHistory: async (companyName: string, limit = 20): Promise => { + const response = await api.get( + `/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}` + ); + return response.data; + }, + + diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise => { + const response = await api.get( + `/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; + summary: string; +} + // Export API export const exportApi = { exportCsv: async (companyName: string): Promise => { @@ -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 => { @@ -216,6 +287,11 @@ export const adminApi = { deleteUser: async (userId: number): Promise => { await api.delete(`/admin/users/${userId}`); }, + + getRateLimits: async (): Promise => { + const response = await api.get('/admin/rate-limits'); + return response.data; + }, }; export default api; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 0c4772e..5e0df45 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; 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 */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 000f263..ae01e70 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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']; diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 01bc5b3..be8f6d5 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -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