From b8e3a39d70c0cf3e6e31146ffaea5c1a4b185b5f Mon Sep 17 00:00:00 2001 From: Ciprian-LocalPulse Date: Tue, 22 Sep 2026 05:43:23 +0300 Subject: [PATCH] feat: paginate persistent review history with integration coverage --- CHANGELOG.md | 2 + docs/API.md | 10 +++++ src/openlongevity/api.py | 13 ++++-- src/openlongevity/repository.py | 15 +++++-- tests/test_api.py | 12 +++++- tests/test_review_history.py | 74 +++++++++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 tests/test_review_history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a27895..92b0113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased — documentation and integrity audit +Review-event history now uses bounded cursor pagination with a default page size of fifty and a maximum of one hundred. Clients follow `next_after_id` to retrieve subsequent pages. PostgreSQL integration coverage verifies persisted review payloads, chronological insertion order, record isolation, and continued exclusion of reviewed fixtures from citation export. Review events do not yet update the evidence read model. + The documentation work begun from commit `9fddcbb` expands the academic corpus, corrects implementation claims, and standardizes attribution to CIPRIAN ȘTEFAN PLEȘCA — cercetător român independent. It preserves the author's removal of the wiki and the additional academic topics. This section describes development work, not a published v0.3.0 release. Mandatory release gates remain a separate decision under the project request and governance policy. The whitepaper now uses current evidence and multi-omics constructors, the implemented A–G mapping, and the actual navigation-score factors. It distinguishes persisted publications from synthetic evidence and graph demonstrations. The API reference now describes JSON-body PubMed ingestion, its operator key, current bounds, and title filtering. These corrections repair the explanation of existing behavior; they do not themselves repair the runtime limitations documented alongside it. diff --git a/docs/API.md b/docs/API.md index 955d5e0..5d3a0ad 100644 --- a/docs/API.md +++ b/docs/API.md @@ -83,6 +83,16 @@ Evidence grades and scores require their methodological labels. The A–G mappin Publication responses currently enforce a false synthetic flag without deriving authenticity from a verified origin model. This is a known limitation for test-seeded or otherwise manually inserted records. Clients and operators must not use that flag alone as proof that an item came from a real provider request. Correcting this requires code and schema decisions plus a regression test; documenting the limitation does not repair it. +### Bounded review history + +Review history accepts `after_id` (a nonnegative event identifier, default zero) and `limit` (one through one hundred, default fifty). Events are ordered by their database identifier, representing insertion order rather than the reviewer-supplied timestamp. The response preserves `items`, `mode`, and `disclaimer`, and adds `limit` and `next_after_id`. Pass the returned cursor as `after_id` for the next request; a null cursor means no additional events were visible during that query. Invalid bounds return HTTP 422 with `INVALID_REQUEST`. + +```bash +curl 'http://localhost:8000/api/v1/evidence/SYN-001/review-events?after_id=0&limit=20' +``` + +The repository fetches at most the requested limit plus one row, using the extra row to detect continuation. Cursors remain scoped to the requested evidence identifier. Concurrent writes can become visible in subsequent requests; this interface is not a frozen export snapshot. Existing clients that previously expected the entire history in one response must follow the cursor. Review events remain separate from the fixture read model and do not make synthetic records citation eligible. PostgreSQL integration tests exercise authenticated writes, persisted payloads, ordered pagination, record isolation, and fixture exclusion from citation export. + ## Failure and health interpretation Request-schema failures use a structured invalid-request response. Provider failures, unavailable storage, disabled ingestion, unauthorized access, missing records, and unsupported resources represent different conditions. Preserve the error classification in client behavior. A provider outage should not become an empty-results screen suggesting no research exists, and a missing database configuration should not be described as a scientific data-quality finding. diff --git a/src/openlongevity/api.py b/src/openlongevity/api.py index b735304..2734ce0 100644 --- a/src/openlongevity/api.py +++ b/src/openlongevity/api.py @@ -287,10 +287,17 @@ async def review_evidence_record( return {"mode": "review-event", "item": event, "disclaimer": DISCLAIMER} @app.get("/api/v1/evidence/{identifier}/review-events") - async def review_events(identifier: str) -> dict[str, Any]: + async def review_events( + identifier: str, + after_id: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=100), + ) -> dict[str, Any]: fixture_by_identifier(identifier) - events = await require_review_repository().list_for_record(identifier) - return {"mode": "review-events", "items": events, "disclaimer": DISCLAIMER} + events, next_after_id = await require_review_repository().list_for_record( + identifier, after_id=after_id, limit=limit, + ) + return {"mode": "review-events", "items": events, "limit": limit, + "next_after_id": next_after_id, "disclaimer": DISCLAIMER} @app.get("/api/v1/research-gaps") def gaps(topic: str = Query(min_length=1, max_length=120)) -> dict[str, Any]: diff --git a/src/openlongevity/repository.py b/src/openlongevity/repository.py index f891c55..84203a9 100644 --- a/src/openlongevity/repository.py +++ b/src/openlongevity/repository.py @@ -119,12 +119,19 @@ async def record_event( await session.flush() return self.serialize(row) - async def list_for_record(self, record_identifier: str) -> list[dict[str, Any]]: + async def list_for_record( + self, record_identifier: str, *, after_id: int = 0, limit: int = 50, + ) -> tuple[list[dict[str, Any]], int | None]: + if after_id < 0 or not 1 <= limit <= 100: + raise ValueError("Review history requires after_id >= 0 and limit between 1 and 100") async with self.database.sessions() as session: rows = await session.scalars(select(EvidenceReviewEventRow).where( - EvidenceReviewEventRow.record_identifier == record_identifier - ).order_by(EvidenceReviewEventRow.id)) - return [self.serialize(row) for row in rows] + EvidenceReviewEventRow.record_identifier == record_identifier, + EvidenceReviewEventRow.id > after_id, + ).order_by(EvidenceReviewEventRow.id).limit(limit + 1)) + items = [self.serialize(row) for row in rows] + next_after_id = items[limit - 1]["id"] if len(items) > limit else None + return items[:limit], next_after_id @staticmethod def serialize(row: EvidenceReviewEventRow) -> dict[str, Any]: diff --git a/tests/test_api.py b/tests/test_api.py index 7e2b4f0..544f3f1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,6 +10,16 @@ TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") +@pytest.mark.parametrize("params", [ + {"limit": 0}, {"limit": 101}, {"after_id": -1}, {"after_id": "invalid"}, +]) +def test_review_history_rejects_invalid_pagination(params: dict[str, object]) -> None: + with TestClient(create_app()) as client: + response = client.get("/api/v1/evidence/SYN-001/review-events", params=params) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "INVALID_REQUEST" + + def test_health_contract() -> None: client = TestClient(create_app()) health = client.get("/api/v1/health") @@ -99,4 +109,4 @@ def test_review_endpoint_rejects_machine_status_as_human_review() -> None: }, ) assert response.status_code == 422 - assert response.json()["error"]["code"] == "INVALID_REVIEW" \ No newline at end of file + assert response.json()["error"]["code"] == "INVALID_REVIEW" diff --git a/tests/test_review_history.py b/tests/test_review_history.py new file mode 100644 index 0000000..78f4c84 --- /dev/null +++ b/tests/test_review_history.py @@ -0,0 +1,74 @@ +"""PostgreSQL review audit round-trip and cursor isolation contracts.""" + +import os +from uuid import uuid4 + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete + +from openlongevity.api import create_app +from openlongevity.db import Database, EvidenceReviewEventRow +from openlongevity.repository import EvidenceReviewRepository + +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") +pytestmark = [ + pytest.mark.postgres, + pytest.mark.skipif(not TEST_DATABASE_URL, reason="TEST_DATABASE_URL is required"), +] + + +async def test_review_history_round_trip_and_cursor() -> None: + assert TEST_DATABASE_URL is not None + database = Database(TEST_DATABASE_URL) + repository = EvidenceReviewRepository(database) + reviewer = f"test-{uuid4()}" + app = create_app(database_url=TEST_DATABASE_URL, review_key="test-review-key") + try: + async with app.router.lifespan_context(app): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test", + ) as client: + body = {"status": "verified", "reviewer": reviewer, + "reviewed_at": "2026-09-22T10:00:00+03:00", "notes": "Fixture review."} + unauthorized = await client.post("/api/v1/evidence/SYN-001/review", json=body) + assert unauthorized.status_code == 401 + created = [] + for status in ("verified", "disputed", "human_reviewed"): + response = await client.post( + "/api/v1/evidence/SYN-001/review", json={**body, "status": status}, + headers={"X-Review-Key": "test-review-key"}, + ) + assert response.status_code == 200 + created.append(response.json()["item"]) + # Another record must never leak into this record's cursor page. + await repository.record_event( + record_identifier=f"OTHER-{reviewer}", status="verified", reviewer=reviewer, + reviewed_at=body["reviewed_at"], notes=body["notes"], payload={}, + ) + first = (await client.get( + "/api/v1/evidence/SYN-001/review-events", + params={"after_id": created[0]["id"] - 1, "limit": 2}, + )).json() + assert first["items"] == created[:2] + assert first["next_after_id"] == created[1]["id"] + second = (await client.get( + "/api/v1/evidence/SYN-001/review-events", + params={"after_id": first["next_after_id"], "limit": 2}, + )).json() + assert second["items"] == created[2:] + assert second["next_after_id"] is None + empty, cursor = await repository.list_for_record( + "SYN-001", after_id=created[-1]["id"], limit=2, + ) + assert (empty, cursor) == ([], None) + # Reviewing a fixture never makes it a citable observation. + exported = (await client.get("/api/v1/evidence/export/citation")).json() + assert exported["items"] == [] + assert exported["excluded"][0]["reason"] == "synthetic_fixture" + finally: + async with database.sessions.begin() as session: + await session.execute(delete(EvidenceReviewEventRow).where( + EvidenceReviewEventRow.reviewer == reviewer, + )) + await database.close()