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)