gabriel / musehub public
test_identity_genealogy_api.py python
137 lines 5.1 KB
Raw
sha256:1a3ef15ee7610ff3777347c20b1f89ac9988b93b0eccc32f2e365a927b46ed68 feat: expose IdentityGraphService/RootDistanceIndex via a g… Sonnet 5 patch 2 days ago
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
File History 1 commit
sha256:1a3ef15ee7610ff3777347c20b1f89ac9988b93b0eccc32f2e365a927b46ed68 feat: expose IdentityGraphService/RootDistanceIndex via a g… Sonnet 5 patch 2 days ago