From 739d1e3ed4b27e654f9720b1ecc11ca6db8aef74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 12:19:37 +0000 Subject: [PATCH] Add optional limit query param to GET /ingestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/ingestions (and the deprecated alias) hardcoded a 25-row cap, so older completed jobs became unreachable. Accept ?limit= with default 25 and reject values outside 1–500 with 400. Closes #28. Co-authored-by: Leonardo --- src/api/routes/ingestions.py | 25 ++++++++++++++++++++++--- tests/unit/test_api.py | 28 ++++++++++++++++++++++++++++ tests/unit/test_openapi_contract.py | 13 +++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/api/routes/ingestions.py b/src/api/routes/ingestions.py index 78954e2..bd56fda 100644 --- a/src/api/routes/ingestions.py +++ b/src/api/routes/ingestions.py @@ -14,7 +14,7 @@ from datetime import datetime, timezone from typing import Any, Literal, Optional -from fastapi import APIRouter, Header, HTTPException, Request +from fastapi import APIRouter, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, field_validator, model_validator @@ -22,6 +22,9 @@ router = APIRouter() +LIST_INGESTIONS_DEFAULT_LIMIT = 25 +LIST_INGESTIONS_MAX_LIMIT = 500 + # ── Schemas ────────────────────────────────────────────────────────────────── @@ -610,7 +613,17 @@ def create_ingestion( @router.get("", response_model=IngestionListResponse) -def list_ingestions(http_request: Request, scope: Optional[str] = None): +def list_ingestions( + http_request: Request, + scope: Optional[str] = None, + limit: int = Query( + LIST_INGESTIONS_DEFAULT_LIMIT, + description=( + "Maximum number of completed ingestions to return " + f"(1–{LIST_INGESTIONS_MAX_LIMIT}). Default {LIST_INGESTIONS_DEFAULT_LIMIT}." + ), + ), +) -> IngestionListResponse: """List recent completed ingestion jobs, newest first. Used by the web UI's ingestion picker.""" from sqlalchemy import desc, select @@ -619,6 +632,12 @@ def list_ingestions(http_request: Request, scope: Optional[str] = None): from src.db.scope_filter import filter_ingestion_jobs_by_scope from src.db.session import get_db + if limit < 1 or limit > LIST_INGESTIONS_MAX_LIMIT: + raise HTTPException( + status_code=400, + detail=f"limit must be between 1 and {LIST_INGESTIONS_MAX_LIMIT}", + ) + resolved = bind_request_scope(http_request, scope) with get_db() as db: @@ -627,7 +646,7 @@ def list_ingestions(http_request: Request, scope: Optional[str] = None): .join(Source, Source.id == IngestionJob.source_id) .where(IngestionJob.status == "completed") .order_by(desc(IngestionJob.finished_at)) - .limit(25) + .limit(limit) ) stmt = filter_ingestion_jobs_by_scope(stmt, resolved) rows = db.execute(stmt).all() diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 31f7a7c..2a28745 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -604,6 +604,34 @@ def test_returns_empty_list_when_no_ingestions(self): assert resp.status_code == 200 assert resp.json()["ingestions"] == [] + def _executed_limit(self, mock_db) -> int: + stmt = mock_db.execute.call_args[0][0] + return int(stmt._limit_clause.value) + + def test_default_limit_is_25(self): + mock_db = self._ctx_db_with_rows([]) + + with patch("src.db.session.get_db", side_effect=lambda: mock_db): + resp = client.get("/v1/ingestions") + + assert resp.status_code == 200 + assert self._executed_limit(mock_db) == 25 + + def test_custom_limit_is_passed_to_query(self): + mock_db = self._ctx_db_with_rows([]) + + with patch("src.db.session.get_db", side_effect=lambda: mock_db): + resp = client.get("/v1/ingestions", params={"limit": 100}) + + assert resp.status_code == 200 + assert self._executed_limit(mock_db) == 100 + + def test_invalid_limit_returns_400(self): + for value in (0, -1, 501): + resp = client.get("/v1/ingestions", params={"limit": value}) + assert resp.status_code == 400, value + assert "limit" in resp.json()["detail"] + # ── GET /ingestions/latest ──────────────────────────────────────────────────── diff --git a/tests/unit/test_openapi_contract.py b/tests/unit/test_openapi_contract.py index ac991b8..ae257c6 100644 --- a/tests/unit/test_openapi_contract.py +++ b/tests/unit/test_openapi_contract.py @@ -72,6 +72,19 @@ def test_ingest_request_includes_callback_url() -> None: assert "callback_url" in schema["properties"] +def test_list_ingestions_documents_limit_query_param() -> None: + paths = app.openapi()["paths"] + for path in ("/v1/ingestions", "/ingestions"): + params = paths[path]["get"].get("parameters") or [] + limit = next((p for p in params if p.get("name") == "limit"), None) + assert limit is not None, f"{path} GET must document limit" + assert limit.get("in") == "query" + assert limit.get("required") is not True + schema = limit.get("schema") or {} + assert schema.get("type") == "integer" + assert schema.get("default") == 25 + + def test_ingest_post_documents_idempotency_key_header() -> None: paths = app.openapi()["paths"] for path in ("/v1/ingestions", "/ingestions"):