feat: ingest repository navigation projections
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
tegwick 2026-08-22 00:35:47 +02:00
parent f9a0a6dd72
commit 3db8c9cd78
9 changed files with 933 additions and 4 deletions

View file

@ -0,0 +1,235 @@
from __future__ import annotations
import asyncio
import json
from copy import deepcopy
from typing import Any
import pytest
from fastapi.testclient import TestClient
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 (
ProjectionRejected,
RepositoryNavigationService,
)
from hub_core.runtime.store import InMemoryPortStore
from hub_core.runtime.tables import runtime_metadata
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"))
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 = fixture_page()
incremental["snapshot"].update(
{
"snapshot_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"mode": "incremental",
"generated_at": "2026-08-22T12:05:00Z",
"source_revision": "123456789abcdef0123456789abcdef012345678",
"total_repository_count": None,
}
)
delete = {
"operation": "delete",
"repository_id": "11111111-1111-4111-8111-111111111111",
"slug": "hub-core",
"revision": deepcopy(initial["repositories"][0]["revision"]),
}
upsert = deepcopy(initial["repositories"][1])
upsert["repository_id"] = "33333333-3333-4333-8333-333333333333"
upsert["slug"] = "classification-catalog"
upsert["classification"]["category"] = "product"
incremental["repositories"] = [delete, upsert]
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_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_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())