test: prove repository navigation conformance
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
parent
fe0e845cb1
commit
93a9151558
6 changed files with 210 additions and 26 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue