test_identity_genealogy_api.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """TDD — musehub#211: genealogy query API surface. |
| 2 | |
| 3 | ``IdentityGraphService``/``RootDistanceIndex`` (I2) is a real, correct, |
| 4 | well-tested in-memory engine (see ``tests/test_graph_depth.py``, |
| 5 | ``tests/test_graph_service.py``) that was never wired into the live |
| 6 | application — no production code path computes root-distance or exposes |
| 7 | it to callers. |
| 8 | |
| 9 | This adds ``GET /api/identities/{handle}/genealogy``: builds the full |
| 10 | persisted spawn graph from ``musehub_identities`` rows, runs |
| 11 | ``RootDistanceIndex`` against it, and returns the target identity's |
| 12 | distance to the nearest human root plus its set of human ancestors. |
| 13 | |
| 14 | Scope (per the ticket's own deferred items, explicitly NOT covered here): |
| 15 | - ``QuorumEngine``/org-quorum queries (I3) — separate concern. |
| 16 | - Cascading/flagging orphaned agents on operator revocation — separate |
| 17 | concern; this endpoint makes the orphan state *queryable*, which is the |
| 18 | minimal slice that closes the ticket's core gap. |
| 19 | """ |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import pytest |
| 23 | from httpx import AsyncClient |
| 24 | from sqlalchemy.ext.asyncio import AsyncSession |
| 25 | |
| 26 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 27 | from musehub.core.genesis import compute_identity_id |
| 28 | |
| 29 | |
| 30 | async def _create_identity( |
| 31 | db_session: AsyncSession, |
| 32 | handle: str, |
| 33 | identity_type: str, |
| 34 | spawned_by: str | None = None, |
| 35 | ) -> MusehubIdentity: |
| 36 | identity = MusehubIdentity( |
| 37 | identity_id=compute_identity_id(handle.encode()), |
| 38 | handle=handle, |
| 39 | identity_type=identity_type, |
| 40 | spawned_by=spawned_by, |
| 41 | ) |
| 42 | db_session.add(identity) |
| 43 | await db_session.commit() |
| 44 | await db_session.refresh(identity) |
| 45 | await db_session.commit() |
| 46 | return identity |
| 47 | |
| 48 | |
| 49 | @pytest.mark.asyncio |
| 50 | class TestGenealogyEndpoint: |
| 51 | async def test_human_root_distance_is_zero( |
| 52 | self, |
| 53 | client: AsyncClient, |
| 54 | db_session: AsyncSession, |
| 55 | test_user: MusehubIdentity, |
| 56 | ) -> None: |
| 57 | resp = await client.get(f"/api/identities/{test_user.handle}/genealogy") |
| 58 | assert resp.status_code == 200 |
| 59 | data = resp.json() |
| 60 | assert data["root_distance"] == 0 |
| 61 | assert data["human_ancestors"] == [test_user.handle] |
| 62 | |
| 63 | async def test_direct_agent_spawn_distance_is_one( |
| 64 | self, |
| 65 | client: AsyncClient, |
| 66 | db_session: AsyncSession, |
| 67 | test_user: MusehubIdentity, |
| 68 | ) -> None: |
| 69 | agent_a = await _create_identity( |
| 70 | db_session, "ep19-agent-a", "agent", spawned_by=test_user.handle |
| 71 | ) |
| 72 | resp = await client.get(f"/api/identities/{agent_a.handle}/genealogy") |
| 73 | assert resp.status_code == 200 |
| 74 | data = resp.json() |
| 75 | assert data["root_distance"] == 1 |
| 76 | assert data["human_ancestors"] == [test_user.handle] |
| 77 | |
| 78 | async def test_two_hop_spawn_chain_distance_is_two( |
| 79 | self, |
| 80 | client: AsyncClient, |
| 81 | db_session: AsyncSession, |
| 82 | test_user: MusehubIdentity, |
| 83 | ) -> None: |
| 84 | """Reproduces the exact chain from the ticket's own live repro: |
| 85 | gabriel -> ep19-agent-a -> ep19-agent-b.""" |
| 86 | agent_a = await _create_identity( |
| 87 | db_session, "ep19-agent-a", "agent", spawned_by=test_user.handle |
| 88 | ) |
| 89 | agent_b = await _create_identity( |
| 90 | db_session, "ep19-agent-b", "agent", spawned_by=agent_a.handle |
| 91 | ) |
| 92 | resp = await client.get(f"/api/identities/{agent_b.handle}/genealogy") |
| 93 | assert resp.status_code == 200 |
| 94 | data = resp.json() |
| 95 | assert data["root_distance"] == 2 |
| 96 | assert data["human_ancestors"] == [test_user.handle] |
| 97 | |
| 98 | async def test_orphaned_agent_has_null_distance( |
| 99 | self, |
| 100 | client: AsyncClient, |
| 101 | db_session: AsyncSession, |
| 102 | test_user: MusehubIdentity, |
| 103 | ) -> None: |
| 104 | """musehub#211's flagged-but-unverified consequence: an agent whose |
| 105 | spawner was revoked (soft-deleted) is a genuinely orphaned chain |
| 106 | with no reachable human root. This endpoint makes that state |
| 107 | queryable, even though automatic detection on revocation itself |
| 108 | is out of scope for this fix.""" |
| 109 | import datetime |
| 110 | |
| 111 | operator = await _create_identity(db_session, "revoked-op", "human") |
| 112 | orphan = await _create_identity( |
| 113 | db_session, "orphaned-agent", "agent", spawned_by=operator.handle |
| 114 | ) |
| 115 | operator.deleted_at = datetime.datetime.now(datetime.timezone.utc) |
| 116 | await db_session.commit() |
| 117 | |
| 118 | resp = await client.get(f"/api/identities/{orphan.handle}/genealogy") |
| 119 | assert resp.status_code == 200 |
| 120 | data = resp.json() |
| 121 | assert data["root_distance"] is None |
| 122 | assert data["human_ancestors"] == [] |
| 123 | |
| 124 | async def test_unknown_handle_404s(self, client: AsyncClient) -> None: |
| 125 | resp = await client.get("/api/identities/does-not-exist/genealogy") |
| 126 | assert resp.status_code == 404 |
| 127 | |
| 128 | async def test_no_auth_required( |
| 129 | self, |
| 130 | client: AsyncClient, |
| 131 | db_session: AsyncSession, |
| 132 | test_user: MusehubIdentity, |
| 133 | ) -> None: |
| 134 | """Same visibility as GET /api/identities/{handle} itself — public |
| 135 | read, no special permission, per the agreed MVP scope.""" |
| 136 | resp = await client.get(f"/api/identities/{test_user.handle}/genealogy") |
| 137 | assert resp.status_code == 200 |