diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index 5426818..4335742 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -100,13 +100,20 @@ "mongodb_get_turns": ["*"], "mongodb_undo_turn": ["Orchestrator"], # ========================================================================= - # MONGODB OPERATIONS - Proposals + # MONGODB OPERATIONS - Proposals (Legacy - kept for backward compatibility) # ========================================================================= "mongodb_create_proposal": ["Narrator", "Resolver", "CanonKeeper"], "mongodb_get_proposals": ["*"], "mongodb_update_proposal": ["CanonKeeper"], "mongodb_list_pending_proposals": ["*"], # ========================================================================= + # MONGODB OPERATIONS - Proposed Changes (DL-5) + # ========================================================================= + "mongodb_create_proposed_change": ["*"], + "mongodb_get_proposed_change": ["*"], + "mongodb_list_proposed_changes": ["*"], + "mongodb_update_proposed_change": ["CanonKeeper"], + # ========================================================================= # MONGODB OPERATIONS - Resolutions # ========================================================================= "mongodb_create_resolution": ["Resolver"], diff --git a/packages/data-layer/src/monitor_data/schemas/proposed_changes.py b/packages/data-layer/src/monitor_data/schemas/proposed_changes.py new file mode 100644 index 0000000..a9744ea --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/proposed_changes.py @@ -0,0 +1,171 @@ +""" +Pydantic schemas for ProposedChange operations (MongoDB). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: mongodb_tools.py + +These schemas define the data contracts for ProposedChange CRUD operations. +ProposedChanges are staging documents for canonical changes that CanonKeeper +evaluates at scene end. + +USE CASE: DL-5 +""" + +from datetime import datetime +from typing import Optional, List, Dict, Any +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + +from monitor_data.schemas.base import ProposalStatus, ProposalType, Authority + + +# ============================================================================= +# EVIDENCE SCHEMAS +# ============================================================================= + + +class Evidence(BaseModel): + """Evidence supporting a proposed change.""" + + type: str = Field( + description="Evidence type: turn, snippet, source, rule", + pattern="^(turn|snippet|source|rule)$", + ) + ref_id: UUID = Field(description="Reference to the evidence source") + + +# ============================================================================= +# DECISION METADATA SCHEMAS +# ============================================================================= + + +class DecisionMetadata(BaseModel): + """Metadata about CanonKeeper's decision on a proposal.""" + + decided_by: str = Field( + description="Agent that made the decision (e.g., CanonKeeper)" + ) + decided_at: datetime = Field(description="When the decision was made") + reason: str = Field( + description="Rationale for accepting or rejecting the proposal", + max_length=2000, + ) + canonical_ref: Optional[UUID] = Field( + None, + description="UUID of the created canonical entity in Neo4j (if accepted)", + ) + + +# ============================================================================= +# PROPOSED CHANGE SCHEMAS +# ============================================================================= + + +class ProposedChangeCreate(BaseModel): + """Request to create a ProposedChange.""" + + scene_id: Optional[UUID] = Field( + None, description="Scene ID (required for scene-based proposals)" + ) + story_id: Optional[UUID] = Field( + None, description="Story ID (for story-level proposals)" + ) + turn_id: Optional[UUID] = Field( + None, description="Turn ID that proposed this (if from a turn)" + ) + change_type: ProposalType = Field(description="Type of proposed change") + content: Dict[str, Any] = Field( + description="Flexible JSON payload for the proposed change" + ) + evidence: List[Evidence] = Field( + default_factory=list, description="Supporting evidence for this proposal" + ) + confidence: float = Field( + default=1.0, + ge=0.0, + le=1.0, + description="Confidence level for this proposal (0.0-1.0)", + ) + authority: Authority = Field( + default=Authority.SYSTEM, description="Who asserted this change" + ) + proposer: str = Field( + default="Unknown", description="Agent or user who created this proposal" + ) + + @field_validator("scene_id", "story_id") + @classmethod + def validate_scene_or_story(cls, v: Optional[UUID], info) -> Optional[UUID]: + """Validate that at least one of scene_id or story_id is provided.""" + # If this is scene_id being validated and it's None, check if story_id exists + if info.field_name == "scene_id" and v is None: + # We can't check story_id here as it might not be set yet + pass + return v + + def model_post_init(self, __context): + """Post-initialization validation to ensure scene_id or story_id is provided.""" + if self.scene_id is None and self.story_id is None: + raise ValueError("Either scene_id or story_id must be provided") + + +class ProposedChangeUpdate(BaseModel): + """Request to update a ProposedChange. + + Only CanonKeeper can update status from pending to accepted/rejected. + """ + + status: ProposalStatus = Field(description="New status for the proposal") + decision_metadata: DecisionMetadata = Field( + description="Decision metadata (required when updating status)" + ) + + +class ProposedChangeResponse(BaseModel): + """Response with ProposedChange data.""" + + proposal_id: UUID + scene_id: Optional[UUID] = None + story_id: Optional[UUID] = None + turn_id: Optional[UUID] = None + change_type: ProposalType + content: Dict[str, Any] + evidence: List[Evidence] = Field(default_factory=list) + confidence: float + authority: Authority + proposer: str + status: ProposalStatus + decision_metadata: Optional[DecisionMetadata] = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProposedChangeFilter(BaseModel): + """Filter parameters for listing proposed changes.""" + + scene_id: Optional[UUID] = None + story_id: Optional[UUID] = None + status: Optional[ProposalStatus] = None + change_type: Optional[ProposalType] = None + limit: int = Field(default=50, ge=1, le=1000) + offset: int = Field(default=0, ge=0) + sort_by: str = Field( + default="created_at", + description="Field to sort by: created_at, confidence", + ) + sort_order: str = Field( + default="desc", description="Sort order: asc, desc", pattern="^(asc|desc)$" + ) + + +class ProposedChangeListResponse(BaseModel): + """Response with list of proposed changes and pagination info.""" + + proposed_changes: List[ProposedChangeResponse] + 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 5204157..a12fc8d 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -24,7 +24,16 @@ TurnCreate, TurnResponse, ) -from monitor_data.schemas.base import SceneStatus +from monitor_data.schemas.proposed_changes import ( + ProposedChangeCreate, + ProposedChangeUpdate, + ProposedChangeResponse, + ProposedChangeFilter, + ProposedChangeListResponse, + Evidence, + DecisionMetadata, +) +from monitor_data.schemas.base import SceneStatus, ProposalStatus # ============================================================================= @@ -429,3 +438,318 @@ def mongodb_append_turn(scene_id: UUID, params: TurnCreate) -> TurnResponse: timestamp=timestamp, resolution_ref=None, ) + + +# ============================================================================= +# PROPOSED CHANGE OPERATIONS +# ============================================================================= + + +def _convert_proposed_change_doc_to_response( + doc: Dict[str, Any], +) -> ProposedChangeResponse: + """ + Convert a proposed change document from MongoDB to a ProposedChangeResponse. + + Args: + doc: ProposedChange data from MongoDB document + + Returns: + ProposedChangeResponse object + """ + # Convert evidence list + evidence = [ + Evidence(type=e["type"], ref_id=UUID(e["ref_id"])) + for e in doc.get("evidence", []) + ] + + # Convert decision metadata if present + decision_metadata = None + if doc.get("decision_metadata"): + dm = doc["decision_metadata"] + decision_metadata = DecisionMetadata( + decided_by=dm["decided_by"], + decided_at=dm["decided_at"], + reason=dm["reason"], + canonical_ref=( + UUID(dm["canonical_ref"]) if dm.get("canonical_ref") else None + ), + ) + + return ProposedChangeResponse( + proposal_id=UUID(doc["proposal_id"]), + scene_id=UUID(doc["scene_id"]) if doc.get("scene_id") else None, + story_id=UUID(doc["story_id"]) if doc.get("story_id") else None, + turn_id=UUID(doc["turn_id"]) if doc.get("turn_id") else None, + change_type=doc["change_type"], + content=doc["content"], + evidence=evidence, + confidence=doc["confidence"], + authority=doc["authority"], + proposer=doc["proposer"], + status=ProposalStatus(doc["status"]), + decision_metadata=decision_metadata, + created_at=doc["created_at"], + updated_at=doc["updated_at"], + ) + + +def mongodb_create_proposed_change( + params: ProposedChangeCreate, +) -> ProposedChangeResponse: + """ + Create a new ProposedChange document in MongoDB. + + Authority: * (all agents can propose changes) + Use Case: DL-5 + + Args: + params: ProposedChange creation parameters + + Returns: + ProposedChangeResponse with created proposal data + + Raises: + ValueError: If scene_id or story_id doesn't exist or neither is provided + """ + mongo_client = get_mongodb_client() + neo4j_client = get_neo4j_client() + + # Verify scene exists if scene_id provided + if params.scene_id: + scenes_collection = mongo_client.get_collection("scenes") + scene_doc = scenes_collection.find_one({"scene_id": str(params.scene_id)}) + if not scene_doc: + raise ValueError(f"Scene {params.scene_id} not found") + + # Verify story exists if story_id provided (and no scene_id) + if params.story_id and not params.scene_id: + story_query = "MATCH (s:Story {id: $story_id}) RETURN s.id as id" + result = neo4j_client.execute_read( + story_query, {"story_id": str(params.story_id)} + ) + if not result: + raise ValueError(f"Story {params.story_id} not found") + + # Create proposal + proposal_id = uuid4() + created_at = datetime.now(timezone.utc) + + proposal_doc = { + "proposal_id": str(proposal_id), + "scene_id": str(params.scene_id) if params.scene_id else None, + "story_id": str(params.story_id) if params.story_id else None, + "turn_id": str(params.turn_id) if params.turn_id else None, + "change_type": params.change_type.value, + "content": params.content, + "evidence": [ + {"type": e.type, "ref_id": str(e.ref_id)} for e in params.evidence + ], + "confidence": params.confidence, + "authority": params.authority.value, + "proposer": params.proposer, + "status": ProposalStatus.PENDING.value, + "decision_metadata": None, + "created_at": created_at, + "updated_at": created_at, + } + + # Insert into MongoDB + proposed_changes_collection = mongo_client.get_collection("proposed_changes") + proposed_changes_collection.insert_one(proposal_doc) + + # If scene_id provided, add this proposal to the scene's proposed_changes list + if params.scene_id: + scenes_collection = mongo_client.get_collection("scenes") + scenes_collection.update_one( + {"scene_id": str(params.scene_id)}, + { + "$push": {"proposed_changes": str(proposal_id)}, + "$set": {"updated_at": created_at}, + }, + ) + + return ProposedChangeResponse( + proposal_id=proposal_id, + scene_id=params.scene_id, + story_id=params.story_id, + turn_id=params.turn_id, + change_type=params.change_type, + content=params.content, + evidence=params.evidence, + confidence=params.confidence, + authority=params.authority, + proposer=params.proposer, + status=ProposalStatus.PENDING, + decision_metadata=None, + created_at=created_at, + updated_at=created_at, + ) + + +def mongodb_get_proposed_change(proposal_id: UUID) -> Optional[ProposedChangeResponse]: + """ + Retrieve a ProposedChange by ID. + + Authority: * (all agents) + Use Case: DL-5 + + Args: + proposal_id: UUID of the proposal to retrieve + + Returns: + ProposedChangeResponse if found, None otherwise + """ + mongo_client = get_mongodb_client() + proposed_changes_collection = mongo_client.get_collection("proposed_changes") + + proposal_doc = proposed_changes_collection.find_one( + {"proposal_id": str(proposal_id)} + ) + if not proposal_doc: + return None + + return _convert_proposed_change_doc_to_response(proposal_doc) + + +def mongodb_list_proposed_changes( + params: ProposedChangeFilter, +) -> ProposedChangeListResponse: + """ + List proposed changes with filtering, sorting, and pagination. + + Authority: * (all agents) + Use Case: DL-5 + + Args: + params: Filter and pagination parameters + + Returns: + ProposedChangeListResponse with list of proposals and pagination info + """ + mongo_client = get_mongodb_client() + proposed_changes_collection = mongo_client.get_collection("proposed_changes") + + # Build filter query + filter_query: Dict[str, Any] = {} + + if params.scene_id is not None: + filter_query["scene_id"] = str(params.scene_id) + + if params.story_id is not None: + filter_query["story_id"] = str(params.story_id) + + if params.status is not None: + filter_query["status"] = params.status.value + + if params.change_type is not None: + filter_query["change_type"] = params.change_type.value + + # Count total matching documents + total = proposed_changes_collection.count_documents(filter_query) + + # Build sort + sort_field = ( + params.sort_by + if params.sort_by in ["created_at", "confidence"] + else "created_at" + ) + sort_order = -1 if params.sort_order == "desc" else 1 + + # Query with pagination + cursor = ( + proposed_changes_collection.find(filter_query) + .sort(sort_field, sort_order) + .skip(params.offset) + .limit(params.limit) + ) + + proposed_changes = [_convert_proposed_change_doc_to_response(doc) for doc in cursor] + + return ProposedChangeListResponse( + proposed_changes=proposed_changes, + total=total, + limit=params.limit, + offset=params.offset, + ) + + +def mongodb_update_proposed_change( + proposal_id: UUID, params: ProposedChangeUpdate +) -> ProposedChangeResponse: + """ + Update a ProposedChange status (accept or reject). + + Authority: CanonKeeper only + Use Case: DL-5 + + Valid status transitions: pending → accepted OR pending → rejected + Once accepted or rejected, status cannot be changed. + + Args: + proposal_id: UUID of the proposal to update + params: Update parameters with new status and decision metadata + + Returns: + ProposedChangeResponse with updated proposal data + + Raises: + ValueError: If proposal doesn't exist or invalid status transition + """ + mongo_client = get_mongodb_client() + proposed_changes_collection = mongo_client.get_collection("proposed_changes") + + # Verify proposal exists + proposal_doc = proposed_changes_collection.find_one( + {"proposal_id": str(proposal_id)} + ) + if not proposal_doc: + raise ValueError(f"Proposal {proposal_id} not found") + + # Validate status transition + current_status = ProposalStatus(proposal_doc["status"]) + new_status = params.status + + # Only allow transitions from pending to accepted or rejected + if current_status != ProposalStatus.PENDING: + raise ValueError( + f"Cannot update proposal with status {current_status.value}. " + f"Only pending proposals can be accepted or rejected." + ) + + if new_status not in [ProposalStatus.ACCEPTED, ProposalStatus.REJECTED]: + raise ValueError( + f"Invalid status transition to {new_status.value}. " + f"Can only transition from pending to accepted or rejected." + ) + + # Build update document + updated_at = datetime.now(timezone.utc) + decision_metadata_doc = { + "decided_by": params.decision_metadata.decided_by, + "decided_at": params.decision_metadata.decided_at, + "reason": params.decision_metadata.reason, + "canonical_ref": ( + str(params.decision_metadata.canonical_ref) + if params.decision_metadata.canonical_ref + else None + ), + } + + update_doc = { + "status": new_status.value, + "decision_metadata": decision_metadata_doc, + "updated_at": updated_at, + } + + # Update proposal + proposed_changes_collection.update_one( + {"proposal_id": str(proposal_id)}, {"$set": update_doc} + ) + + # Return updated proposal + updated_proposal = mongodb_get_proposed_change(proposal_id) + if updated_proposal is None: + raise ValueError(f"Proposal {proposal_id} not found after update") + + return updated_proposal diff --git a/packages/data-layer/tests/conftest.py b/packages/data-layer/tests/conftest.py index fe1072d..7210e47 100644 --- a/packages/data-layer/tests/conftest.py +++ b/packages/data-layer/tests/conftest.py @@ -111,6 +111,16 @@ def universe_node(universe_data: Dict[str, Any]) -> Dict[str, Any]: return {"u": universe_data} +@pytest.fixture +def story_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample story data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "title": "Test Story", + } + + # ============================================================================= # UTILITY FIXTURES # ============================================================================= diff --git a/packages/data-layer/tests/test_tools/test_proposed_change_tools.py b/packages/data-layer/tests/test_tools/test_proposed_change_tools.py new file mode 100644 index 0000000..dabaf15 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_proposed_change_tools.py @@ -0,0 +1,726 @@ +""" +Unit tests for MongoDB proposed change operations (DL-5). + +Tests cover: +- mongodb_create_proposed_change +- mongodb_get_proposed_change +- mongodb_list_proposed_changes +- mongodb_update_proposed_change +""" + +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.proposed_changes import ( + ProposedChangeCreate, + ProposedChangeUpdate, + ProposedChangeFilter, + Evidence, + DecisionMetadata, +) +from monitor_data.schemas.base import ProposalStatus, ProposalType, Authority +from monitor_data.tools.mongodb_tools import ( + mongodb_create_proposed_change, + mongodb_get_proposed_change, + mongodb_list_proposed_changes, + mongodb_update_proposed_change, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def mock_mongodb_client() -> Mock: + """Provide a mock MongoDB client.""" + client = Mock() + collection = Mock() + client.get_collection.return_value = collection + return client + + +@pytest.fixture +def scene_doc_data( + story_data: Dict[str, Any], universe_data: Dict[str, Any] +) -> Dict[str, Any]: + """Provide sample scene document data.""" + return { + "scene_id": str(uuid4()), + "story_id": story_data["id"], + "universe_id": universe_data["id"], + "title": "Test Scene", + "status": "active", + "proposed_changes": [], + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + + +@pytest.fixture +def proposed_change_doc(scene_doc_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample proposed change document.""" + return { + "proposal_id": str(uuid4()), + "scene_id": scene_doc_data["scene_id"], + "story_id": None, + "turn_id": None, + "change_type": ProposalType.FACT.value, + "content": {"statement": "The sky is blue", "entity_ids": []}, + "evidence": [ + {"type": "turn", "ref_id": str(uuid4())}, + ], + "confidence": 0.9, + "authority": Authority.PLAYER.value, + "proposer": "TestAgent", + "status": ProposalStatus.PENDING.value, + "decision_metadata": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + + +# ============================================================================= +# TESTS: mongodb_create_proposed_change +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_proposed_change_fact( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + scene_doc_data: Dict[str, Any], +): + """Test creating a proposed change with type 'fact'.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock MongoDB collections + scenes_collection = Mock() + proposed_changes_collection = Mock() + mock_mongodb_client.get_collection.side_effect = lambda name: { + "scenes": scenes_collection, + "proposed_changes": proposed_changes_collection, + }[name] + + # Mock scene exists + scenes_collection.find_one.return_value = scene_doc_data + scenes_collection.update_one.return_value = Mock() + proposed_changes_collection.insert_one.return_value = Mock() + + evidence = [Evidence(type="turn", ref_id=uuid4())] + params = ProposedChangeCreate( + scene_id=UUID(scene_doc_data["scene_id"]), + change_type=ProposalType.FACT, + content={"statement": "The sky is blue", "entity_ids": []}, + evidence=evidence, + confidence=0.9, + authority=Authority.PLAYER, + proposer="Narrator", + ) + + result = mongodb_create_proposed_change(params) + + assert result.change_type == ProposalType.FACT + assert result.scene_id == UUID(scene_doc_data["scene_id"]) + assert result.status == ProposalStatus.PENDING + assert result.confidence == 0.9 + assert result.proposer == "Narrator" + assert result.decision_metadata is None + proposed_changes_collection.insert_one.assert_called_once() + scenes_collection.update_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_proposed_change_entity( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + scene_doc_data: Dict[str, Any], +): + """Test creating a proposed change with type 'entity'.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock MongoDB collections + scenes_collection = Mock() + proposed_changes_collection = Mock() + mock_mongodb_client.get_collection.side_effect = lambda name: { + "scenes": scenes_collection, + "proposed_changes": proposed_changes_collection, + }[name] + + scenes_collection.find_one.return_value = scene_doc_data + scenes_collection.update_one.return_value = Mock() + proposed_changes_collection.insert_one.return_value = Mock() + + params = ProposedChangeCreate( + scene_id=UUID(scene_doc_data["scene_id"]), + change_type=ProposalType.ENTITY, + content={ + "name": "Gandalf", + "entity_type": "character", + "properties": {"race": "Maia", "class": "Wizard"}, + }, + confidence=1.0, + authority=Authority.GM, + proposer="CanonKeeper", + ) + + result = mongodb_create_proposed_change(params) + + assert result.change_type == ProposalType.ENTITY + assert result.content["name"] == "Gandalf" + assert result.authority == Authority.GM + proposed_changes_collection.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_proposed_change_relationship( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + scene_doc_data: Dict[str, Any], +): + """Test creating a proposed change with type 'relationship'.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock MongoDB collections + scenes_collection = Mock() + proposed_changes_collection = Mock() + mock_mongodb_client.get_collection.side_effect = lambda name: { + "scenes": scenes_collection, + "proposed_changes": proposed_changes_collection, + }[name] + + scenes_collection.find_one.return_value = scene_doc_data + scenes_collection.update_one.return_value = Mock() + proposed_changes_collection.insert_one.return_value = Mock() + + from_id = uuid4() + to_id = uuid4() + params = ProposedChangeCreate( + scene_id=UUID(scene_doc_data["scene_id"]), + change_type=ProposalType.RELATIONSHIP, + content={ + "from": str(from_id), + "to": str(to_id), + "rel_type": "ALLY_OF", + "properties": {"strength": 0.8}, + }, + confidence=0.95, + authority=Authority.SYSTEM, + proposer="Resolver", + ) + + result = mongodb_create_proposed_change(params) + + assert result.change_type == ProposalType.RELATIONSHIP + assert result.content["rel_type"] == "ALLY_OF" + proposed_changes_collection.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_proposed_change_story_level( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test creating a story-level proposed change (no scene_id).""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock story exists in Neo4j + mock_neo4j_client.execute_read.return_value = [{"id": story_data["id"]}] + + # Mock MongoDB collection + proposed_changes_collection = Mock() + mock_mongodb_client.get_collection.return_value = proposed_changes_collection + proposed_changes_collection.insert_one.return_value = Mock() + + params = ProposedChangeCreate( + story_id=UUID(story_data["id"]), + change_type=ProposalType.EVENT, + content={"description": "A major battle occurred", "timestamp": "2024-01-01"}, + confidence=1.0, + authority=Authority.GM, + proposer="Orchestrator", + ) + + result = mongodb_create_proposed_change(params) + + assert result.story_id == UUID(story_data["id"]) + assert result.scene_id is None + assert result.change_type == ProposalType.EVENT + proposed_changes_collection.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_proposed_change_invalid_scene( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, +): + """Test creating a proposed change with invalid scene_id.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock scene doesn't exist + scenes_collection = Mock() + mock_mongodb_client.get_collection.return_value = scenes_collection + scenes_collection.find_one.return_value = None + + params = ProposedChangeCreate( + scene_id=uuid4(), + change_type=ProposalType.FACT, + content={"statement": "Test"}, + proposer="TestAgent", + ) + + with pytest.raises(ValueError, match="Scene .* not found"): + mongodb_create_proposed_change(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_proposed_change_invalid_story( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, +): + """Test creating a proposed change with invalid story_id.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock story doesn't exist in Neo4j + mock_neo4j_client.execute_read.return_value = [] + + proposed_changes_collection = Mock() + mock_mongodb_client.get_collection.return_value = proposed_changes_collection + + params = ProposedChangeCreate( + story_id=uuid4(), + change_type=ProposalType.FACT, + content={"statement": "Test"}, + proposer="TestAgent", + ) + + with pytest.raises(ValueError, match="Story .* not found"): + mongodb_create_proposed_change(params) + + +def test_create_proposed_change_no_scene_or_story(): + """Test that creating a proposal without scene_id or story_id fails validation.""" + with pytest.raises( + ValueError, match="Either scene_id or story_id must be provided" + ): + ProposedChangeCreate( + change_type=ProposalType.FACT, + content={"statement": "Test"}, + proposer="TestAgent", + ) + + +# ============================================================================= +# TESTS: mongodb_get_proposed_change +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_proposed_change_success( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test retrieving a proposed change by ID.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = proposed_change_doc + + proposal_id = UUID(proposed_change_doc["proposal_id"]) + result = mongodb_get_proposed_change(proposal_id) + + assert result is not None + assert result.proposal_id == proposal_id + assert result.change_type == ProposalType.FACT + assert result.status == ProposalStatus.PENDING + collection.find_one.assert_called_once_with({"proposal_id": str(proposal_id)}) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_proposed_change_not_found( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, +): + """Test retrieving a non-existent proposed change.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = None + + result = mongodb_get_proposed_change(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: mongodb_list_proposed_changes +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_proposed_changes_by_scene( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test listing proposed changes filtered by scene_id.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 1 + collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = [ + proposed_change_doc + ] + + scene_id = UUID(proposed_change_doc["scene_id"]) + params = ProposedChangeFilter(scene_id=scene_id) + result = mongodb_list_proposed_changes(params) + + assert result.total == 1 + assert len(result.proposed_changes) == 1 + assert result.proposed_changes[0].scene_id == scene_id + collection.count_documents.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_proposed_changes_by_story( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + story_data: Dict[str, Any], +): + """Test listing proposed changes filtered by story_id.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Create a story-level proposal doc + story_proposal_doc = { + "proposal_id": str(uuid4()), + "scene_id": None, + "story_id": story_data["id"], + "turn_id": None, + "change_type": ProposalType.EVENT.value, + "content": {"description": "Test event"}, + "evidence": [], + "confidence": 1.0, + "authority": Authority.GM.value, + "proposer": "Orchestrator", + "status": ProposalStatus.PENDING.value, + "decision_metadata": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 1 + collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = [ + story_proposal_doc + ] + + story_id = UUID(story_data["id"]) + params = ProposedChangeFilter(story_id=story_id) + result = mongodb_list_proposed_changes(params) + + assert result.total == 1 + assert len(result.proposed_changes) == 1 + assert result.proposed_changes[0].story_id == story_id + # Verify filter was passed correctly + call_args = collection.count_documents.call_args[0][0] + assert call_args["story_id"] == str(story_id) + collection.count_documents.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_proposed_changes_by_status( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test listing proposed changes filtered by status.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 2 + collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = [ + proposed_change_doc, + proposed_change_doc, + ] + + params = ProposedChangeFilter(status=ProposalStatus.PENDING) + result = mongodb_list_proposed_changes(params) + + assert result.total == 2 + assert len(result.proposed_changes) == 2 + # Verify filter was passed correctly + call_args = collection.count_documents.call_args[0][0] + assert call_args["status"] == ProposalStatus.PENDING.value + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_proposed_changes_by_change_type( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test listing proposed changes filtered by change_type.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 1 + collection.find.return_value.sort.return_value.skip.return_value.limit.return_value = [ + proposed_change_doc + ] + + params = ProposedChangeFilter(change_type=ProposalType.FACT) + result = mongodb_list_proposed_changes(params) + + assert result.total == 1 + # Verify filter was passed correctly + call_args = collection.count_documents.call_args[0][0] + assert call_args["change_type"] == ProposalType.FACT.value + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_proposed_changes_pagination( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test pagination in list_proposed_changes.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 100 + + # Create mock cursor + mock_cursor = Mock() + mock_sort = Mock() + mock_skip = Mock() + mock_limit = Mock() + + mock_cursor.sort = Mock(return_value=mock_sort) + mock_sort.skip = Mock(return_value=mock_skip) + mock_skip.limit = Mock(return_value=mock_limit) + mock_limit.__iter__ = Mock(return_value=iter([proposed_change_doc])) + + collection.find.return_value = mock_cursor + + params = ProposedChangeFilter(limit=10, offset=20) + result = mongodb_list_proposed_changes(params) + + assert result.total == 100 + assert result.limit == 10 + assert result.offset == 20 + mock_sort.skip.assert_called_once_with(20) + mock_skip.limit.assert_called_once_with(10) + + +# ============================================================================= +# TESTS: mongodb_update_proposed_change +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_proposed_change_accept( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test accepting a pending proposed change.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + + # Mock find operations + canonical_ref = uuid4() + accepted_doc = proposed_change_doc.copy() + accepted_doc["status"] = ProposalStatus.ACCEPTED.value + accepted_doc["decision_metadata"] = { + "decided_by": "CanonKeeper", + "decided_at": datetime.now(timezone.utc), + "reason": "Valid change", + "canonical_ref": str(canonical_ref), + } + + collection.find_one.side_effect = [ + proposed_change_doc, # Initial find for validation + accepted_doc, # Find after update + ] + collection.update_one.return_value = Mock() + + decision = DecisionMetadata( + decided_by="CanonKeeper", + decided_at=datetime.now(timezone.utc), + reason="Valid change", + canonical_ref=canonical_ref, + ) + params = ProposedChangeUpdate( + status=ProposalStatus.ACCEPTED, decision_metadata=decision + ) + + proposal_id = UUID(proposed_change_doc["proposal_id"]) + result = mongodb_update_proposed_change(proposal_id, params) + + assert result.status == ProposalStatus.ACCEPTED + assert result.decision_metadata is not None + assert result.decision_metadata.decided_by == "CanonKeeper" + assert result.decision_metadata.canonical_ref == canonical_ref + collection.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_proposed_change_reject( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test rejecting a pending proposed change.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + + rejected_doc = proposed_change_doc.copy() + rejected_doc["status"] = ProposalStatus.REJECTED.value + rejected_doc["decision_metadata"] = { + "decided_by": "CanonKeeper", + "decided_at": datetime.now(timezone.utc), + "reason": "Conflicts with canon", + "canonical_ref": None, + } + + collection.find_one.side_effect = [ + proposed_change_doc, # Initial find for validation + rejected_doc, # Find after update + ] + collection.update_one.return_value = Mock() + + decision = DecisionMetadata( + decided_by="CanonKeeper", + decided_at=datetime.now(timezone.utc), + reason="Conflicts with canon", + ) + params = ProposedChangeUpdate( + status=ProposalStatus.REJECTED, decision_metadata=decision + ) + + proposal_id = UUID(proposed_change_doc["proposal_id"]) + result = mongodb_update_proposed_change(proposal_id, params) + + assert result.status == ProposalStatus.REJECTED + assert result.decision_metadata is not None + assert result.decision_metadata.reason == "Conflicts with canon" + assert result.decision_metadata.canonical_ref is None + collection.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_proposed_change_invalid_transition( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test invalid status transition (already accepted).""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock an already accepted proposal + accepted_doc = proposed_change_doc.copy() + accepted_doc["status"] = ProposalStatus.ACCEPTED.value + + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = accepted_doc + + decision = DecisionMetadata( + decided_by="CanonKeeper", + decided_at=datetime.now(timezone.utc), + reason="Test", + ) + params = ProposedChangeUpdate( + status=ProposalStatus.REJECTED, decision_metadata=decision + ) + + proposal_id = UUID(proposed_change_doc["proposal_id"]) + + with pytest.raises(ValueError, match="Cannot update proposal with status accepted"): + mongodb_update_proposed_change(proposal_id, params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_proposed_change_invalid_status( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + proposed_change_doc: Dict[str, Any], +): + """Test updating to pending (invalid - can only go to accepted/rejected).""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = proposed_change_doc + + decision = DecisionMetadata( + decided_by="CanonKeeper", + decided_at=datetime.now(timezone.utc), + reason="Test", + ) + params = ProposedChangeUpdate( + status=ProposalStatus.PENDING, decision_metadata=decision + ) + + proposal_id = UUID(proposed_change_doc["proposal_id"]) + + with pytest.raises( + ValueError, match="Can only transition from pending to accepted or rejected" + ): + mongodb_update_proposed_change(proposal_id, params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_proposed_change_not_found( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, +): + """Test updating a non-existent proposed change.""" + mock_get_mongo.return_value = mock_mongodb_client + + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = None + + decision = DecisionMetadata( + decided_by="CanonKeeper", + decided_at=datetime.now(timezone.utc), + reason="Test", + ) + params = ProposedChangeUpdate( + status=ProposalStatus.ACCEPTED, decision_metadata=decision + ) + + with pytest.raises(ValueError, match="Proposal .* not found"): + mongodb_update_proposed_change(uuid4(), params)