From 93a91515583ab103e53f08808e9f0145285254ef Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 00:44:09 +0200 Subject: [PATCH] test: prove repository navigation conformance Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49 --- WORK-RECORDS.md | 2 +- .../repository_navigation/v1_0_0/README.md | 3 +- ...repository-classification-incremental.json | 52 ++++++ tests/test_repository_navigation_contract.py | 5 + tests/test_repository_navigation_ingestion.py | 159 +++++++++++++++--- ...06-repository-classification-navigation.md | 15 +- 6 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 hub_core/contracts/repository_navigation/v1_0_0/fixtures/repository-classification-incremental.json diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 82e8606..599ccfe 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -42,5 +42,5 @@ | task | HUB-WP-0006-T02 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md | | task | HUB-WP-0006-T03 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md | | task | HUB-WP-0006-T04 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md | -| task | HUB-WP-0006-T05 | todo | — | workplans/HUB-WP-0006-repository-classification-navigation.md | +| task | HUB-WP-0006-T05 | done | — | workplans/HUB-WP-0006-repository-classification-navigation.md | | task | HUB-WP-0006-T06 | wait | — | workplans/HUB-WP-0006-repository-classification-navigation.md | diff --git a/hub_core/contracts/repository_navigation/v1_0_0/README.md b/hub_core/contracts/repository_navigation/v1_0_0/README.md index 2f4a58c..f36b780 100644 --- a/hub_core/contracts/repository_navigation/v1_0_0/README.md +++ b/hub_core/contracts/repository_navigation/v1_0_0/README.md @@ -9,7 +9,8 @@ classification projections: repository, facet, provenance, diagnostic, cursor, and legacy-alias output; - `openapi/repository-navigation.openapi.json` defines the read-only `port.projection.query` routes; -- `fixtures/` supplies a valid full input page and its representative output; +- `fixtures/` supplies valid full and incremental/delete input pages plus a + representative derived output; - `compatibility-matrix.json` records the only accepted input version and the temporary State Hub topic adapter. diff --git a/hub_core/contracts/repository_navigation/v1_0_0/fixtures/repository-classification-incremental.json b/hub_core/contracts/repository_navigation/v1_0_0/fixtures/repository-classification-incremental.json new file mode 100644 index 0000000..bd352d3 --- /dev/null +++ b/hub_core/contracts/repository_navigation/v1_0_0/fixtures/repository-classification-incremental.json @@ -0,0 +1,52 @@ +{ + "contract_id": "helixforge.repository-classification-projection", + "contract_version": "1.0.0", + "source": { + "system": "repo-manager", + "classification_contract_id": "RMGR-CONTRACT-CLASSIFICATION-0001", + "classification_contract_version": "1.0", + "producer_version": "0.4.0" + }, + "snapshot": { + "snapshot_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "mode": "incremental", + "generated_at": "2026-08-22T12:05:00Z", + "source_revision": "123456789abcdef0123456789abcdef012345678", + "page_cursor": null, + "next_cursor": null, + "final_page": true, + "total_repository_count": null + }, + "repositories": [ + { + "operation": "delete", + "repository_id": "11111111-1111-4111-8111-111111111111", + "slug": "hub-core", + "revision": { + "head_sha": "1111111111111111111111111111111111111111", + "source_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "observed_at": "2026-08-22T12:04:00Z" + } + }, + { + "operation": "upsert", + "repository_id": "33333333-3333-4333-8333-333333333333", + "slug": "classification-catalog", + "lifecycle": "active", + "classification": { + "category": "product", + "domain": "infotech", + "secondary_domains": [], + "capability_tags": ["repository-governance"], + "business_stake": ["operations", "technology"], + "business_mechanics": ["control", "coordination"] + }, + "revision": { + "head_sha": "3333333333333333333333333333333333333333", + "source_fingerprint": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "observed_at": "2026-08-22T12:04:30Z" + } + } + ], + "diagnostics": [] +} diff --git a/tests/test_repository_navigation_contract.py b/tests/test_repository_navigation_contract.py index 5bba40f..90a5a38 100644 --- a/tests/test_repository_navigation_contract.py +++ b/tests/test_repository_navigation_contract.py @@ -14,6 +14,9 @@ from hub_core.contracts import ( ROOT = repository_navigation_contract_root() SCHEMAS = ROOT.joinpath("schemas") INPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-classification-page.json") +INCREMENTAL_FIXTURE = ROOT.joinpath( + "fixtures", "repository-classification-incremental.json" +) OUTPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-navigation-projection.json") OPENAPI = ROOT.joinpath("openapi", "repository-navigation.openapi.json") COMPATIBILITY = ROOT.joinpath("compatibility-matrix.json") @@ -70,6 +73,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non for resource in ( ROOT.joinpath("README.md"), INPUT_FIXTURE, + INCREMENTAL_FIXTURE, OUTPUT_FIXTURE, OPENAPI, COMPATIBILITY, @@ -79,6 +83,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non def test_input_and_output_fixtures_validate() -> None: validate(load_json(INPUT_FIXTURE), "repository-classification-page.schema.json") + validate(load_json(INCREMENTAL_FIXTURE), "repository-classification-page.schema.json") validate(load_json(OUTPUT_FIXTURE), "repository-navigation-projection.schema.json") diff --git a/tests/test_repository_navigation_ingestion.py b/tests/test_repository_navigation_ingestion.py index 755b4f9..d4c03a5 100644 --- a/tests/test_repository_navigation_ingestion.py +++ b/tests/test_repository_navigation_ingestion.py @@ -21,6 +21,11 @@ from hub_core.runtime.repository_navigation import ( ) 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]: @@ -30,6 +35,13 @@ def fixture_page() -> dict[str, Any]: 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 @@ -87,27 +99,7 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None: ) 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] + incremental = incremental_fixture() result = await RepositoryNavigationService( client=PageClient([incremental]), store=store @@ -130,6 +122,100 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None: 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() @@ -241,6 +327,26 @@ def test_query_cursor_is_stable_and_rejects_different_filters() -> None: 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 @@ -326,3 +432,14 @@ def test_postgres_projection_survives_store_reopen(tmp_path) -> None: 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) diff --git a/workplans/HUB-WP-0006-repository-classification-navigation.md b/workplans/HUB-WP-0006-repository-classification-navigation.md index cf9ee28..f9a3477 100644 --- a/workplans/HUB-WP-0006-repository-classification-navigation.md +++ b/workplans/HUB-WP-0006-repository-classification-navigation.md @@ -67,7 +67,7 @@ defines the strict Repo Manager page envelope, stable registrar UUID identity, atomic snapshot/rebuild and cursor rules, per-repository provenance, failure diagnostics, all six derived facet kinds, read-only query routes, and expiring State Hub topic aliases with residual ownership. Both representative fixtures -validate, the wheel contains all eight artifacts, and the full suite passes +validate, the wheel contains the versioned artifacts, and the full suite passes 72 tests. ## Implement Repo Manager ingestion and rebuild @@ -123,7 +123,7 @@ OpenAPI inspection confirms that every navigation operation is GET-only. ```task id: HUB-WP-0006-T05 -status: todo +status: done priority: medium state_hub_task_id: "a3c0efba-9913-4617-b167-8853b34de36e" ``` @@ -133,6 +133,15 @@ deletion, duplicate delivery, unsupported versions, Repo Manager outage, stable ordering/cursors, provenance, and absence of classification writes or foreign database access. +Completed 2026-08-22. A packaged incremental fixture and 15 focused ingestion +checks now cover single- and multi-page full rebuild, incremental upsert plus +delete, duplicate delivery, snapshot-ID reuse with changed content, +unsupported versions, upstream outage, inconsistent-page atomicity, durable +reopen, stable ordering, filter- and generation-bound cursors, response +provenance, GET-only HTTP navigation, and projection tables without foreign +database keys. The wheel rebuild succeeds and the complete suite passes 88 +tests. + ## Hand A5/A4 navigation to State Hub retirement ```task @@ -151,6 +160,6 @@ expiry. Wait on T02-T05 and the State Hub cutover window. - [x] Repo Manager v1.0 authority and projection contract accepted - [x] Hub-side projection and compatibility alias contract versioned -- [ ] Rebuildable durable ingestion passes provenance and failure checks +- [x] Rebuildable durable ingestion passes provenance and failure checks - [x] HTTP/MCP navigation reads only the derived projection - [ ] A5/A4 consumer comparison and rollback evidence recorded