"""TDD — musehub#211: genealogy query API surface. ``IdentityGraphService``/``RootDistanceIndex`` (I2) is a real, correct, well-tested in-memory engine (see ``tests/test_graph_depth.py``, ``tests/test_graph_service.py``) that was never wired into the live application — no production code path computes root-distance or exposes it to callers. This adds ``GET /api/identities/{handle}/genealogy``: builds the full persisted spawn graph from ``musehub_identities`` rows, runs ``RootDistanceIndex`` against it, and returns the target identity's distance to the nearest human root plus its set of human ancestors. Scope (per the ticket's own deferred items, explicitly NOT covered here): - ``QuorumEngine``/org-quorum queries (I3) — separate concern. - Cascading/flagging orphaned agents on operator revocation — separate concern; this endpoint makes the orphan state *queryable*, which is the minimal slice that closes the ticket's core gap. """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_identity_models import MusehubIdentity from musehub.core.genesis import compute_identity_id async def _create_identity( db_session: AsyncSession, handle: str, identity_type: str, spawned_by: str | None = None, ) -> MusehubIdentity: identity = MusehubIdentity( identity_id=compute_identity_id(handle.encode()), handle=handle, identity_type=identity_type, spawned_by=spawned_by, ) db_session.add(identity) await db_session.commit() await db_session.refresh(identity) await db_session.commit() return identity @pytest.mark.asyncio class TestGenealogyEndpoint: async def test_human_root_distance_is_zero( self, client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, ) -> None: resp = await client.get(f"/api/identities/{test_user.handle}/genealogy") assert resp.status_code == 200 data = resp.json() assert data["root_distance"] == 0 assert data["human_ancestors"] == [test_user.handle] async def test_direct_agent_spawn_distance_is_one( self, client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, ) -> None: agent_a = await _create_identity( db_session, "ep19-agent-a", "agent", spawned_by=test_user.handle ) resp = await client.get(f"/api/identities/{agent_a.handle}/genealogy") assert resp.status_code == 200 data = resp.json() assert data["root_distance"] == 1 assert data["human_ancestors"] == [test_user.handle] async def test_two_hop_spawn_chain_distance_is_two( self, client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, ) -> None: """Reproduces the exact chain from the ticket's own live repro: gabriel -> ep19-agent-a -> ep19-agent-b.""" agent_a = await _create_identity( db_session, "ep19-agent-a", "agent", spawned_by=test_user.handle ) agent_b = await _create_identity( db_session, "ep19-agent-b", "agent", spawned_by=agent_a.handle ) resp = await client.get(f"/api/identities/{agent_b.handle}/genealogy") assert resp.status_code == 200 data = resp.json() assert data["root_distance"] == 2 assert data["human_ancestors"] == [test_user.handle] async def test_orphaned_agent_has_null_distance( self, client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, ) -> None: """musehub#211's flagged-but-unverified consequence: an agent whose spawner was revoked (soft-deleted) is a genuinely orphaned chain with no reachable human root. This endpoint makes that state queryable, even though automatic detection on revocation itself is out of scope for this fix.""" import datetime operator = await _create_identity(db_session, "revoked-op", "human") orphan = await _create_identity( db_session, "orphaned-agent", "agent", spawned_by=operator.handle ) operator.deleted_at = datetime.datetime.now(datetime.timezone.utc) await db_session.commit() resp = await client.get(f"/api/identities/{orphan.handle}/genealogy") assert resp.status_code == 200 data = resp.json() assert data["root_distance"] is None assert data["human_ancestors"] == [] async def test_unknown_handle_404s(self, client: AsyncClient) -> None: resp = await client.get("/api/identities/does-not-exist/genealogy") assert resp.status_code == 404 async def test_no_auth_required( self, client: AsyncClient, db_session: AsyncSession, test_user: MusehubIdentity, ) -> None: """Same visibility as GET /api/identities/{handle} itself — public read, no special permission, per the agreed MVP scope.""" resp = await client.get(f"/api/identities/{test_user.handle}/genealogy") assert resp.status_code == 200