Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
445 lines
16 KiB
Python
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)
|