feat: ingest repository navigation projections
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
parent
f9a0a6dd72
commit
3db8c9cd78
9 changed files with 933 additions and 4 deletions
235
tests/test_repository_navigation_ingestion.py
Normal file
235
tests/test_repository_navigation_ingestion.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue