diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index 4335742..a9b8d19 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -159,6 +159,19 @@ "qdrant_search_memories": ["*"], "qdrant_delete_vectors": ["Indexer"], # ========================================================================= + # NEO4J OPERATIONS - Parties (DL-15) + # ========================================================================= + "neo4j_create_party": ["Orchestrator", "CanonKeeper"], + "neo4j_get_party": ["*"], + "neo4j_list_parties": ["*"], + "neo4j_add_party_member": ["Orchestrator", "CanonKeeper"], + "neo4j_remove_party_member": ["Orchestrator", "CanonKeeper"], + "neo4j_set_active_pc": ["Orchestrator"], + "neo4j_update_party_status": ["Orchestrator", "CanonKeeper"], + "neo4j_update_party_location": ["Orchestrator", "CanonKeeper"], + "neo4j_update_party_formation": ["Orchestrator"], + "neo4j_delete_party": ["CanonKeeper"], + # ========================================================================= # COMPOSITE OPERATIONS # ========================================================================= "composite_get_entity_full": ["*"], diff --git a/packages/data-layer/src/monitor_data/schemas/__init__.py b/packages/data-layer/src/monitor_data/schemas/__init__.py index a41bf5f..f67e400 100644 --- a/packages/data-layer/src/monitor_data/schemas/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/__init__.py @@ -34,6 +34,7 @@ ProposalStatus, ProposalType, Speaker, + PartyStatus, CanonicalMetadata, BaseResponse, ) @@ -69,6 +70,7 @@ "ProposalStatus", "ProposalType", "Speaker", + "PartyStatus", "CanonicalMetadata", "BaseResponse", # Universe schemas diff --git a/packages/data-layer/src/monitor_data/schemas/base.py b/packages/data-layer/src/monitor_data/schemas/base.py index 0477d94..d470322 100644 --- a/packages/data-layer/src/monitor_data/schemas/base.py +++ b/packages/data-layer/src/monitor_data/schemas/base.py @@ -215,6 +215,17 @@ class PayoffStatus(str, Enum): ABANDONED = "abandoned" +class PartyStatus(str, Enum): + """Party activity status.""" + + TRAVELING = "traveling" + CAMPING = "camping" + IN_SCENE = "in_scene" + COMBAT = "combat" + SPLIT = "split" + RESTING = "resting" + + # ============================================================================= # BASE MODELS # ============================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/parties.py b/packages/data-layer/src/monitor_data/schemas/parties.py new file mode 100644 index 0000000..4ce1b8a --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/parties.py @@ -0,0 +1,132 @@ +""" +Pydantic schemas for Party operations (DL-15). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: neo4j_tools.py + +These schemas define the data contracts for Party CRUD operations. +Parties represent groups of player characters acting together in a story. +""" + +from datetime import datetime +from typing import Optional, List +from uuid import UUID + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import PartyStatus + + +# ============================================================================= +# PARTY MEMBER SCHEMAS +# ============================================================================= + + +class PartyMemberInfo(BaseModel): + """Information about a party member.""" + + entity_id: UUID + role: Optional[str] = Field( + None, max_length=50, description="e.g., 'leader', 'scout', 'healer'" + ) + position: Optional[int] = Field( + None, ge=0, description="Position in marching order (0-based)" + ) + joined_at: datetime + + +# ============================================================================= +# PARTY CRUD SCHEMAS +# ============================================================================= + + +class PartyCreate(BaseModel): + """Request to create a Party.""" + + story_id: UUID + name: str = Field(min_length=1, max_length=200, description="Party name") + status: PartyStatus = Field(default=PartyStatus.TRAVELING) + initial_member_ids: List[UUID] = Field( + default_factory=list, description="Initial party members (EntityInstance IDs)" + ) + active_pc_id: Optional[UUID] = Field( + None, description="Currently active PC for turn-based actions" + ) + location_id: Optional[UUID] = Field( + None, description="Current location (EntityInstance of type location)" + ) + formation: List[UUID] = Field( + default_factory=list, + description="Ordered list of entity_ids for marching order", + ) + + +class PartyUpdate(BaseModel): + """Request to update a Party.""" + + name: Optional[str] = Field(None, min_length=1, max_length=200) + status: Optional[PartyStatus] = None + location_id: Optional[UUID] = None + formation: Optional[List[UUID]] = None + + +class PartyResponse(BaseModel): + """Response with Party data.""" + + id: UUID + story_id: UUID + name: str + status: PartyStatus + active_pc_id: Optional[UUID] = None + location_id: Optional[UUID] = None + formation: List[UUID] + members: List[PartyMemberInfo] = Field( + default_factory=list, description="Current party members" + ) + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +# ============================================================================= +# PARTY MEMBER OPERATIONS +# ============================================================================= + + +class AddPartyMember(BaseModel): + """Request to add a member to a party.""" + + party_id: UUID + entity_id: UUID + role: Optional[str] = Field(None, max_length=50) + position: Optional[int] = Field(None, ge=0) + + +class RemovePartyMember(BaseModel): + """Request to remove a member from a party.""" + + party_id: UUID + entity_id: UUID + + +class SetActivePC(BaseModel): + """Request to set the active PC.""" + + party_id: UUID + entity_id: UUID + + +# ============================================================================= +# QUERY SCHEMAS +# ============================================================================= + + +class PartyFilter(BaseModel): + """Filter parameters for listing parties.""" + + story_id: Optional[UUID] = None + status: Optional[str] = None + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py index 2d20f86..5710325 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -14,7 +14,7 @@ from uuid import UUID, uuid4 from monitor_data.db.neo4j import get_neo4j_client -from monitor_data.schemas.base import CanonLevel +from monitor_data.schemas.base import CanonLevel, PartyStatus from monitor_data.schemas.universe import ( UniverseCreate, UniverseUpdate, @@ -56,6 +56,15 @@ PlotThreadStatus, ThreadDeadline, ) +from monitor_data.schemas.parties import ( + PartyCreate, + PartyResponse, + PartyFilter, + PartyMemberInfo, + AddPartyMember, + RemovePartyMember, + SetActivePC, +) # ============================================================================= @@ -2873,3 +2882,663 @@ def neo4j_list_plot_threads(params: PlotThreadFilter) -> PlotThreadListResponse: return PlotThreadListResponse( threads=threads, total=total, limit=params.limit, offset=params.offset ) + + +# ============================================================================= +# PARTY OPERATIONS (DL-15) +# ============================================================================= + + +def neo4j_create_party(params: PartyCreate) -> PartyResponse: + """ + Create a new Party node. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-15 + + Args: + params: Party creation parameters + + Returns: + PartyResponse with created party data + + Raises: + ValueError: If story_id doesn't exist or initial_member_ids invalid + """ + client = get_neo4j_client() + + # Verify story exists + verify_query = """ + MATCH (s:Story {id: $story_id}) + RETURN s.id as id + """ + result = client.execute_read(verify_query, {"story_id": str(params.story_id)}) + if not result: + raise ValueError(f"Story {params.story_id} not found") + + # Verify initial members are EntityInstances of type CHARACTER if provided + if params.initial_member_ids: + verify_members_query = """ + MATCH (e:EntityInstance) + WHERE e.id IN $member_ids AND e.entity_type = 'character' + RETURN collect(e.id) as valid_ids + """ + member_result = client.execute_read( + verify_members_query, + {"member_ids": [str(eid) for eid in params.initial_member_ids]}, + ) + valid_ids = member_result[0]["valid_ids"] if member_result else [] + if len(valid_ids) != len(params.initial_member_ids): + raise ValueError( + "All initial_member_ids must be EntityInstance nodes of type CHARACTER" + ) + + # Verify active_pc_id is in initial_member_ids if both are provided + if ( + params.active_pc_id + and params.initial_member_ids + and params.active_pc_id not in params.initial_member_ids + ): + raise ValueError("active_pc_id must be one of the initial_member_ids") + + # Create party + party_id = uuid4() + now = datetime.now(timezone.utc) + create_query = """ + MATCH (s:Story {id: $story_id}) + CREATE (p:Party { + id: $id, + story_id: $story_id, + name: $name, + status: $status, + active_pc_id: $active_pc_id, + location_id: $location_id, + formation: $formation, + created_at: $created_at, + updated_at: $updated_at + }) + CREATE (s)-[:HAS_PARTY]->(p) + RETURN p + """ + + create_params = { + "story_id": str(params.story_id), + "id": str(party_id), + "name": params.name, + "status": params.status.value, + "active_pc_id": str(params.active_pc_id) if params.active_pc_id else None, + "location_id": str(params.location_id) if params.location_id else None, + "formation": [str(eid) for eid in params.formation], + "created_at": now, + "updated_at": now, + } + + result = client.execute_write(create_query, create_params) + if not result: + raise ValueError("Failed to create party") + + # Add initial members + members = [] + if params.initial_member_ids: + for idx, entity_id in enumerate(params.initial_member_ids): + member_query = """ + MATCH (e:EntityInstance {id: $entity_id}) + MATCH (p:Party {id: $party_id}) + CREATE (e)-[r:MEMBER_OF { + role: $role, + position: $position, + joined_at: $joined_at + }]->(p) + RETURN e.id as entity_id, r + """ + member_params = { + "entity_id": str(entity_id), + "party_id": str(party_id), + "role": None, + "position": idx, + "joined_at": now, + } + member_result = client.execute_write(member_query, member_params) + if not member_result: + raise ValueError( + f"Failed to add initial member {entity_id} to party - entity may not exist" + ) + r = member_result[0]["r"] + members.append( + PartyMemberInfo( + entity_id=entity_id, + role=r.get("role"), + position=r.get("position"), + joined_at=r["joined_at"], + ) + ) + + return PartyResponse( + id=party_id, + story_id=params.story_id, + name=params.name, + status=params.status, + active_pc_id=params.active_pc_id, + location_id=params.location_id, + formation=params.formation, + members=members, + created_at=now, + updated_at=now, + ) + + +def neo4j_get_party(party_id: UUID) -> Optional[PartyResponse]: + """ + Get a party by ID with all members. + + Authority: All + Use Case: DL-15 + + Args: + party_id: Party UUID + + Returns: + PartyResponse if found, None otherwise + """ + client = get_neo4j_client() + + query = """ + MATCH (p:Party {id: $party_id}) + OPTIONAL MATCH (e:EntityInstance)-[r:MEMBER_OF]->(p) + RETURN p, + collect({ + entity_id: e.id, + role: r.role, + position: r.position, + joined_at: r.joined_at + }) as members + """ + + result = client.execute_read(query, {"party_id": str(party_id)}) + + if not result: + return None + + p = result[0]["p"] + member_data = result[0]["members"] + + # Filter out null entries from OPTIONAL MATCH + members = [] + for m in member_data: + if m.get("entity_id"): + members.append( + PartyMemberInfo( + entity_id=UUID(m["entity_id"]), + role=m.get("role"), + position=m.get("position"), + joined_at=m["joined_at"], + ) + ) + + # Parse formation + formation = [UUID(eid) for eid in p.get("formation", [])] + + return PartyResponse( + id=UUID(p["id"]), + story_id=UUID(p["story_id"]), + name=p["name"], + status=p["status"], + active_pc_id=UUID(p["active_pc_id"]) if p.get("active_pc_id") else None, + location_id=UUID(p["location_id"]) if p.get("location_id") else None, + formation=formation, + members=members, + created_at=p["created_at"], + updated_at=p.get("updated_at"), + ) + + +def neo4j_list_parties(params: PartyFilter = PartyFilter()) -> List[PartyResponse]: + """ + List parties with optional filtering. + + Authority: All + Use Case: DL-15 + + Args: + params: Filter parameters + + Returns: + List of parties + """ + client = get_neo4j_client() + + # Build WHERE clause + where_clauses = [] + query_params: Dict[str, Any] = {} + + if params.story_id: + where_clauses.append("p.story_id = $story_id") + query_params["story_id"] = str(params.story_id) + + if params.status: + where_clauses.append("p.status = $status") + query_params["status"] = params.status + + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + query = f""" + MATCH (p:Party) + {where_clause} + OPTIONAL MATCH (e:EntityInstance)-[r:MEMBER_OF]->(p) + RETURN p, + collect({{ + entity_id: e.id, + role: r.role, + position: r.position, + joined_at: r.joined_at + }}) as members + ORDER BY p.created_at DESC + SKIP $offset + LIMIT $limit + """ + + query_params["offset"] = params.offset + query_params["limit"] = params.limit + + results = client.execute_read(query, query_params) + + parties = [] + for record in results: + p = record["p"] + member_data = record["members"] + + # Filter out null entries + members = [] + for m in member_data: + if m.get("entity_id"): + members.append( + PartyMemberInfo( + entity_id=UUID(m["entity_id"]), + role=m.get("role"), + position=m.get("position"), + joined_at=m["joined_at"], + ) + ) + + formation = [UUID(eid) for eid in p.get("formation", [])] + + parties.append( + PartyResponse( + id=UUID(p["id"]), + story_id=UUID(p["story_id"]), + name=p["name"], + status=p["status"], + active_pc_id=UUID(p["active_pc_id"]) if p.get("active_pc_id") else None, + location_id=UUID(p["location_id"]) if p.get("location_id") else None, + formation=formation, + members=members, + created_at=p["created_at"], + updated_at=p.get("updated_at"), + ) + ) + + return parties + + +def neo4j_add_party_member(params: AddPartyMember) -> PartyResponse: + """ + Add a member to a party. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-15 + + Args: + params: Member addition parameters + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party or entity not found, or entity not a character + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(params.party_id) + if not party: + raise ValueError(f"Party {params.party_id} not found") + + # Verify entity is a character + verify_query = """ + MATCH (e:EntityInstance {id: $entity_id}) + WHERE e.entity_type = 'character' + RETURN e.id as id + """ + result = client.execute_read(verify_query, {"entity_id": str(params.entity_id)}) + if not result: + raise ValueError(f"Entity {params.entity_id} not found or not a character type") + + # Add member + now = datetime.now(timezone.utc) + add_query = """ + MATCH (e:EntityInstance {id: $entity_id}) + MATCH (p:Party {id: $party_id}) + MERGE (e)-[r:MEMBER_OF]->(p) + SET r.role = $role, + r.position = $position, + r.joined_at = COALESCE(r.joined_at, $joined_at), + p.updated_at = $updated_at + RETURN r + """ + + add_params = { + "entity_id": str(params.entity_id), + "party_id": str(params.party_id), + "role": params.role, + "position": params.position, + "joined_at": now, + "updated_at": now, + } + + client.execute_write(add_query, add_params) + + # Return updated party + updated_party = neo4j_get_party(params.party_id) + if updated_party is None: + raise ValueError(f"Party {params.party_id} not found after update") + return updated_party + + +def neo4j_remove_party_member(params: RemovePartyMember) -> PartyResponse: + """ + Remove a member from a party. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-15 + + Args: + params: Member removal parameters + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party not found + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(params.party_id) + if not party: + raise ValueError(f"Party {params.party_id} not found") + + # Remove member and clean up active_pc_id and formation + now = datetime.now(timezone.utc) + remove_query = """ + MATCH (e:EntityInstance {id: $entity_id})-[r:MEMBER_OF]->(p:Party {id: $party_id}) + DELETE r + WITH p, $entity_id as removed_id + SET p.updated_at = $updated_at, + p.active_pc_id = CASE + WHEN p.active_pc_id = removed_id THEN null + ELSE p.active_pc_id + END, + p.formation = [id IN p.formation WHERE id <> removed_id] + RETURN p + """ + + remove_params = { + "entity_id": str(params.entity_id), + "party_id": str(params.party_id), + "updated_at": now, + } + + client.execute_write(remove_query, remove_params) + + # Return updated party + updated_party = neo4j_get_party(params.party_id) + if updated_party is None: + raise ValueError(f"Party {params.party_id} not found after update") + return updated_party + + +def neo4j_set_active_pc(params: SetActivePC) -> PartyResponse: + """ + Set the active PC for a party. + + Authority: Orchestrator + Use Case: DL-15 + + Args: + params: Active PC parameters + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party not found or entity not a member + """ + client = get_neo4j_client() + + # Verify party exists and entity is a member + party = neo4j_get_party(params.party_id) + if not party: + raise ValueError(f"Party {params.party_id} not found") + + member_ids = {m.entity_id for m in party.members} + if params.entity_id not in member_ids: + raise ValueError( + f"Entity {params.entity_id} is not a member of party {params.party_id}" + ) + + # Update active PC + now = datetime.now(timezone.utc) + update_query = """ + MATCH (p:Party {id: $party_id}) + SET p.active_pc_id = $active_pc_id, + p.updated_at = $updated_at + RETURN p + """ + + update_params = { + "party_id": str(params.party_id), + "active_pc_id": str(params.entity_id), + "updated_at": now, + } + + client.execute_write(update_query, update_params) + + # Return updated party + updated_party = neo4j_get_party(params.party_id) + if updated_party is None: + raise ValueError(f"Party {params.party_id} not found after update") + return updated_party + + +def neo4j_update_party_status(party_id: UUID, status: PartyStatus) -> PartyResponse: + """ + Update party status. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-15 + + Args: + party_id: Party UUID + status: New PartyStatus enum value + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party not found + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(party_id) + if not party: + raise ValueError(f"Party {party_id} not found") + + # Update status + now = datetime.now(timezone.utc) + update_query = """ + MATCH (p:Party {id: $party_id}) + SET p.status = $status, + p.updated_at = $updated_at + RETURN p + """ + + update_params = { + "party_id": str(party_id), + "status": status.value, # Convert enum to string + "updated_at": now, + } + + client.execute_write(update_query, update_params) + + # Return updated party + updated_party = neo4j_get_party(party_id) + if updated_party is None: + raise ValueError(f"Party {party_id} not found after update") + return updated_party + + +def neo4j_update_party_location( + party_id: UUID, location_id: Optional[UUID] +) -> PartyResponse: + """ + Update party location. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-15 + + Args: + party_id: Party UUID + location_id: Location entity UUID (or None to clear) + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party not found + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(party_id) + if not party: + raise ValueError(f"Party {party_id} not found") + + # Update location + now = datetime.now(timezone.utc) + update_query = """ + MATCH (p:Party {id: $party_id}) + SET p.location_id = $location_id, + p.updated_at = $updated_at + RETURN p + """ + + update_params = { + "party_id": str(party_id), + "location_id": str(location_id) if location_id else None, + "updated_at": now, + } + + client.execute_write(update_query, update_params) + + # Return updated party + updated_party = neo4j_get_party(party_id) + if updated_party is None: + raise ValueError(f"Party {party_id} not found after update") + return updated_party + + +def neo4j_update_party_formation( + party_id: UUID, formation: List[UUID] +) -> PartyResponse: + """ + Update party marching order formation. + + Authority: Orchestrator + Use Case: DL-15 + + Args: + party_id: Party UUID + formation: Ordered list of entity IDs + + Returns: + Updated PartyResponse + + Raises: + ValueError: If party not found + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(party_id) + if not party: + raise ValueError(f"Party {party_id} not found") + + # Verify all formation IDs are party members + if formation: + member_ids = {m.entity_id for m in party.members} + invalid_ids = [eid for eid in formation if eid not in member_ids] + if invalid_ids: + raise ValueError(f"Formation contains non-member entity IDs: {invalid_ids}") + + # Update formation + now = datetime.now(timezone.utc) + update_query = """ + MATCH (p:Party {id: $party_id}) + SET p.formation = $formation, + p.updated_at = $updated_at + RETURN p + """ + + update_params = { + "party_id": str(party_id), + "formation": [str(eid) for eid in formation], + "updated_at": now, + } + + client.execute_write(update_query, update_params) + + # Return updated party + updated_party = neo4j_get_party(party_id) + if updated_party is None: + raise ValueError(f"Party {party_id} not found after update") + return updated_party + + +def neo4j_delete_party(party_id: UUID) -> Dict[str, Any]: + """ + Delete a party and all MEMBER_OF relationships. + + Authority: CanonKeeper only + Use Case: DL-15 + + Args: + party_id: Party UUID + + Returns: + Dict with deletion status + + Raises: + ValueError: If party not found + """ + client = get_neo4j_client() + + # Verify party exists + party = neo4j_get_party(party_id) + if not party: + raise ValueError(f"Party {party_id} not found") + + # Delete party and relationships + delete_query = """ + MATCH (p:Party {id: $party_id}) + DETACH DELETE p + RETURN count(p) as deleted_count + """ + + result = client.execute_write(delete_query, {"party_id": str(party_id)}) + + return { + "deleted": True, + "party_id": str(party_id), + "deleted_count": result[0]["deleted_count"] if result else 0, + } diff --git a/packages/data-layer/tests/test_tools/test_party_tools.py b/packages/data-layer/tests/test_tools/test_party_tools.py new file mode 100644 index 0000000..4f2942d --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_party_tools.py @@ -0,0 +1,757 @@ +""" +Unit tests for Neo4j party operations (DL-15). + +Tests cover: +- neo4j_create_party +- neo4j_get_party +- neo4j_list_parties +- neo4j_add_party_member +- neo4j_remove_party_member +- neo4j_set_active_pc +- neo4j_update_party_status +- neo4j_update_party_location +- neo4j_update_party_formation +- neo4j_delete_party +""" + +from typing import Dict, Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 +from datetime import datetime, timezone + +import pytest + +from monitor_data.schemas.parties import ( + PartyCreate, + PartyFilter, + AddPartyMember, + RemovePartyMember, + SetActivePC, +) +from monitor_data.schemas.base import PartyStatus +from monitor_data.tools.neo4j_tools import ( + neo4j_create_party, + neo4j_get_party, + neo4j_list_parties, + neo4j_add_party_member, + neo4j_remove_party_member, + neo4j_set_active_pc, + neo4j_update_party_status, + neo4j_update_party_location, + neo4j_update_party_formation, + neo4j_delete_party, +) + + +# ============================================================================= +# TESTS: neo4j_create_party +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_party_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test successful party creation.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock story exists + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], # verify story exists + ] + + # Mock party creation + party_id = uuid4() + party_data = { + "id": str(party_id), + "story_id": story_data["id"], + "name": "The Fellowship", + "status": "traveling", + "active_pc_id": None, + "location_id": None, + "formation": [], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + mock_neo4j_client.execute_write.return_value = [{"p": party_data}] + + params = PartyCreate( + story_id=UUID(story_data["id"]), + name="The Fellowship", + status=PartyStatus.TRAVELING, + ) + + result = neo4j_create_party(params) + + assert result.name == "The Fellowship" + assert result.story_id == UUID(story_data["id"]) + assert result.status == PartyStatus.TRAVELING + assert len(result.members) == 0 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_party_with_initial_members( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test party creation with initial members.""" + mock_get_client.return_value = mock_neo4j_client + + member1_id = uuid4() + member2_id = uuid4() + + # Mock story exists and members are valid characters + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], # verify story exists + [{"valid_ids": [str(member1_id), str(member2_id)]}], # verify members + ] + + # Mock party and member creation + party_id = uuid4() + party_data = { + "id": str(party_id), + "story_id": story_data["id"], + "name": "The Crew", + "status": "traveling", + "active_pc_id": str(member1_id), + "location_id": None, + "formation": [str(member1_id), str(member2_id)], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + mock_neo4j_client.execute_write.side_effect = [ + [{"p": party_data}], # party creation + [ + { + "entity_id": str(member1_id), + "r": { + "role": None, + "position": 0, + "joined_at": datetime.now(timezone.utc), + }, + } + ], # member 1 + [ + { + "entity_id": str(member2_id), + "r": { + "role": None, + "position": 1, + "joined_at": datetime.now(timezone.utc), + }, + } + ], # member 2 + ] + + params = PartyCreate( + story_id=UUID(story_data["id"]), + name="The Crew", + initial_member_ids=[member1_id, member2_id], + active_pc_id=member1_id, + formation=[member1_id, member2_id], + ) + + result = neo4j_create_party(params) + + assert result.name == "The Crew" + assert len(result.members) == 2 + assert result.active_pc_id == member1_id + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_party_invalid_story(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test party creation with invalid story_id.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + params = PartyCreate( + story_id=uuid4(), + name="Test Party", + ) + + with pytest.raises(ValueError, match="Story .* not found"): + neo4j_create_party(params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_party_invalid_members( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test party creation with invalid member IDs.""" + mock_get_client.return_value = mock_neo4j_client + + member_id = uuid4() + + # Mock story exists but members are invalid + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], # verify story + [{"valid_ids": []}], # no valid members + ] + + params = PartyCreate( + story_id=UUID(story_data["id"]), + name="Test Party", + initial_member_ids=[member_id], + ) + + with pytest.raises(ValueError, match="must be EntityInstance nodes"): + neo4j_create_party(params) + + +# ============================================================================= +# TESTS: neo4j_get_party +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_party_exists( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test getting an existing party.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + story_id = uuid4() + member_id = uuid4() + + party_data = { + "id": str(party_id), + "story_id": str(story_id), + "name": "Test Party", + "status": "traveling", + "active_pc_id": str(member_id), + "location_id": None, + "formation": [str(member_id)], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + + mock_neo4j_client.execute_read.return_value = [ + { + "p": party_data, + "members": [ + { + "entity_id": str(member_id), + "role": "leader", + "position": 0, + "joined_at": datetime.now(timezone.utc), + } + ], + } + ] + + result = neo4j_get_party(party_id) + + assert result is not None + assert result.id == party_id + assert result.name == "Test Party" + assert len(result.members) == 1 + assert result.members[0].entity_id == member_id + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_party_not_found(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test getting a non-existent party.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + result = neo4j_get_party(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: neo4j_list_parties +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_parties_no_filter( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test listing all parties without filters.""" + mock_get_client.return_value = mock_neo4j_client + + party1_id = uuid4() + party2_id = uuid4() + story_id = uuid4() + + mock_neo4j_client.execute_read.return_value = [ + { + "p": { + "id": str(party1_id), + "story_id": str(story_id), + "name": "Party 1", + "status": "traveling", + "active_pc_id": None, + "location_id": None, + "formation": [], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, + "members": [], + }, + { + "p": { + "id": str(party2_id), + "story_id": str(story_id), + "name": "Party 2", + "status": "combat", + "active_pc_id": None, + "location_id": None, + "formation": [], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, + "members": [], + }, + ] + + result = neo4j_list_parties() + + assert len(result) == 2 + assert result[0].name == "Party 1" + assert result[1].name == "Party 2" + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_parties_by_story( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test listing parties filtered by story_id.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + + mock_neo4j_client.execute_read.return_value = [ + { + "p": { + "id": str(party_id), + "story_id": story_data["id"], + "name": "Story Party", + "status": "traveling", + "active_pc_id": None, + "location_id": None, + "formation": [], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }, + "members": [], + } + ] + + filters = PartyFilter(story_id=UUID(story_data["id"])) + result = neo4j_list_parties(filters) + + assert len(result) == 1 + assert result[0].story_id == UUID(story_data["id"]) + + +# ============================================================================= +# TESTS: neo4j_add_party_member +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_add_party_member_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully adding a member to a party.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + entity_id = uuid4() + + # Mock party exists + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + # Mock entity is valid character + mock_neo4j_client.execute_read.return_value = [{"id": str(entity_id)}] + mock_neo4j_client.execute_write.return_value = [{"r": {}}] + + params = AddPartyMember( + party_id=party_id, + entity_id=entity_id, + role="scout", + position=0, + ) + + result = neo4j_add_party_member(params) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +def test_add_party_member_party_not_found(mock_get_party: Mock, mock_get_client: Mock): + """Test adding member to non-existent party.""" + mock_get_party.return_value = None + + params = AddPartyMember( + party_id=uuid4(), + entity_id=uuid4(), + ) + + with pytest.raises(ValueError, match="Party .* not found"): + neo4j_add_party_member(params) + + +# ============================================================================= +# TESTS: neo4j_remove_party_member +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_remove_party_member_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully removing a member from a party.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + entity_id = uuid4() + + # Mock party exists + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"p": {}}] + + params = RemovePartyMember( + party_id=party_id, + entity_id=entity_id, + ) + + result = neo4j_remove_party_member(params) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +# ============================================================================= +# TESTS: neo4j_set_active_pc +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_set_active_pc_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully setting active PC.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + entity_id = uuid4() + + # Mock party exists with member + from monitor_data.schemas.parties import PartyResponse, PartyMemberInfo + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[ + PartyMemberInfo( + entity_id=entity_id, + role="leader", + position=0, + joined_at=datetime.now(timezone.utc), + ) + ], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"p": {}}] + + params = SetActivePC( + party_id=party_id, + entity_id=entity_id, + ) + + result = neo4j_set_active_pc(params) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +def test_set_active_pc_not_a_member(mock_get_party: Mock): + """Test setting active PC to non-member.""" + party_id = uuid4() + entity_id = uuid4() + + # Mock party exists without this member + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], # Empty members + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + params = SetActivePC( + party_id=party_id, + entity_id=entity_id, + ) + + with pytest.raises(ValueError, match="is not a member"): + neo4j_set_active_pc(params) + + +# ============================================================================= +# TESTS: neo4j_update_party_status +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_party_status_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test updating party status.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"p": {}}] + + result = neo4j_update_party_status(party_id, PartyStatus.COMBAT) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +# ============================================================================= +# TESTS: neo4j_delete_party +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_party_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully deleting a party.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"deleted_count": 1}] + + result = neo4j_delete_party(party_id) + + assert result["deleted"] is True + assert result["party_id"] == str(party_id) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +def test_delete_party_not_found(mock_get_party: Mock, mock_get_client: Mock): + """Test deleting a non-existent party.""" + mock_get_party.return_value = None + + with pytest.raises(ValueError, match="Party .* not found"): + neo4j_delete_party(uuid4()) + + +# ============================================================================= +# TESTS: neo4j_update_party_location +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_party_location_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully updating party location.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + location_id = uuid4() + + from monitor_data.schemas.parties import PartyResponse + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"p": {}}] + + result = neo4j_update_party_location(party_id, location_id) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +# ============================================================================= +# TESTS: neo4j_update_party_formation +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_party_formation_success( + mock_get_client: Mock, + mock_get_party: Mock, + mock_neo4j_client: Mock, +): + """Test successfully updating party formation.""" + mock_get_client.return_value = mock_neo4j_client + + party_id = uuid4() + member1_id = uuid4() + member2_id = uuid4() + + from monitor_data.schemas.parties import PartyResponse, PartyMemberInfo + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[ + PartyMemberInfo( + entity_id=member1_id, + role="leader", + position=0, + joined_at=datetime.now(timezone.utc), + ), + PartyMemberInfo( + entity_id=member2_id, + role="scout", + position=1, + joined_at=datetime.now(timezone.utc), + ), + ], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + mock_neo4j_client.execute_write.return_value = [{"p": {}}] + + result = neo4j_update_party_formation(party_id, [member1_id, member2_id]) + + assert result.id == party_id + assert mock_neo4j_client.execute_write.called + + +@patch("monitor_data.tools.neo4j_tools.neo4j_get_party") +def test_update_party_formation_invalid_member( + mock_get_party: Mock, +): + """Test formation update with non-member entity.""" + party_id = uuid4() + member1_id = uuid4() + invalid_id = uuid4() + + from monitor_data.schemas.parties import PartyResponse, PartyMemberInfo + + mock_party = PartyResponse( + id=party_id, + story_id=uuid4(), + name="Test Party", + status=PartyStatus.TRAVELING, + formation=[], + members=[ + PartyMemberInfo( + entity_id=member1_id, + role="leader", + position=0, + joined_at=datetime.now(timezone.utc), + ), + ], + created_at=datetime.now(timezone.utc), + ) + mock_get_party.return_value = mock_party + + with pytest.raises(ValueError, match="Formation contains non-member entity IDs"): + neo4j_update_party_formation(party_id, [member1_id, invalid_id])