From d681909b018c5b21fc8223b8452bdb339bb86cd4 Mon Sep 17 00:00:00 2001 From: spuentesp Date: Tue, 6 Jan 2026 11:07:45 -0300 Subject: [PATCH 1/2] feat(data-layer): DL-16 - Manage Party Inventory & Splits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MongoDB operations for shared party inventory and party split tracking, supporting common RPG patterns like shared gold, party loot, and split-party adventures. ## Party Inventory (MongoDB) New schemas (party_inventory.py): - ItemCategory enum: weapons, armor, consumables, treasure, quest_items, misc - InventoryItem: Item with quantity, category, value, notes - PartyInventoryCreate/Response: Full inventory with gold and items - AddInventoryItemRequest/RemoveInventoryItemRequest: Item management - UpdateGoldRequest: Gold tracking with reason field MongoDB tools (mongodb_tools.py): - mongodb_create_party_inventory: Create inventory with initial items/gold - mongodb_get_party_inventory: Retrieve full inventory - mongodb_add_inventory_item: Add item or increment quantity - mongodb_remove_inventory_item: Remove item or decrement quantity - mongodb_update_party_gold: Add/subtract gold with validation - mongodb_transfer_item: Validate transfers (character inventory placeholder) ## Party Splits (MongoDB) New schemas (party_inventory.py): - SplitStatus enum: active, resolved - SubParty: Name, member_ids, location_id, purpose - PartySplitCreate/Response: Split with sub-parties array - ResolvePartySplitRequest: Mark split as resolved - ActiveSplitsResponse/SplitHistoryResponse: Query splits MongoDB tools (mongodb_tools.py): - mongodb_create_party_split: Create split with 2+ sub-parties - mongodb_get_active_splits: Get all active splits for party - mongodb_resolve_party_split: Mark split as resolved - mongodb_get_split_history: Get all splits with pagination ## Authority Matrix (auth.py) Added 10 party inventory operations: - Create/add/remove/update: Orchestrator, CanonKeeper - Get/list operations: All agents (*) - Transfer: Orchestrator only ## Tests (test_party_inventory_tools.py) Added 26 comprehensive tests covering: - Inventory CRUD: create, get, initial items - Item management: add (new/existing), remove (partial/full), errors - Gold tracking: add, subtract, insufficient gold validation - Transfer validation: character inventory not implemented check - Split lifecycle: create, get active, resolve, history with pagination ## Fixes - Fixed quantity validation in mongodb_remove_inventory_item (check insufficient before removal) - Fixed Pydantic v2 deprecation warnings (min_items → min_length) - Fixed mypy type errors in mongodb_resolve_party_split (None check after find_one) ## Architecture Decisions - Dual collections: party_inventories (items/gold) and party_splits (temporal divisions) - Gold stored as integer (copper pieces) for precision - Incremental item management avoids full document rewrites - Transfer tool validates but defers character inventory to future implementation Implements: DL-16 Depends on: DL-15 (Parties), DL-2 (Entities) All 360 tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mypy.ini | 5 + .../src/monitor_data/middleware/auth.py | 13 + .../src/monitor_data/schemas/__init__.py | 39 + .../monitor_data/schemas/party_inventory.py | 248 ++++ .../src/monitor_data/tools/mongodb_tools.py | 604 +++++++++ .../test_tools/test_party_inventory_tools.py | 1140 +++++++++++++++++ 6 files changed, 2049 insertions(+) create mode 100644 mypy.ini create mode 100644 packages/data-layer/src/monitor_data/schemas/party_inventory.py create mode 100644 packages/data-layer/tests/test_tools/test_party_inventory_tools.py diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..e15e01d --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.11 + +# Skip type-checking tests to avoid pydantic dataclass inference issues. +exclude = (?x)(^packages/data-layer/tests|^packages/agents/tests|^packages/cli/tests) diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index e029ee1..f9d0c24 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -199,6 +199,19 @@ "qdrant_embed_memory": ["*"], "qdrant_search_memories": ["*"], # ========================================================================= + # MONGODB OPERATIONS - Party Inventory & Splits (DL-16) + # ========================================================================= + "mongodb_create_party_inventory": ["Orchestrator", "CanonKeeper"], + "mongodb_get_party_inventory": ["*"], + "mongodb_add_inventory_item": ["Orchestrator", "CanonKeeper"], + "mongodb_remove_inventory_item": ["Orchestrator", "CanonKeeper"], + "mongodb_update_party_gold": ["Orchestrator", "CanonKeeper"], + "mongodb_transfer_item": ["Orchestrator"], + "mongodb_create_party_split": ["Orchestrator", "CanonKeeper"], + "mongodb_get_active_splits": ["*"], + "mongodb_resolve_party_split": ["Orchestrator", "CanonKeeper"], + "mongodb_get_split_history": ["*"], + # ========================================================================= # 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 f69e3ef..2cb4fbe 100644 --- a/packages/data-layer/src/monitor_data/schemas/__init__.py +++ b/packages/data-layer/src/monitor_data/schemas/__init__.py @@ -110,6 +110,26 @@ MemorySearchResult, MemorySearchResponse, ) +from monitor_data.schemas.party_inventory import ( + ItemCategory, + TransferSourceType, + TransferTargetType, + SplitStatus, + InventoryItem, + PartyInventoryCreate, + PartyInventoryResponse, + AddInventoryItemRequest, + RemoveInventoryItemRequest, + TransferItemRequest, + UpdateGoldRequest, + SubParty, + PartySplitCreate, + PartySplitResponse, + ResolvePartySplitRequest, + ActiveSplitsResponse, + SplitHistoryFilter, + SplitHistoryResponse, +) # from monitor_data.schemas.sources import * # from monitor_data.schemas.queries import * @@ -195,4 +215,23 @@ "MemorySearchRequest", "MemorySearchResult", "MemorySearchResponse", + # Party Inventory schemas + "ItemCategory", + "TransferSourceType", + "TransferTargetType", + "SplitStatus", + "InventoryItem", + "PartyInventoryCreate", + "PartyInventoryResponse", + "AddInventoryItemRequest", + "RemoveInventoryItemRequest", + "TransferItemRequest", + "UpdateGoldRequest", + "SubParty", + "PartySplitCreate", + "PartySplitResponse", + "ResolvePartySplitRequest", + "ActiveSplitsResponse", + "SplitHistoryFilter", + "SplitHistoryResponse", ] diff --git a/packages/data-layer/src/monitor_data/schemas/party_inventory.py b/packages/data-layer/src/monitor_data/schemas/party_inventory.py new file mode 100644 index 0000000..3b0fa75 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/party_inventory.py @@ -0,0 +1,248 @@ +""" +Pydantic schemas for Party Inventory and Split operations (DL-16). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime, enum) and base schemas +CALLED BY: mongodb_tools.py + +These schemas define the data contracts for party inventory management and +party split tracking. Party inventory holds items owned collectively by the +party (not individual characters). Party splits track when a party temporarily +divides and later rejoins. +""" + +from datetime import datetime +from enum import Enum +from typing import Optional, List, Dict, Any +from uuid import UUID + +from pydantic import BaseModel, Field + + +# ============================================================================= +# ENUMS +# ============================================================================= + + +class ItemCategory(str, Enum): + """Item category classification for party inventory.""" + + WEAPONS = "weapons" + ARMOR = "armor" + CONSUMABLES = "consumables" + TREASURE = "treasure" + QUEST_ITEMS = "quest_items" + MISC = "misc" + + +class TransferSourceType(str, Enum): + """Source type for inventory transfers.""" + + PARTY = "party" + CHARACTER = "character" + + +class TransferTargetType(str, Enum): + """Target type for inventory transfers.""" + + PARTY = "party" + CHARACTER = "character" + + +class SplitStatus(str, Enum): + """Status of a party split.""" + + ACTIVE = "active" + RESOLVED = "resolved" + + +# ============================================================================= +# INVENTORY ITEM SCHEMAS +# ============================================================================= + + +class InventoryItem(BaseModel): + """A single item in the party inventory.""" + + name: str = Field(min_length=1, max_length=200, description="Item name") + quantity: int = Field(ge=1, description="Number of items") + category: ItemCategory = Field(default=ItemCategory.MISC) + value: Optional[int] = Field( + None, + ge=0, + description="Item value in copper pieces (optional, for tracking wealth)", + ) + notes: Optional[str] = Field( + None, max_length=500, description="Notes about the item" + ) + added_at: datetime = Field(description="When the item was added to inventory") + + +# ============================================================================= +# PARTY INVENTORY CRUD SCHEMAS +# ============================================================================= + + +class PartyInventoryCreate(BaseModel): + """Request to create a party inventory.""" + + party_id: UUID = Field(description="Party this inventory belongs to") + initial_gold: int = Field( + default=0, ge=0, description="Initial gold in copper pieces" + ) + initial_items: List[Dict[str, Any]] = Field( + default_factory=list, + description="Initial items [{name, quantity, category?, value?, notes?}]", + ) + + +class PartyInventoryResponse(BaseModel): + """Response with party inventory data.""" + + inventory_id: UUID + party_id: UUID + gold: int = Field(description="Gold in copper pieces") + items: List[InventoryItem] + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +# ============================================================================= +# INVENTORY OPERATIONS +# ============================================================================= + + +class AddInventoryItemRequest(BaseModel): + """Request to add an item to party inventory.""" + + party_id: UUID + item_name: str = Field(min_length=1, max_length=200) + quantity: int = Field(ge=1, default=1) + category: Optional[ItemCategory] = Field(None) + value: Optional[int] = Field(None, ge=0) + notes: Optional[str] = Field(None, max_length=500) + + +class RemoveInventoryItemRequest(BaseModel): + """Request to remove an item from party inventory.""" + + party_id: UUID + item_name: str = Field(min_length=1, max_length=200) + quantity: Optional[int] = Field( + None, + ge=1, + description="Quantity to remove (if None or >= current quantity, removes all)", + ) + + +class TransferItemRequest(BaseModel): + """Request to transfer an item between party and character inventory.""" + + from_type: TransferSourceType + from_id: UUID = Field( + description="party_id if from_type=party, entity_id if from_type=character" + ) + to_type: TransferTargetType + to_id: UUID = Field( + description="party_id if to_type=party, entity_id if to_type=character" + ) + item_name: str = Field(min_length=1, max_length=200) + quantity: int = Field(ge=1, default=1) + + +class UpdateGoldRequest(BaseModel): + """Request to update party gold.""" + + party_id: UUID + amount: int = Field( + description="Amount to add (positive) or subtract (negative) in copper pieces" + ) + reason: Optional[str] = Field( + None, max_length=200, description="Reason for gold change" + ) + + +# ============================================================================= +# PARTY SPLIT SCHEMAS +# ============================================================================= + + +class SubParty(BaseModel): + """A sub-party in a party split.""" + + name: str = Field( + min_length=1, max_length=100, description="Sub-party identifier (e.g., 'Alpha')" + ) + member_ids: List[UUID] = Field( + min_length=1, description="Entity IDs of characters in this sub-party" + ) + location_id: Optional[UUID] = Field( + None, description="Current location of this sub-party" + ) + purpose: Optional[str] = Field( + None, max_length=200, description="Purpose of this sub-party's mission" + ) + + +class PartySplitCreate(BaseModel): + """Request to create a party split.""" + + party_id: UUID = Field(description="Party being split") + sub_parties: List[SubParty] = Field( + min_length=2, description="At least 2 sub-parties required" + ) + + +class PartySplitResponse(BaseModel): + """Response with party split data.""" + + split_id: UUID + party_id: UUID + sub_parties: List[SubParty] + status: SplitStatus + created_at: datetime + resolved_at: Optional[datetime] = None + resolution_notes: Optional[str] = None + + model_config = {"from_attributes": True} + + +class ResolvePartySplitRequest(BaseModel): + """Request to resolve a party split.""" + + split_id: UUID + resolution_notes: Optional[str] = Field( + None, max_length=500, description="Notes about how the split was resolved" + ) + + +# ============================================================================= +# QUERY SCHEMAS +# ============================================================================= + + +class ActiveSplitsResponse(BaseModel): + """Response with active splits for a party.""" + + party_id: UUID + splits: List[PartySplitResponse] + + +class SplitHistoryFilter(BaseModel): + """Filter for split history query.""" + + party_id: UUID + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +class SplitHistoryResponse(BaseModel): + """Response with split history.""" + + party_id: UUID + splits: List[PartySplitResponse] + total: int + limit: int + offset: int diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py index 73dec07..1339b39 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -81,6 +81,24 @@ MemoryResponse, MemoryListResponse, ) +from monitor_data.schemas.party_inventory import ( + ItemCategory, + SplitStatus, + InventoryItem, + PartyInventoryCreate, + PartyInventoryResponse, + AddInventoryItemRequest, + RemoveInventoryItemRequest, + TransferItemRequest, + UpdateGoldRequest, + SubParty, + PartySplitCreate, + PartySplitResponse, + ResolvePartySplitRequest, + ActiveSplitsResponse, + SplitHistoryFilter, + SplitHistoryResponse, +) # ============================================================================= @@ -2251,3 +2269,589 @@ def mongodb_delete_memory(memory_id: UUID) -> bool: result = memories_collection.delete_one({"memory_id": str(memory_id)}) return result.deleted_count > 0 + + +# ============================================================================= +# PARTY INVENTORY OPERATIONS (DL-16) +# ============================================================================= + + +def mongodb_create_party_inventory( + params: PartyInventoryCreate, +) -> PartyInventoryResponse: + """ + Create a new party inventory document in MongoDB. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Party inventory creation parameters + + Returns: + PartyInventoryResponse with created inventory data + + Raises: + ValueError: If party_id doesn't exist in Neo4j or inventory already exists + """ + mongo_client = get_mongodb_client() + neo4j_client = get_neo4j_client() + + # Verify party exists in Neo4j + party_check_query = """ + MATCH (p:Party {id: $party_id}) + RETURN p.id as id + """ + result = neo4j_client.execute_read( + party_check_query, {"party_id": str(params.party_id)} + ) + if not result: + raise ValueError(f"Party {params.party_id} not found") + + # Check if inventory already exists + inventories_collection = mongo_client.get_collection("party_inventories") + existing = inventories_collection.find_one({"party_id": str(params.party_id)}) + if existing: + raise ValueError(f"Inventory for party {params.party_id} already exists") + + # Create inventory document + now = datetime.now(timezone.utc) + inventory_id = uuid4() + + # Process initial items + items = [] + if params.initial_items: + for item_data in params.initial_items: + item = InventoryItem( + name=item_data["name"], + quantity=item_data.get("quantity", 1), + category=ItemCategory(item_data.get("category", ItemCategory.MISC)), + value=item_data.get("value"), + notes=item_data.get("notes"), + added_at=now, + ) + items.append(item.model_dump()) + + inventory_doc = { + "inventory_id": str(inventory_id), + "party_id": str(params.party_id), + "gold": params.initial_gold, + "items": items, + "created_at": now, + "updated_at": now, + } + + inventories_collection.insert_one(inventory_doc) + + return PartyInventoryResponse( + inventory_id=inventory_id, + party_id=params.party_id, + gold=params.initial_gold, + items=[InventoryItem(**item) for item in items], + created_at=now, + updated_at=now, + ) + + +def mongodb_get_party_inventory(party_id: UUID) -> PartyInventoryResponse: + """ + Get party inventory by party_id. + + Authority: All agents + Use Case: DL-16 + + Args: + party_id: Party UUID + + Returns: + PartyInventoryResponse with inventory data + + Raises: + ValueError: If inventory not found + """ + mongo_client = get_mongodb_client() + inventories_collection = mongo_client.get_collection("party_inventories") + + inventory_doc = inventories_collection.find_one({"party_id": str(party_id)}) + if not inventory_doc: + raise ValueError(f"Inventory for party {party_id} not found") + + return PartyInventoryResponse( + inventory_id=UUID(inventory_doc["inventory_id"]), + party_id=UUID(inventory_doc["party_id"]), + gold=inventory_doc["gold"], + items=[InventoryItem(**item) for item in inventory_doc.get("items", [])], + created_at=inventory_doc["created_at"], + updated_at=inventory_doc.get("updated_at"), + ) + + +def mongodb_add_inventory_item( + params: AddInventoryItemRequest, +) -> PartyInventoryResponse: + """ + Add an item to party inventory or increment quantity if it exists. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Add item parameters + + Returns: + PartyInventoryResponse with updated inventory + + Raises: + ValueError: If inventory not found + """ + mongo_client = get_mongodb_client() + inventories_collection = mongo_client.get_collection("party_inventories") + + # Get current inventory + inventory_doc = inventories_collection.find_one({"party_id": str(params.party_id)}) + if not inventory_doc: + raise ValueError(f"Inventory for party {params.party_id} not found") + + now = datetime.now(timezone.utc) + items = inventory_doc.get("items", []) + + # Check if item already exists + existing_item = None + for item in items: + if item["name"] == params.item_name: + existing_item = item + break + + if existing_item: + # Increment quantity + existing_item["quantity"] += params.quantity + else: + # Add new item + new_item = InventoryItem( + name=params.item_name, + quantity=params.quantity, + category=params.category or ItemCategory.MISC, + value=params.value, + notes=params.notes, + added_at=now, + ) + items.append(new_item.model_dump()) + + # Update inventory + inventories_collection.update_one( + {"party_id": str(params.party_id)}, + {"$set": {"items": items, "updated_at": now}}, + ) + + return mongodb_get_party_inventory(params.party_id) + + +def mongodb_remove_inventory_item( + params: RemoveInventoryItemRequest, +) -> PartyInventoryResponse: + """ + Remove an item from party inventory or decrement quantity. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Remove item parameters + + Returns: + PartyInventoryResponse with updated inventory + + Raises: + ValueError: If inventory or item not found, or insufficient quantity + """ + mongo_client = get_mongodb_client() + inventories_collection = mongo_client.get_collection("party_inventories") + + # Get current inventory + inventory_doc = inventories_collection.find_one({"party_id": str(params.party_id)}) + if not inventory_doc: + raise ValueError(f"Inventory for party {params.party_id} not found") + + now = datetime.now(timezone.utc) + items = inventory_doc.get("items", []) + + # Find the item + item_index = None + for i, item in enumerate(items): + if item["name"] == params.item_name: + item_index = i + break + + if item_index is None: + raise ValueError(f"Item '{params.item_name}' not found in inventory") + + item = items[item_index] + + # Check for insufficient quantity + if params.quantity is not None and params.quantity > item["quantity"]: + raise ValueError( + f"Insufficient quantity: have {item['quantity']}, trying to remove {params.quantity}" + ) + + if params.quantity is None or params.quantity >= item["quantity"]: + # Remove item completely + items.pop(item_index) + else: + # Decrement quantity + item["quantity"] -= params.quantity + + # Update inventory + inventories_collection.update_one( + {"party_id": str(params.party_id)}, + {"$set": {"items": items, "updated_at": now}}, + ) + + return mongodb_get_party_inventory(params.party_id) + + +def mongodb_update_party_gold(params: UpdateGoldRequest) -> PartyInventoryResponse: + """ + Update party gold (add or subtract). + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Update gold parameters + + Returns: + PartyInventoryResponse with updated inventory + + Raises: + ValueError: If inventory not found or gold would become negative + """ + mongo_client = get_mongodb_client() + inventories_collection = mongo_client.get_collection("party_inventories") + + # Get current inventory + inventory_doc = inventories_collection.find_one({"party_id": str(params.party_id)}) + if not inventory_doc: + raise ValueError(f"Inventory for party {params.party_id} not found") + + current_gold = inventory_doc.get("gold", 0) + new_gold = current_gold + params.amount + + if new_gold < 0: + raise ValueError( + f"Insufficient gold: have {current_gold}, trying to subtract {abs(params.amount)}" + ) + + now = datetime.now(timezone.utc) + + # Update inventory + inventories_collection.update_one( + {"party_id": str(params.party_id)}, + {"$set": {"gold": new_gold, "updated_at": now}}, + ) + + return mongodb_get_party_inventory(params.party_id) + + +def mongodb_transfer_item(params: TransferItemRequest) -> Dict[str, str]: + """ + Transfer an item between party and character inventory. + + Note: This is a placeholder that validates the transfer but doesn't + implement character inventory (not in scope for DL-16). + + Authority: Orchestrator + Use Case: DL-16 + + Args: + params: Transfer item parameters + + Returns: + Dict with transfer confirmation + + Raises: + ValueError: If source inventory not found or insufficient quantity + NotImplementedError: If character inventory is involved (not yet implemented) + """ + mongo_client = get_mongodb_client() + + # Validate transfer type + if params.from_type.value == "character" or params.to_type.value == "character": + raise NotImplementedError( + "Character inventory not yet implemented. " + "This tool currently only supports party inventory operations." + ) + + # For party-to-party transfers, this would be for moving items between + # different parties (e.g., splitting loot) + # For now, just validate that the source party has the item + + inventories_collection = mongo_client.get_collection("party_inventories") + source_inventory = inventories_collection.find_one( + {"party_id": str(params.from_id)} + ) + if not source_inventory: + raise ValueError(f"Source inventory for party {params.from_id} not found") + + # Find the item + items = source_inventory.get("items", []) + item_found = False + for item in items: + if item["name"] == params.item_name: + item_found = True + if item["quantity"] < params.quantity: + raise ValueError( + f"Insufficient quantity: have {item['quantity']}, trying to transfer {params.quantity}" + ) + break + + if not item_found: + raise ValueError(f"Item '{params.item_name}' not found in source inventory") + + # TODO: Implement actual transfer when character inventory is added + return { + "status": "validated", + "message": f"Transfer of {params.quantity}x {params.item_name} validated but not executed (character inventory not implemented)", + } + + +def mongodb_create_party_split(params: PartySplitCreate) -> PartySplitResponse: + """ + Create a party split record. + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Party split creation parameters + + Returns: + PartySplitResponse with created split data + + Raises: + ValueError: If party doesn't exist or validation fails + """ + mongo_client = get_mongodb_client() + neo4j_client = get_neo4j_client() + + # Verify party exists + party_check_query = """ + MATCH (p:Party {id: $party_id}) + RETURN p.id as id + """ + result = neo4j_client.execute_read( + party_check_query, {"party_id": str(params.party_id)} + ) + if not result: + raise ValueError(f"Party {params.party_id} not found") + + # Validate sub-parties (check that all members exist) + all_member_ids = [] + for sub_party in params.sub_parties: + all_member_ids.extend(sub_party.member_ids) + + # Verify location if provided + if sub_party.location_id: + location_check_query = """ + MATCH (l:EntityInstance {id: $location_id}) + WHERE l.entity_type = 'location' + RETURN l.id as id + """ + result = neo4j_client.execute_read( + location_check_query, {"location_id": str(sub_party.location_id)} + ) + if not result: + raise ValueError(f"Location {sub_party.location_id} not found") + + # Verify all members exist + for member_id in all_member_ids: + member_check_query = """ + MATCH (e:EntityInstance {id: $entity_id}) + RETURN e.id as id + """ + result = neo4j_client.execute_read( + member_check_query, {"entity_id": str(member_id)} + ) + if not result: + raise ValueError(f"Entity {member_id} not found") + + # Create split document + now = datetime.now(timezone.utc) + split_id = uuid4() + + sub_parties_list = [sub_party.model_dump() for sub_party in params.sub_parties] + + split_doc = { + "split_id": str(split_id), + "party_id": str(params.party_id), + "sub_parties": sub_parties_list, + "status": SplitStatus.ACTIVE.value, + "created_at": now, + "resolved_at": None, + "resolution_notes": None, + } + + splits_collection = mongo_client.get_collection("party_splits") + splits_collection.insert_one(split_doc) + + return PartySplitResponse( + split_id=split_id, + party_id=params.party_id, + sub_parties=params.sub_parties, + status=SplitStatus.ACTIVE, + created_at=now, + resolved_at=None, + resolution_notes=None, + ) + + +def mongodb_get_active_splits(party_id: UUID) -> ActiveSplitsResponse: + """ + Get all active splits for a party. + + Authority: All agents + Use Case: DL-16 + + Args: + party_id: Party UUID + + Returns: + ActiveSplitsResponse with active splits + """ + mongo_client = get_mongodb_client() + splits_collection = mongo_client.get_collection("party_splits") + + # Find all active splits for party + splits_docs = splits_collection.find( + {"party_id": str(party_id), "status": SplitStatus.ACTIVE.value} + ) + + splits = [] + for split_doc in splits_docs: + splits.append( + PartySplitResponse( + split_id=UUID(split_doc["split_id"]), + party_id=UUID(split_doc["party_id"]), + sub_parties=[ + SubParty(**sub_party) for sub_party in split_doc["sub_parties"] + ], + status=SplitStatus(split_doc["status"]), + created_at=split_doc["created_at"], + resolved_at=split_doc.get("resolved_at"), + resolution_notes=split_doc.get("resolution_notes"), + ) + ) + + return ActiveSplitsResponse(party_id=party_id, splits=splits) + + +def mongodb_resolve_party_split( + params: ResolvePartySplitRequest, +) -> PartySplitResponse: + """ + Resolve a party split (mark as rejoined). + + Authority: Orchestrator, CanonKeeper + Use Case: DL-16 + + Args: + params: Resolve split parameters + + Returns: + PartySplitResponse with resolved split data + + Raises: + ValueError: If split not found or already resolved + """ + mongo_client = get_mongodb_client() + splits_collection = mongo_client.get_collection("party_splits") + + # Get current split + split_doc = splits_collection.find_one({"split_id": str(params.split_id)}) + if not split_doc: + raise ValueError(f"Split {params.split_id} not found") + + if split_doc["status"] == SplitStatus.RESOLVED.value: + raise ValueError(f"Split {params.split_id} is already resolved") + + now = datetime.now(timezone.utc) + + # Update split + splits_collection.update_one( + {"split_id": str(params.split_id)}, + { + "$set": { + "status": SplitStatus.RESOLVED.value, + "resolved_at": now, + "resolution_notes": params.resolution_notes, + } + }, + ) + + # Return updated split + updated_split_doc = splits_collection.find_one({"split_id": str(params.split_id)}) + if not updated_split_doc: + raise ValueError(f"Split {params.split_id} not found after update") + + return PartySplitResponse( + split_id=UUID(updated_split_doc["split_id"]), + party_id=UUID(updated_split_doc["party_id"]), + sub_parties=[ + SubParty(**sub_party) for sub_party in updated_split_doc["sub_parties"] + ], + status=SplitStatus(updated_split_doc["status"]), + created_at=updated_split_doc["created_at"], + resolved_at=updated_split_doc.get("resolved_at"), + resolution_notes=updated_split_doc.get("resolution_notes"), + ) + + +def mongodb_get_split_history(params: SplitHistoryFilter) -> SplitHistoryResponse: + """ + Get split history for a party (all splits, including resolved). + + Authority: All agents + Use Case: DL-16 + + Args: + params: Split history filter parameters + + Returns: + SplitHistoryResponse with split history + """ + mongo_client = get_mongodb_client() + splits_collection = mongo_client.get_collection("party_splits") + + # Count total splits + total = splits_collection.count_documents({"party_id": str(params.party_id)}) + + # Find splits with pagination + splits_docs = ( + splits_collection.find({"party_id": str(params.party_id)}) + .sort("created_at", -1) # Most recent first + .skip(params.offset) + .limit(params.limit) + ) + + splits = [] + for split_doc in splits_docs: + splits.append( + PartySplitResponse( + split_id=UUID(split_doc["split_id"]), + party_id=UUID(split_doc["party_id"]), + sub_parties=[ + SubParty(**sub_party) for sub_party in split_doc["sub_parties"] + ], + status=SplitStatus(split_doc["status"]), + created_at=split_doc["created_at"], + resolved_at=split_doc.get("resolved_at"), + resolution_notes=split_doc.get("resolution_notes"), + ) + ) + + return SplitHistoryResponse( + party_id=params.party_id, + splits=splits, + total=total, + limit=params.limit, + offset=params.offset, + ) diff --git a/packages/data-layer/tests/test_tools/test_party_inventory_tools.py b/packages/data-layer/tests/test_tools/test_party_inventory_tools.py new file mode 100644 index 0000000..6c31f63 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_party_inventory_tools.py @@ -0,0 +1,1140 @@ +""" +Tests for Party Inventory MongoDB tools (DL-16). + +Tests all party inventory CRUD operations, item management, +gold tracking, party splits, and split history. +""" + +from datetime import datetime, timezone +from unittest.mock import Mock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from monitor_data.schemas.party_inventory import ( + ItemCategory, + SplitStatus, + InventoryItem, + PartyInventoryCreate, + PartyInventoryResponse, + AddInventoryItemRequest, + RemoveInventoryItemRequest, + TransferItemRequest, + TransferSourceType, + TransferTargetType, + UpdateGoldRequest, + SubParty, + PartySplitCreate, + ResolvePartySplitRequest, + SplitHistoryFilter, +) +from monitor_data.tools.mongodb_tools import ( + mongodb_create_party_inventory, + mongodb_get_party_inventory, + mongodb_add_inventory_item, + mongodb_remove_inventory_item, + mongodb_update_party_gold, + mongodb_transfer_item, + mongodb_create_party_split, + mongodb_get_active_splits, + mongodb_resolve_party_split, + mongodb_get_split_history, +) + + +# ============================================================================= +# TEST: mongodb_create_party_inventory +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_inventory_success( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a party inventory.""" + party_id = uuid4() + inventory_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + mock_inventories.find_one.return_value = None # No existing inventory + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"id": str(party_id)}] + + params = PartyInventoryCreate( + party_id=party_id, + initial_gold=100, + initial_items=[], + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=inventory_id): + result = mongodb_create_party_inventory(params) + + assert result.inventory_id == inventory_id + assert result.party_id == party_id + assert result.gold == 100 + assert len(result.items) == 0 + mock_inventories.insert_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_inventory_with_items( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a party inventory with initial items.""" + party_id = uuid4() + inventory_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + mock_inventories.find_one.return_value = None + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"id": str(party_id)}] + + params = PartyInventoryCreate( + party_id=party_id, + initial_gold=500, + initial_items=[ + {"name": "Health Potion", "quantity": 5, "category": "consumables"}, + {"name": "Rope", "quantity": 1, "category": "misc", "notes": "50 feet"}, + ], + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=inventory_id): + result = mongodb_create_party_inventory(params) + + assert result.gold == 500 + assert len(result.items) == 2 + assert result.items[0].name == "Health Potion" + assert result.items[0].quantity == 5 + assert result.items[1].notes == "50 feet" + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_inventory_party_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating inventory for non-existent party.""" + party_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + mock_inventories.find_one.return_value = None + + # Mock Neo4j - party not found + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [] + + params = PartyInventoryCreate(party_id=party_id) + + with pytest.raises(ValueError, match="Party .* not found"): + mongodb_create_party_inventory(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_inventory_already_exists( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating inventory when one already exists.""" + party_id = uuid4() + + # Mock MongoDB - inventory already exists + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + mock_inventories.find_one.return_value = {"inventory_id": str(uuid4())} + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [{"id": str(party_id)}] + + params = PartyInventoryCreate(party_id=party_id) + + with pytest.raises(ValueError, match="already exists"): + mongodb_create_party_inventory(params) + + +# ============================================================================= +# TEST: mongodb_get_party_inventory +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_inventory_success(mock_get_mongodb: Mock): + """Test retrieving a party inventory.""" + party_id = uuid4() + inventory_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(inventory_id), + "party_id": str(party_id), + "gold": 250, + "items": [ + { + "name": "Sword", + "quantity": 1, + "category": "weapons", + "value": 50, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + result = mongodb_get_party_inventory(party_id) + + assert result.inventory_id == inventory_id + assert result.party_id == party_id + assert result.gold == 250 + assert len(result.items) == 1 + assert result.items[0].name == "Sword" + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_inventory_not_found(mock_get_mongodb: Mock): + """Test retrieving non-existent inventory.""" + party_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + mock_inventories.find_one.return_value = None + + with pytest.raises(ValueError, match="Inventory .* not found"): + mongodb_get_party_inventory(party_id) + + +# ============================================================================= +# TEST: mongodb_add_inventory_item +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_add_item_new( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test adding a new item to inventory.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=100, + items=[ + InventoryItem( + name="Torch", + quantity=3, + category=ItemCategory.MISC, + value=None, + notes=None, + added_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + params = AddInventoryItemRequest( + party_id=party_id, + item_name="Torch", + quantity=3, + category=ItemCategory.MISC, + ) + + result = mongodb_add_inventory_item(params) + + assert len(result.items) == 1 + assert result.items[0].name == "Torch" + assert result.items[0].quantity == 3 + mock_inventories.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_add_item_existing( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test adding quantity to an existing item.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [ + { + "name": "Arrow", + "quantity": 10, + "category": "weapons", + "value": None, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=100, + items=[ + InventoryItem( + name="Arrow", + quantity=25, # 10 + 15 + category=ItemCategory.WEAPONS, + value=None, + notes=None, + added_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + params = AddInventoryItemRequest( + party_id=party_id, + item_name="Arrow", + quantity=15, + ) + + result = mongodb_add_inventory_item(params) + + assert result.items[0].name == "Arrow" + assert result.items[0].quantity == 25 + + +# ============================================================================= +# TEST: mongodb_remove_inventory_item +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_item_partial( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test removing partial quantity of an item.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [ + { + "name": "Ration", + "quantity": 10, + "category": "consumables", + "value": None, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=100, + items=[ + InventoryItem( + name="Ration", + quantity=7, # 10 - 3 + category=ItemCategory.CONSUMABLES, + value=None, + notes=None, + added_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + params = RemoveInventoryItemRequest( + party_id=party_id, + item_name="Ration", + quantity=3, + ) + + result = mongodb_remove_inventory_item(params) + + assert result.items[0].quantity == 7 + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_item_full( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test removing all quantity of an item.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [ + { + "name": "Torch", + "quantity": 5, + "category": "misc", + "value": None, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return - item removed + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=100, + items=[], # Item removed + created_at=now, + updated_at=now, + ) + + params = RemoveInventoryItemRequest( + party_id=party_id, + item_name="Torch", + quantity=None, # Remove all + ) + + result = mongodb_remove_inventory_item(params) + + assert len(result.items) == 0 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_item_not_found(mock_get_mongodb: Mock): + """Test removing item that doesn't exist.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + params = RemoveInventoryItemRequest( + party_id=party_id, + item_name="NonExistent", + quantity=1, + ) + + with pytest.raises(ValueError, match="not found in inventory"): + mongodb_remove_inventory_item(params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_remove_item_insufficient_quantity(mock_get_mongodb: Mock): + """Test removing more quantity than available.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [ + { + "name": "Potion", + "quantity": 2, + "category": "consumables", + "value": None, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + params = RemoveInventoryItemRequest( + party_id=party_id, + item_name="Potion", + quantity=5, + ) + + with pytest.raises(ValueError, match="Insufficient quantity"): + mongodb_remove_inventory_item(params) + + +# ============================================================================= +# TEST: mongodb_update_party_gold +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_gold_positive( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test adding gold to party.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=250, # 100 + 150 + items=[], + created_at=now, + updated_at=now, + ) + + params = UpdateGoldRequest( + party_id=party_id, + amount=150, + reason="Loot from dragon", + ) + + result = mongodb_update_party_gold(params) + + assert result.gold == 250 + + +@patch("monitor_data.tools.mongodb_tools.mongodb_get_party_inventory") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_gold_negative( + mock_get_mongodb: Mock, + mock_get_inventory: Mock, +): + """Test subtracting gold from party.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 200, + "items": [], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + # Mock get_inventory return + mock_get_inventory.return_value = PartyInventoryResponse( + inventory_id=uuid4(), + party_id=party_id, + gold=150, # 200 - 50 + items=[], + created_at=now, + updated_at=now, + ) + + params = UpdateGoldRequest( + party_id=party_id, + amount=-50, + reason="Bought supplies", + ) + + result = mongodb_update_party_gold(params) + + assert result.gold == 150 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_gold_insufficient(mock_get_mongodb: Mock): + """Test subtracting more gold than available.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 50, + "items": [], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + params = UpdateGoldRequest( + party_id=party_id, + amount=-100, + ) + + with pytest.raises(ValueError, match="Insufficient gold"): + mongodb_update_party_gold(params) + + +# ============================================================================= +# TEST: mongodb_transfer_item +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_transfer_item_character_not_implemented(mock_get_mongodb: Mock): + """Test transfer with character inventory (not implemented).""" + party_id = uuid4() + character_id = uuid4() + + params = TransferItemRequest( + from_type=TransferSourceType.PARTY, + from_id=party_id, + to_type=TransferTargetType.CHARACTER, + to_id=character_id, + item_name="Sword", + quantity=1, + ) + + with pytest.raises( + NotImplementedError, match="Character inventory not yet implemented" + ): + mongodb_transfer_item(params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_transfer_item_validation_success(mock_get_mongodb: Mock): + """Test transfer validation (party-to-party).""" + party_id = uuid4() + other_party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_inventories = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_inventories + + inventory_doc = { + "inventory_id": str(uuid4()), + "party_id": str(party_id), + "gold": 100, + "items": [ + { + "name": "Map", + "quantity": 1, + "category": "misc", + "value": None, + "notes": None, + "added_at": now, + } + ], + "created_at": now, + "updated_at": now, + } + mock_inventories.find_one.return_value = inventory_doc + + params = TransferItemRequest( + from_type=TransferSourceType.PARTY, + from_id=party_id, + to_type=TransferTargetType.PARTY, + to_id=other_party_id, + item_name="Map", + quantity=1, + ) + + result = mongodb_transfer_item(params) + + assert result["status"] == "validated" + + +# ============================================================================= +# TEST: mongodb_create_party_split +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_split_success( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating a party split.""" + party_id = uuid4() + split_id = uuid4() + member1_id = uuid4() + member2_id = uuid4() + member3_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + # Mock Neo4j + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + # Party exists, all members exist + mock_neo4j.execute_read.side_effect = [ + [{"id": str(party_id)}], # Party check + [{"id": str(member1_id)}], # Member 1 + [{"id": str(member2_id)}], # Member 2 + [{"id": str(member3_id)}], # Member 3 + ] + + sub_parties = [ + SubParty( + name="Alpha", + member_ids=[member1_id, member2_id], + location_id=None, + purpose="Scout ahead", + ), + SubParty( + name="Bravo", + member_ids=[member3_id], + location_id=None, + purpose="Guard camp", + ), + ] + + params = PartySplitCreate( + party_id=party_id, + sub_parties=sub_parties, + ) + + with patch("monitor_data.tools.mongodb_tools.uuid4", return_value=split_id): + result = mongodb_create_party_split(params) + + assert result.split_id == split_id + assert result.party_id == party_id + assert len(result.sub_parties) == 2 + assert result.status == SplitStatus.ACTIVE + mock_splits.insert_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_split_party_not_found( + mock_get_mongodb: Mock, + mock_get_neo4j: Mock, +): + """Test creating split for non-existent party.""" + party_id = uuid4() + + # Mock Neo4j - party not found + mock_neo4j = MagicMock() + mock_get_neo4j.return_value = mock_neo4j + mock_neo4j.execute_read.return_value = [] + + params = PartySplitCreate( + party_id=party_id, + sub_parties=[ + SubParty(name="Alpha", member_ids=[uuid4()]), + SubParty(name="Bravo", member_ids=[uuid4()]), + ], + ) + + with pytest.raises(ValueError, match="Party .* not found"): + mongodb_create_party_split(params) + + +# ============================================================================= +# TEST: mongodb_get_active_splits +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_active_splits_success(mock_get_mongodb: Mock): + """Test retrieving active splits for a party.""" + party_id = uuid4() + split_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + split_docs = [ + { + "split_id": str(split_id), + "party_id": str(party_id), + "sub_parties": [ + { + "name": "Alpha", + "member_ids": [str(uuid4())], + "location_id": None, + "purpose": "Scout", + }, + { + "name": "Bravo", + "member_ids": [str(uuid4())], + "location_id": None, + "purpose": "Guard", + }, + ], + "status": "active", + "created_at": now, + "resolved_at": None, + "resolution_notes": None, + } + ] + mock_splits.find.return_value = split_docs + + result = mongodb_get_active_splits(party_id) + + assert result.party_id == party_id + assert len(result.splits) == 1 + assert result.splits[0].status == SplitStatus.ACTIVE + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_active_splits_none(mock_get_mongodb: Mock): + """Test retrieving active splits when there are none.""" + party_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + mock_splits.find.return_value = [] + + result = mongodb_get_active_splits(party_id) + + assert result.party_id == party_id + assert len(result.splits) == 0 + + +# ============================================================================= +# TEST: mongodb_resolve_party_split +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_resolve_split_success(mock_get_mongodb: Mock): + """Test resolving a party split.""" + split_id = uuid4() + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + # First call returns active split, second call returns resolved split + split_doc_active = { + "split_id": str(split_id), + "party_id": str(party_id), + "sub_parties": [ + { + "name": "Alpha", + "member_ids": [str(uuid4())], + "location_id": None, + "purpose": None, + }, + { + "name": "Bravo", + "member_ids": [str(uuid4())], + "location_id": None, + "purpose": None, + }, + ], + "status": "active", + "created_at": now, + "resolved_at": None, + "resolution_notes": None, + } + + split_doc_resolved = split_doc_active.copy() + split_doc_resolved["status"] = "resolved" + split_doc_resolved["resolved_at"] = now + split_doc_resolved["resolution_notes"] = "Party rejoined at tavern" + + mock_splits.find_one.side_effect = [split_doc_active, split_doc_resolved] + + params = ResolvePartySplitRequest( + split_id=split_id, + resolution_notes="Party rejoined at tavern", + ) + + result = mongodb_resolve_party_split(params) + + assert result.split_id == split_id + assert result.status == SplitStatus.RESOLVED + assert result.resolution_notes == "Party rejoined at tavern" + mock_splits.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_resolve_split_not_found(mock_get_mongodb: Mock): + """Test resolving non-existent split.""" + split_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + mock_splits.find_one.return_value = None + + params = ResolvePartySplitRequest(split_id=split_id) + + with pytest.raises(ValueError, match="Split .* not found"): + mongodb_resolve_party_split(params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_resolve_split_already_resolved(mock_get_mongodb: Mock): + """Test resolving already resolved split.""" + split_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + split_doc = { + "split_id": str(split_id), + "party_id": str(uuid4()), + "sub_parties": [], + "status": "resolved", # Already resolved + "created_at": now, + "resolved_at": now, + "resolution_notes": "Already resolved", + } + mock_splits.find_one.return_value = split_doc + + params = ResolvePartySplitRequest(split_id=split_id) + + with pytest.raises(ValueError, match="already resolved"): + mongodb_resolve_party_split(params) + + +# ============================================================================= +# TEST: mongodb_get_split_history +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_split_history_success(mock_get_mongodb: Mock): + """Test retrieving split history for a party.""" + party_id = uuid4() + now = datetime.now(timezone.utc) + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + # Mock count + mock_splits.count_documents.return_value = 3 + + # Mock find with cursor + split_docs = [ + { + "split_id": str(uuid4()), + "party_id": str(party_id), + "sub_parties": [], + "status": "resolved", + "created_at": now, + "resolved_at": now, + "resolution_notes": "Rejoined", + }, + { + "split_id": str(uuid4()), + "party_id": str(party_id), + "sub_parties": [], + "status": "active", + "created_at": now, + "resolved_at": None, + "resolution_notes": None, + }, + ] + + mock_cursor = MagicMock() + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = split_docs + mock_splits.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + + params = SplitHistoryFilter( + party_id=party_id, + limit=50, + offset=0, + ) + + result = mongodb_get_split_history(params) + + assert result.party_id == party_id + assert result.total == 3 + assert len(result.splits) == 2 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_split_history_with_pagination(mock_get_mongodb: Mock): + """Test split history with pagination.""" + party_id = uuid4() + + # Mock MongoDB + mock_mongodb = MagicMock() + mock_splits = MagicMock() + mock_get_mongodb.return_value = mock_mongodb + mock_mongodb.get_collection.return_value = mock_splits + + # Mock count + mock_splits.count_documents.return_value = 10 + + # Mock find with cursor + mock_cursor = MagicMock() + mock_cursor.skip.return_value = mock_cursor + mock_cursor.limit.return_value = [] + mock_splits.find.return_value = mock_cursor + mock_cursor.sort.return_value = mock_cursor + + params = SplitHistoryFilter( + party_id=party_id, + limit=5, + offset=5, + ) + + result = mongodb_get_split_history(params) + + assert result.total == 10 + assert result.limit == 5 + assert result.offset == 5 + mock_cursor.skip.assert_called_with(5) + mock_cursor.limit.assert_called_with(5) From bdf981c3e4110698ce537bf98549330d85aae2bc Mon Sep 17 00:00:00 2001 From: spuentesp Date: Tue, 6 Jan 2026 11:36:32 -0300 Subject: [PATCH 2/2] fix(data-layer): Address 9 Copilot review comments on DL-16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes Applied ### Critical MongoDB Serialization (3 locations) - **Line 2333**: Added `mode="json"` to model_dump() for initial_items serialization - **Line 2438**: Added `mode="json"` to model_dump() for add_inventory_item - **Line 2681**: Added `mode="json"` to model_dump() for party split sub_parties - Prevents UUID/datetime deserialization errors when reading from MongoDB ### Validation Improvements - **Line 2650**: Added duplicate member validation in mongodb_create_party_split - Checks if len(all_member_ids) != len(set(all_member_ids)) - Raises error if character assigned to multiple sub-parties - **Line 2421**: Implemented case-insensitive item name matching - Uses `.strip().casefold()` for comparison - Stores with preserved case but prevents duplicates like "Health Potion" vs "health potion" - Strips whitespace from stored names ### Error Message Clarity - **Line 2541**: Fixed gold error message to use `-params.amount` instead of `abs(params.amount)` - More intuitive when params.amount is negative ### Configuration Cleanup - **mypy.ini**: Removed root-level mypy.ini file - Duplicated package-level pyproject.toml configs - Package-level configs use `strict = true` which is more rigorous ## Not Addressed (Deferred) ### Race Condition Fixes (Lines 2444, 2508) The review identified read-modify-write race conditions in: - `mongodb_add_inventory_item` - `mongodb_remove_inventory_item` **Rationale for deferring:** - Requires MongoDB atomic operators ($inc, $pull, arrayFilters, find_one_and_update) - Significant refactoring (~80 lines for remove_inventory_item alone) - Current implementation is correct for single-threaded usage - Can be addressed in follow-up task if high concurrency becomes a requirement **Tracking:** Created technical debt item for DL-16 race condition fixes ## Testing All 360 tests passing ✅ - 26 party inventory tests - No regressions in existing functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- mypy.ini | 5 --- .../src/monitor_data/tools/mongodb_tools.py | 31 ++++++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) delete mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index e15e01d..0000000 --- a/mypy.ini +++ /dev/null @@ -1,5 +0,0 @@ -[mypy] -python_version = 3.11 - -# Skip type-checking tests to avoid pydantic dataclass inference issues. -exclude = (?x)(^packages/data-layer/tests|^packages/agents/tests|^packages/cli/tests) diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py index 1339b39..38bd1e9 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -2330,7 +2330,7 @@ def mongodb_create_party_inventory( notes=item_data.get("notes"), added_at=now, ) - items.append(item.model_dump()) + items.append(item.model_dump(mode="json")) inventory_doc = { "inventory_id": str(inventory_id), @@ -2415,10 +2415,14 @@ def mongodb_add_inventory_item( now = datetime.now(timezone.utc) items = inventory_doc.get("items", []) - # Check if item already exists + # Normalize item name for case-insensitive comparison + normalized_name = params.item_name.strip().casefold() + + # Check if item already exists (case-insensitive) existing_item = None for item in items: - if item["name"] == params.item_name: + item_name = item.get("name", "") + if item_name.strip().casefold() == normalized_name: existing_item = item break @@ -2426,16 +2430,16 @@ def mongodb_add_inventory_item( # Increment quantity existing_item["quantity"] += params.quantity else: - # Add new item + # Add new item (store with stripped whitespace but preserve case) new_item = InventoryItem( - name=params.item_name, + name=params.item_name.strip(), quantity=params.quantity, category=params.category or ItemCategory.MISC, value=params.value, notes=params.notes, added_at=now, ) - items.append(new_item.model_dump()) + items.append(new_item.model_dump(mode="json")) # Update inventory inventories_collection.update_one( @@ -2538,7 +2542,7 @@ def mongodb_update_party_gold(params: UpdateGoldRequest) -> PartyInventoryRespon if new_gold < 0: raise ValueError( - f"Insufficient gold: have {current_gold}, trying to subtract {abs(params.amount)}" + f"Insufficient gold: have {current_gold}, trying to subtract {-params.amount}" ) now = datetime.now(timezone.utc) @@ -2649,6 +2653,15 @@ def mongodb_create_party_split(params: PartySplitCreate) -> PartySplitResponse: for sub_party in params.sub_parties: all_member_ids.extend(sub_party.member_ids) + # Check for duplicate member IDs across sub-parties + if len(all_member_ids) != len(set(all_member_ids)): + raise ValueError( + "Duplicate member IDs found across sub-parties. " + "Each character can only be assigned to one sub-party." + ) + + # Verify locations if provided + for sub_party in params.sub_parties: # Verify location if provided if sub_party.location_id: location_check_query = """ @@ -2678,7 +2691,9 @@ def mongodb_create_party_split(params: PartySplitCreate) -> PartySplitResponse: now = datetime.now(timezone.utc) split_id = uuid4() - sub_parties_list = [sub_party.model_dump() for sub_party in params.sub_parties] + sub_parties_list = [ + sub_party.model_dump(mode="json") for sub_party in params.sub_parties + ] split_doc = { "split_id": str(split_id),