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())