hub-core/tests/test_repository_navigation_ingestion.py
tegwick 93a9151558
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s
test: prove repository navigation conformance
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
2026-08-22 00:44:09 +02:00

445 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import json
from copy import deepcopy
from typing import Any
import pytest
from fastapi.testclient import TestClient
from jsonschema import Draft202012Validator, FormatChecker
from sqlalchemy.ext.asyncio import create_async_engine
from hub_core.contracts import repository_navigation_contract_root
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.postgres_store import PostgresPortStore
from hub_core.runtime.repository_navigation import (
ProjectionCursorMismatch,
ProjectionRejected,
RepositoryNavigationService,
)
from hub_core.runtime.store import InMemoryPortStore
from hub_core.runtime.tables import runtime_metadata
from hub_core.runtime.tables import (
runtime_repository_navigation_facets,
runtime_repository_navigation_repositories,
runtime_repository_navigation_state,
)
def fixture_page() -> dict[str, Any]:
resource = repository_navigation_contract_root().joinpath(
"fixtures", "repository-classification-page.json"
)
return json.loads(resource.read_text(encoding="utf-8"))
def incremental_fixture() -> dict[str, Any]:
resource = repository_navigation_contract_root().joinpath(
"fixtures", "repository-classification-incremental.json"
)
return json.loads(resource.read_text(encoding="utf-8"))
class PageClient:
def __init__(self, pages: list[dict[str, Any]]) -> None:
self.pages = pages
self.calls: list[str | None] = []
async def fetch_classification_page(self, cursor: str | None) -> dict[str, Any]:
self.calls.append(cursor)
for page in self.pages:
if page["snapshot"]["page_cursor"] == cursor:
return deepcopy(page)
raise RuntimeError(f"unexpected cursor {cursor!r}")
class FailingClient:
async def fetch_classification_page(self, cursor: str | None) -> dict[str, Any]:
raise RuntimeError("repo-manager unavailable")
def test_full_rebuild_is_deterministic_and_duplicate_delivery_is_idempotent() -> None:
async def run() -> None:
page = fixture_page()
store = InMemoryPortStore()
service = RepositoryNavigationService(client=PageClient([page]), store=store)
first = await service.refresh()
duplicate = await service.refresh()
projection = await store.get_repository_navigation()
assert first.status == "accepted"
assert duplicate.status == "duplicate"
assert duplicate.content_hash == first.content_hash
assert projection is not None
assert [row["repository_id"] for row in projection.repositories] == sorted(
row["repository_id"] for row in projection.repositories
)
assert {facet["kind"] for facet in projection.facets} == {
"primary_domain",
"secondary_domain",
"category",
"capability_tag",
"business_stake",
"business_mechanic",
}
assert await service.readiness_checks() == {"repo_manager_projection": "ok"}
asyncio.run(run())
def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None:
async def run() -> None:
initial = fixture_page()
store = InMemoryPortStore()
first_service = RepositoryNavigationService(
client=PageClient([initial]), store=store
)
await first_service.refresh()
incremental = incremental_fixture()
result = await RepositoryNavigationService(
client=PageClient([incremental]), store=store
).refresh()
projection = await store.get_repository_navigation()
assert result.status == "accepted"
assert projection is not None
assert [row["slug"] for row in projection.repositories] == [
"repo-manager",
"classification-catalog",
]
assert any(
facet["kind"] == "category"
and facet["value"] == "product"
and facet["repository_count"] == 1
for facet in projection.facets
)
asyncio.run(run())
def test_multi_page_full_rebuild_is_complete_and_cursor_driven() -> None:
async def run() -> None:
first = fixture_page()
second = deepcopy(first)
first["repositories"] = [first["repositories"][0]]
first["snapshot"].update(
{"next_cursor": "page-2", "final_page": False}
)
second["repositories"] = [second["repositories"][1]]
second["snapshot"].update(
{"page_cursor": "page-2", "next_cursor": None, "final_page": True}
)
client = PageClient([first, second])
store = InMemoryPortStore()
result = await RepositoryNavigationService(
client=client, store=store
).refresh()
assert result.repository_count == 2
assert client.calls == [None, "page-2"]
asyncio.run(run())
def test_inconsistent_multi_page_rebuild_never_partly_replaces_active_rows() -> None:
async def run() -> None:
store = InMemoryPortStore()
await RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
).refresh()
before = await store.get_repository_navigation()
first = fixture_page()
second = deepcopy(first)
for page in (first, second):
page["snapshot"].update(
{
"snapshot_id": "9999999999999999999999999999999999999999999999999999999999999999",
"generated_at": "2026-08-22T12:10:00Z",
}
)
first["repositories"] = [first["repositories"][0]]
first["snapshot"].update({"next_cursor": "page-2", "final_page": False})
second["repositories"] = [second["repositories"][1]]
second["snapshot"].update(
{
"source_revision": "abcdef0123456789abcdef0123456789abcdef01",
"page_cursor": "page-2",
"next_cursor": None,
"final_page": True,
}
)
with pytest.raises(ProjectionRejected, match="metadata changed"):
await RepositoryNavigationService(
client=PageClient([first, second]), store=store
).refresh()
after = await store.get_repository_navigation()
assert before is not None and after is not None
assert after.content_hash == before.content_hash
assert after.repositories == before.repositories
assert after.projection_status == "stale"
asyncio.run(run())
def test_reused_snapshot_identity_with_different_content_is_rejected() -> None:
async def run() -> None:
store = InMemoryPortStore()
original = fixture_page()
await RepositoryNavigationService(
client=PageClient([original]), store=store
).refresh()
changed = fixture_page()
changed["repositories"][0]["slug"] = "hub-core-renamed"
with pytest.raises(ProjectionRejected, match="snapshot_id was reused"):
await RepositoryNavigationService(
client=PageClient([changed]), store=store
).refresh()
projection = await store.get_repository_navigation()
assert projection is not None
assert projection.projection_status == "stale"
assert [row["slug"] for row in projection.repositories] == [
"hub-core",
"repo-manager",
]
asyncio.run(run())
def test_rejected_version_preserves_last_projection_and_marks_it_stale() -> None:
async def run() -> None:
store = InMemoryPortStore()
initial = fixture_page()
await RepositoryNavigationService(
client=PageClient([initial]), store=store
).refresh()
before = await store.get_repository_navigation()
unsupported = fixture_page()
unsupported["contract_version"] = "2.0.0"
service = RepositoryNavigationService(
client=PageClient([unsupported]), store=store
)
with pytest.raises(ProjectionRejected, match="invalid projection page"):
await service.refresh()
after = await store.get_repository_navigation()
assert before is not None and after is not None
assert after.content_hash == before.content_hash
assert after.repositories == before.repositories
assert after.projection_status == "stale"
assert after.diagnostics[0]["code"] == "repo_projection.rejected"
assert await service.readiness_checks() == {"repo_manager_projection": "stale"}
asyncio.run(run())
def test_upstream_outage_without_a_generation_fails_readiness_closed() -> None:
async def run() -> None:
store = InMemoryPortStore()
service = RepositoryNavigationService(client=FailingClient(), store=store)
with pytest.raises(ProjectionRejected, match="repo-manager unavailable"):
await service.refresh()
assert await store.get_repository_navigation() is None
assert await service.readiness_checks() == {
"repo_manager_projection": "unavailable"
}
asyncio.run(run())
def test_injected_port_repo_client_refreshes_at_startup_and_controls_readiness() -> None:
settings = RuntimeSettings(
environment="test", backend="memory", allow_ephemeral=True
)
store = InMemoryPortStore()
app = create_app(
settings=settings,
port_store=store,
repo_projection_client=PageClient([fixture_page()]),
)
with TestClient(app) as runtime:
response = runtime.get("/readyz")
assert response.status_code == 200
assert response.json()["checks"]["repo_manager_projection"] == "ok"
def test_query_filters_are_ored_within_families_and_anded_across_them() -> None:
async def run() -> None:
store = InMemoryPortStore()
service = RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
)
await service.refresh()
result = await service.query(
filters={
"primary_domain": ["infotech", "agents"],
"capability_tag": ["repository-navigation"],
}
)
assert result is not None
assert [row["slug"] for row in result["repositories"]] == ["hub-core"]
assert result["source_snapshot"]["source_revision"]
assert result["content_hash"]
assert result["rebuilt_at"]
asyncio.run(run())
def test_query_cursor_is_stable_and_rejects_different_filters() -> None:
async def run() -> None:
store = InMemoryPortStore()
service = RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
)
await service.refresh()
first = await service.query(filters={}, limit=1)
assert first is not None and first["next_cursor"]
second = await service.query(
filters={}, cursor=first["next_cursor"], limit=1
)
assert second is not None
assert [row["slug"] for row in second["repositories"]] == ["repo-manager"]
with pytest.raises(ProjectionCursorMismatch, match="cursor_snapshot_mismatch"):
await service.query(
filters={"category": ["tooling"]},
cursor=first["next_cursor"],
limit=1,
)
asyncio.run(run())
def test_query_cursor_is_invalidated_by_an_incremental_generation() -> None:
async def run() -> None:
store = InMemoryPortStore()
initial = RepositoryNavigationService(
client=PageClient([fixture_page()]), store=store
)
await initial.refresh()
first = await initial.query(filters={}, limit=1)
assert first is not None and first["next_cursor"]
updated = RepositoryNavigationService(
client=PageClient([incremental_fixture()]), store=store
)
await updated.refresh()
with pytest.raises(ProjectionCursorMismatch, match="cursor_snapshot_mismatch"):
await updated.query(filters={}, cursor=first["next_cursor"], limit=1)
asyncio.run(run())
def test_http_navigation_routes_are_read_only_and_return_contract_provenance() -> None:
settings = RuntimeSettings(
environment="test", backend="memory", allow_ephemeral=True
)
app = create_app(
settings=settings,
port_store=InMemoryPortStore(),
repo_projection_client=PageClient([fixture_page()]),
)
with TestClient(app) as runtime:
response = runtime.get(
"/ports/projections/repository-navigation/repositories",
params={"secondary_domain": "agents"},
)
facet = runtime.get(
"/ports/projections/repository-navigation/facets/business_mechanic/control"
)
document = runtime.get("/openapi.json").json()
assert response.status_code == 200
output_schema = json.loads(
repository_navigation_contract_root()
.joinpath("schemas", "repository-navigation-projection.schema.json")
.read_text(encoding="utf-8")
)
Draft202012Validator(
output_schema, format_checker=FormatChecker()
).validate(response.json())
assert [row["slug"] for row in response.json()["repositories"]] == ["hub-core"]
assert response.json()["source_snapshot"]["source_system"] == "repo-manager"
assert facet.status_code == 200
assert [row["slug"] for row in facet.json()["repositories"]] == ["repo-manager"]
navigation_paths = {
path: path_item
for path, path_item in document["paths"].items()
if path.startswith("/ports/projections/repository-navigation")
}
assert navigation_paths
assert all(set(path_item) == {"get"} for path_item in navigation_paths.values())
def test_failed_startup_refresh_keeps_api_up_for_diagnosis_but_not_ready() -> None:
settings = RuntimeSettings(
environment="test", backend="memory", allow_ephemeral=True
)
app = create_app(
settings=settings,
port_store=InMemoryPortStore(),
repo_projection_client=FailingClient(),
)
with TestClient(app) as runtime:
assert runtime.get("/healthz").status_code == 200
response = runtime.get("/readyz")
assert response.status_code == 503
assert response.json()["checks"]["repo_manager_projection"] == "unavailable"
def test_postgres_projection_survives_store_reopen(tmp_path) -> None:
database_url = f"sqlite+aiosqlite:///{tmp_path / 'navigation.db'}"
async def run() -> None:
engine = create_async_engine(database_url)
async with engine.begin() as connection:
await connection.run_sync(runtime_metadata.create_all)
await engine.dispose()
first_store = PostgresPortStore.from_url(database_url)
result = await RepositoryNavigationService(
client=PageClient([fixture_page()]), store=first_store
).refresh()
await first_store.aclose()
second_store = PostgresPortStore.from_url(database_url)
projection = await second_store.get_repository_navigation()
await second_store.aclose()
assert projection is not None
assert projection.content_hash == result.content_hash
assert len(projection.repositories) == 2
assert projection.source_snapshot["source_system"] == "repo-manager"
asyncio.run(run())
def test_navigation_projection_tables_have_no_foreign_database_coupling() -> None:
owned_tables = (
runtime_repository_navigation_state,
runtime_repository_navigation_repositories,
runtime_repository_navigation_facets,
)
assert all(not table.foreign_keys for table in owned_tables)
assert {table.name for table in owned_tables} <= set(runtime_metadata.tables)