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-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-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-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 |
|
| 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;
|
repository, facet, provenance, diagnostic, cursor, and legacy-alias output;
|
||||||
- `openapi/repository-navigation.openapi.json` defines the read-only
|
- `openapi/repository-navigation.openapi.json` defines the read-only
|
||||||
`port.projection.query` routes;
|
`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
|
- `compatibility-matrix.json` records the only accepted input version and the
|
||||||
temporary State Hub topic adapter.
|
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()
|
ROOT = repository_navigation_contract_root()
|
||||||
SCHEMAS = ROOT.joinpath("schemas")
|
SCHEMAS = ROOT.joinpath("schemas")
|
||||||
INPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-classification-page.json")
|
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")
|
OUTPUT_FIXTURE = ROOT.joinpath("fixtures", "repository-navigation-projection.json")
|
||||||
OPENAPI = ROOT.joinpath("openapi", "repository-navigation.openapi.json")
|
OPENAPI = ROOT.joinpath("openapi", "repository-navigation.openapi.json")
|
||||||
COMPATIBILITY = ROOT.joinpath("compatibility-matrix.json")
|
COMPATIBILITY = ROOT.joinpath("compatibility-matrix.json")
|
||||||
|
|
@ -70,6 +73,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non
|
||||||
for resource in (
|
for resource in (
|
||||||
ROOT.joinpath("README.md"),
|
ROOT.joinpath("README.md"),
|
||||||
INPUT_FIXTURE,
|
INPUT_FIXTURE,
|
||||||
|
INCREMENTAL_FIXTURE,
|
||||||
OUTPUT_FIXTURE,
|
OUTPUT_FIXTURE,
|
||||||
OPENAPI,
|
OPENAPI,
|
||||||
COMPATIBILITY,
|
COMPATIBILITY,
|
||||||
|
|
@ -79,6 +83,7 @@ def test_packaged_repository_navigation_contract_identity_and_artifacts() -> Non
|
||||||
|
|
||||||
def test_input_and_output_fixtures_validate() -> None:
|
def test_input_and_output_fixtures_validate() -> None:
|
||||||
validate(load_json(INPUT_FIXTURE), "repository-classification-page.schema.json")
|
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")
|
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.store import InMemoryPortStore
|
||||||
from hub_core.runtime.tables import runtime_metadata
|
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]:
|
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"))
|
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:
|
class PageClient:
|
||||||
def __init__(self, pages: list[dict[str, Any]]) -> None:
|
def __init__(self, pages: list[dict[str, Any]]) -> None:
|
||||||
self.pages = pages
|
self.pages = pages
|
||||||
|
|
@ -87,27 +99,7 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None:
|
||||||
)
|
)
|
||||||
await first_service.refresh()
|
await first_service.refresh()
|
||||||
|
|
||||||
incremental = fixture_page()
|
incremental = incremental_fixture()
|
||||||
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(
|
result = await RepositoryNavigationService(
|
||||||
client=PageClient([incremental]), store=store
|
client=PageClient([incremental]), store=store
|
||||||
|
|
@ -130,6 +122,100 @@ def test_incremental_upsert_and_delete_rebuild_facets_atomically() -> None:
|
||||||
asyncio.run(run())
|
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:
|
def test_rejected_version_preserves_last_projection_and_marks_it_stale() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
store = InMemoryPortStore()
|
store = InMemoryPortStore()
|
||||||
|
|
@ -241,6 +327,26 @@ def test_query_cursor_is_stable_and_rejects_different_filters() -> None:
|
||||||
asyncio.run(run())
|
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:
|
def test_http_navigation_routes_are_read_only_and_return_contract_provenance() -> None:
|
||||||
settings = RuntimeSettings(
|
settings = RuntimeSettings(
|
||||||
environment="test", backend="memory", allow_ephemeral=True
|
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"
|
assert projection.source_snapshot["source_system"] == "repo-manager"
|
||||||
|
|
||||||
asyncio.run(run())
|
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
|
atomic snapshot/rebuild and cursor rules, per-repository provenance, failure
|
||||||
diagnostics, all six derived facet kinds, read-only query routes, and expiring
|
diagnostics, all six derived facet kinds, read-only query routes, and expiring
|
||||||
State Hub topic aliases with residual ownership. Both representative fixtures
|
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.
|
72 tests.
|
||||||
|
|
||||||
## Implement Repo Manager ingestion and rebuild
|
## Implement Repo Manager ingestion and rebuild
|
||||||
|
|
@ -123,7 +123,7 @@ OpenAPI inspection confirms that every navigation operation is GET-only.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: HUB-WP-0006-T05
|
id: HUB-WP-0006-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "a3c0efba-9913-4617-b167-8853b34de36e"
|
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
|
stable ordering/cursors, provenance, and absence of classification writes or
|
||||||
foreign database access.
|
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
|
## Hand A5/A4 navigation to State Hub retirement
|
||||||
|
|
||||||
```task
|
```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] Repo Manager v1.0 authority and projection contract accepted
|
||||||
- [x] Hub-side projection and compatibility alias contract versioned
|
- [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
|
- [x] HTTP/MCP navigation reads only the derived projection
|
||||||
- [ ] A5/A4 consumer comparison and rollback evidence recorded
|
- [ ] A5/A4 consumer comparison and rollback evidence recorded
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue