"""Phase 4 — Org creation, member_of relationships, and hub routes. TDD regression suite: every test starts RED and turns GREEN as the feature is implemented. Their permanent role is to prevent regressions. What this phase covers: - POST /api/orgs creates an org identity + identity repo - Org IdentityRecord has type="org", pubkey=None, quorum=threshold - POST /api/orgs/{org}/members/{handle} commits a member_of RelationshipRecord - GET /api/orgs/{org}/members reads members from the identity repo HEAD - DELETE /api/orgs/{org}/members/{handle} removes the member (new commit) - 409 on duplicate org handle - 404 on unknown org/member """ from __future__ import annotations import json import pytest import msgpack from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot from musehub.types.json_types import JSONObject, StrDict # ── helpers ─────────────────────────────────────────────────────────────────── async def _create_org( client: AsyncClient, auth_headers: StrDict, handle: str, display_name: str = "Test Org", quorum: int = 1, ) -> JSONObject: r = await client.post( "/api/orgs", json={"handle": handle, "display_name": display_name, "quorum": quorum}, headers=auth_headers, ) assert r.status_code == 201, f"create org failed {r.status_code}: {r.text}" return r.json() async def _add_member( client: AsyncClient, auth_headers: StrDict, org: str, member: str, weight: str = "write", ) -> JSONObject: r = await client.post( f"/api/orgs/{org}/members/{member}", json={"weight": weight}, headers=auth_headers, ) assert r.status_code == 201, f"add member failed {r.status_code}: {r.text}" return r.json() async def _get_identity_repo_head_manifest( session: AsyncSession, owner: str ) -> JSONObject: repo_result = await session.execute( select(MusehubRepo).where( MusehubRepo.owner == owner, MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one() branch_result = await session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo.repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one() commit = await session.get(MusehubCommit, branch.head_commit_id) snap = await session.get(MusehubSnapshot, commit.snapshot_id) return msgpack.unpackb(snap.manifest_blob, raw=False) async def _read_object(session: AsyncSession, object_id: str) -> bytes: from musehub.storage.backends import read_object_bytes obj = await session.get(MusehubObject, object_id) assert obj is not None raw = await read_object_bytes(obj) assert raw is not None return raw # ═══════════════════════════════════════════════════════════════════════════════ # 1. POST /api/orgs — org creation # ═══════════════════════════════════════════════════════════════════════════════ class TestOrgCreation: async def test_create_org_returns_201( self, client: AsyncClient, auth_headers: StrDict ) -> None: r = await client.post( "/api/orgs", json={"handle": "test-org4a", "display_name": "Test Org 4A", "quorum": 1}, headers=auth_headers, ) assert r.status_code == 201, f"{r.status_code}: {r.text}" async def test_create_org_response_has_correct_handle( self, client: AsyncClient, auth_headers: StrDict ) -> None: r = await client.post( "/api/orgs", json={"handle": "test-org4b", "display_name": "Org 4B", "quorum": 1}, headers=auth_headers, ) assert r.status_code == 201 assert r.json()["handle"] == "test-org4b" async def test_create_org_response_type_is_org( self, client: AsyncClient, auth_headers: StrDict ) -> None: r = await client.post( "/api/orgs", json={"handle": "test-org4c", "display_name": "Org 4C", "quorum": 1}, headers=auth_headers, ) assert r.status_code == 201 assert r.json()["identity_type"] == "org" async def test_create_org_creates_identity_repo( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession ) -> None: await _create_org(client, auth_headers, "test-org4d") result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "test-org4d", MusehubRepo.slug == "identity", ) ) repo = result.scalar_one_or_none() assert repo is not None, "Org must have an identity repo created on registration." assert repo.domain_id == "identity" async def test_create_org_identity_record_type_is_org( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession ) -> None: await _create_org(client, auth_headers, "test-org4e", quorum=2) manifest = await _get_identity_repo_head_manifest(db_session, "test-org4e") file_path = f"identities/test-org4e.json" assert file_path in manifest raw = await _read_object(db_session, manifest[file_path]) record = json.loads(raw) assert record["type"] == "org" assert record["pubkey"] is None assert record["quorum"] == 2 async def test_create_org_duplicate_handle_returns_409( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4f") r = await client.post( "/api/orgs", json={"handle": "test-org4f", "display_name": "Dup", "quorum": 1}, headers=auth_headers, ) assert r.status_code == 409, f"Expected 409 for duplicate handle, got {r.status_code}" async def test_create_org_requires_auth( self, client: AsyncClient ) -> None: r = await client.post( "/api/orgs", json={"handle": "test-org4g", "display_name": "No Auth", "quorum": 1}, ) assert r.status_code in (401, 403), f"Expected 401/403 without auth, got {r.status_code}" # ═══════════════════════════════════════════════════════════════════════════════ # 2. POST /api/orgs/{org}/members/{handle} — add member # ═══════════════════════════════════════════════════════════════════════════════ class TestAddOrgMember: async def test_add_member_returns_201( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4h") r = await client.post( "/api/orgs/test-org4h/members/testuser", json={"weight": "write"}, headers=auth_headers, ) assert r.status_code == 201, f"{r.status_code}: {r.text}" async def test_add_member_commits_relationship_to_org_identity_repo( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession ) -> None: await _create_org(client, auth_headers, "test-org4i") await _add_member(client, auth_headers, "test-org4i", "testuser") manifest = await _get_identity_repo_head_manifest(db_session, "test-org4i") rel_path = "relationships/testuser--member_of--test-org4i.json" assert rel_path in manifest, ( f"Expected {rel_path!r} in org identity repo manifest, got: {list(manifest)!r}" ) async def test_add_member_relationship_record_content( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession ) -> None: await _create_org(client, auth_headers, "test-org4j") await _add_member(client, auth_headers, "test-org4j", "testuser", weight="admin") manifest = await _get_identity_repo_head_manifest(db_session, "test-org4j") rel_path = "relationships/testuser--member_of--test-org4j.json" raw = await _read_object(db_session, manifest[rel_path]) record = json.loads(raw) assert record["from_handle"] == "testuser" assert record["to_handle"] == "test-org4j" assert record["edge_type"] == "member_of" assert record["weight"] == "admin" async def test_add_member_unknown_org_returns_404( self, client: AsyncClient, auth_headers: StrDict ) -> None: r = await client.post( "/api/orgs/nonexistent-org/members/testuser", json={"weight": "write"}, headers=auth_headers, ) assert r.status_code == 404, f"Expected 404 for unknown org, got {r.status_code}" async def test_add_member_requires_auth( self, client: AsyncClient ) -> None: r = await client.post( "/api/orgs/any-org/members/testuser", json={"weight": "write"}, ) assert r.status_code in (401, 403) # ═══════════════════════════════════════════════════════════════════════════════ # 3. GET /api/orgs/{org}/members — list members # ═══════════════════════════════════════════════════════════════════════════════ class TestListOrgMembers: async def test_list_members_empty_after_creation( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4l") r = await client.get("/api/orgs/test-org4l/members", headers=auth_headers) assert r.status_code == 200 assert r.json()["members"] == [] async def test_list_members_includes_added_member( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4m") await _add_member(client, auth_headers, "test-org4m", "testuser") r = await client.get("/api/orgs/test-org4m/members", headers=auth_headers) assert r.status_code == 200 members = r.json()["members"] handles = [m["from_handle"] for m in members] assert "testuser" in handles async def test_list_members_unknown_org_returns_404( self, client: AsyncClient, auth_headers: StrDict ) -> None: r = await client.get("/api/orgs/nonexistent-org/members", headers=auth_headers) assert r.status_code == 404 # ═══════════════════════════════════════════════════════════════════════════════ # 4. DELETE /api/orgs/{org}/members/{handle} — remove member # ═══════════════════════════════════════════════════════════════════════════════ class TestRemoveOrgMember: async def test_remove_member_returns_204( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4n") await _add_member(client, auth_headers, "test-org4n", "testuser") r = await client.delete( "/api/orgs/test-org4n/members/testuser", headers=auth_headers, ) assert r.status_code == 204, f"{r.status_code}: {r.text}" async def test_remove_member_absent_from_members_list( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4o") await _add_member(client, auth_headers, "test-org4o", "testuser") await client.delete( "/api/orgs/test-org4o/members/testuser", headers=auth_headers ) r = await client.get("/api/orgs/test-org4o/members", headers=auth_headers) handles = [m["from_handle"] for m in r.json()["members"]] assert "testuser" not in handles, ( f"'testuser' should be absent after removal, got members: {handles}" ) async def test_remove_member_relationship_file_absent_from_repo( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession ) -> None: await _create_org(client, auth_headers, "test-org4p") await _add_member(client, auth_headers, "test-org4p", "testuser") await client.delete( "/api/orgs/test-org4p/members/testuser", headers=auth_headers ) manifest = await _get_identity_repo_head_manifest(db_session, "test-org4p") rel_path = "relationships/testuser--member_of--test-org4p.json" assert rel_path not in manifest, ( f"Relationship file {rel_path!r} must be absent from identity repo HEAD after removal." ) async def test_remove_nonexistent_member_returns_404( self, client: AsyncClient, auth_headers: StrDict ) -> None: await _create_org(client, auth_headers, "test-org4q") r = await client.delete( "/api/orgs/test-org4q/members/nobody", headers=auth_headers, ) assert r.status_code == 404 async def test_remove_member_requires_auth( self, client: AsyncClient ) -> None: r = await client.delete("/api/orgs/any-org/members/testuser") assert r.status_code in (401, 403)