""" Proposal Lifecycle Tests ======================== Tests the complete knowledge-item status transitions through proposal approval and rejection workflows. ISOLATION: These tests create a dedicated test workspace, user, and all associated records. They NEVER use existing workspaces or records. All test data is cleaned up deterministically. Covers: - CREATE + APPROVE → PENDING → ACTIVE - CREATE + REJECT → PENDING → REJECTED - UPDATE + APPROVE → target stays ACTIVE (values updated), source → SUPERSEDED - UPDATE + REJECT → target unchanged, source unchanged - DELETE + APPROVE → ACTIVE → ARCHIVED - DELETE + REJECT → ACTIVE unchanged - Guard: reject CREATE where item already ACTIVE → no change - Guard: double-reject → 409 - Guard: reject CREATE with NULL knowledge_item_id → no error - Sentinel: pre-existing data is never modified """ import sys import os import uuid from datetime import datetime, timezone import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.database.database import SessionLocal from app.models.commit import Commit from app.models.document import Document from app.models.document_version import DocumentVersion, DocumentVersionStatus from app.models.knowledge_item import KnowledgeItem, KnowledgeStatus, KnowledgeType from app.models.proposal import Proposal, ProposalStatus, ProposalType from app.models.review import Review from app.models.user import User from app.models.workspace import Workspace from app.services.proposal_review_service import ProposalReviewService # ------------------------------------------------------------------ # Unique test run identifier (avoids collisions if tests run in parallel) # ------------------------------------------------------------------ _TEST_RUN_ID = uuid.uuid4().hex[:8] # ------------------------------------------------------------------ # Fixtures — fully isolated workspace # ------------------------------------------------------------------ @pytest.fixture(scope="module") def db(): """Module-scoped session for setup/teardown.""" session = SessionLocal() yield session session.close() @pytest.fixture(scope="module") def isolated_env(db): """ Create a completely isolated test environment: - Dedicated test user - Dedicated test workspace - Dedicated document + version All test operations use these records exclusively. Cleaned up at module teardown. """ # Create test user test_user = User( username=f"lifecycle_test_user_{_TEST_RUN_ID}", email=f"lifecycle_{_TEST_RUN_ID}@test.docweave.local", hashed_password="not_a_real_hash", role="operator", ) db.add(test_user) db.flush() # Create test workspace test_workspace = Workspace( name=f"lifecycle_test_workspace_{_TEST_RUN_ID}", description="Isolated workspace for proposal lifecycle tests", created_by=test_user.id, ) db.add(test_workspace) db.flush() # Create test document + version test_doc = Document( workspace_id=test_workspace.id, title=f"lifecycle_test_doc_{_TEST_RUN_ID}", document_type="GENERAL", ) db.add(test_doc) db.flush() test_version = DocumentVersion( document_id=test_doc.id, version_number=1, filename=f"lifecycle_test_{_TEST_RUN_ID}.txt", file_type=".txt", checksum=f"lifecycle_{_TEST_RUN_ID}", storage_path=f"/tmp/lifecycle_test_{_TEST_RUN_ID}.txt", status=DocumentVersionStatus.PROCESSED, uploaded_by=test_user.id, ) db.add(test_version) db.commit() yield { "user": test_user, "workspace": test_workspace, "doc": test_doc, "version": test_version, } # --- Deterministic cleanup --- try: # Delete commits referencing proposals in this workspace db.query(Commit).filter( Commit.workspace_id == test_workspace.id ).delete(synchronize_session=False) # Delete reviews for proposals in this workspace proposal_ids_subq = db.query(Proposal.id).filter( Proposal.workspace_id == test_workspace.id ) db.query(Review).filter( Review.proposal_id.in_(proposal_ids_subq) ).delete(synchronize_session=False) # Delete proposals db.query(Proposal).filter( Proposal.workspace_id == test_workspace.id ).delete(synchronize_session=False) # Delete knowledge items db.query(KnowledgeItem).filter( KnowledgeItem.workspace_id == test_workspace.id ).delete(synchronize_session=False) # Delete document version + document db.query(DocumentVersion).filter( DocumentVersion.id == test_version.id ).delete(synchronize_session=False) db.query(Document).filter( Document.id == test_doc.id ).delete(synchronize_session=False) # Delete workspace + user db.query(Workspace).filter( Workspace.id == test_workspace.id ).delete(synchronize_session=False) db.query(User).filter( User.id == test_user.id ).delete(synchronize_session=False) db.commit() except Exception: db.rollback() @pytest.fixture() def test_db(): """Per-test session for service calls (ProposalReviewService commits internally).""" session = SessionLocal() yield session session.close() # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _create_knowledge_item(db, workspace, version, status=KnowledgeStatus.PENDING): """Create a test knowledge item in the isolated workspace.""" item = KnowledgeItem( workspace_id=workspace.id, document_version_id=version.id, type=KnowledgeType.CLAIM, title=f"Test Claim {uuid.uuid4().hex[:8]}", value="Test value for lifecycle test", summary="Test summary", confidence=0.9, status=status, ) db.add(item) db.commit() db.refresh(item) return item def _create_proposal(db, workspace, item, proposal_type, proposed_changes=None): """Create a test proposal in the isolated workspace.""" proposal = Proposal( workspace_id=workspace.id, knowledge_item_id=item.id if item else None, proposal_type=proposal_type, status=ProposalStatus.PENDING, summary=f"Test {proposal_type.value} proposal {uuid.uuid4().hex[:6]}", rationale="Testing lifecycle", proposed_changes=proposed_changes or {"title": "test", "value": "test"}, ) db.add(proposal) db.commit() db.refresh(proposal) return proposal # ------------------------------------------------------------------ # CREATE + APPROVE # ------------------------------------------------------------------ def test_create_approve(test_db, isolated_env): """CREATE proposal approval transitions knowledge item PENDING → ACTIVE.""" env = isolated_env item = _create_knowledge_item(test_db, env["workspace"], env["version"]) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE) service = ProposalReviewService(test_db) commit = service.approve( proposal_id=proposal.id, user_id=env["user"].id, comments="Approved in test", ) test_db.refresh(item) test_db.refresh(proposal) assert item.status == KnowledgeStatus.ACTIVE assert proposal.status == ProposalStatus.APPROVED assert commit is not None # ------------------------------------------------------------------ # CREATE + REJECT # ------------------------------------------------------------------ def test_create_reject(test_db, isolated_env): """CREATE proposal rejection transitions knowledge item PENDING → REJECTED.""" env = isolated_env item = _create_knowledge_item(test_db, env["workspace"], env["version"]) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE) service = ProposalReviewService(test_db) review = service.reject( proposal_id=proposal.id, user_id=env["user"].id, comments="Rejected in test", ) test_db.refresh(item) test_db.refresh(proposal) assert item.status == KnowledgeStatus.REJECTED assert proposal.status == ProposalStatus.REJECTED assert review is not None # Verify no commit was created commit = test_db.query(Commit).filter(Commit.proposal_id == proposal.id).first() assert commit is None # ------------------------------------------------------------------ # UPDATE + APPROVE # ------------------------------------------------------------------ def test_update_approve(test_db, isolated_env): """UPDATE proposal approval updates target item values, marks source SUPERSEDED.""" env = isolated_env target = _create_knowledge_item( test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE ) source = _create_knowledge_item(test_db, env["workspace"], env["version"]) proposal = _create_proposal( test_db, env["workspace"], target, ProposalType.UPDATE, proposed_changes={ "existing": {"value": target.value, "confidence": target.confidence}, "proposed": {"value": "Updated value", "confidence": 0.95}, "source_knowledge_item_id": str(source.id), }, ) service = ProposalReviewService(test_db) commit = service.approve( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(target) test_db.refresh(source) assert target.status == KnowledgeStatus.ACTIVE assert target.value == "Updated value" assert target.confidence == 0.95 assert source.status == KnowledgeStatus.SUPERSEDED assert commit is not None # ------------------------------------------------------------------ # UPDATE + REJECT # ------------------------------------------------------------------ def test_update_reject(test_db, isolated_env): """UPDATE proposal rejection leaves both target and source unchanged.""" env = isolated_env target = _create_knowledge_item( test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE ) source = _create_knowledge_item(test_db, env["workspace"], env["version"]) original_value = target.value proposal = _create_proposal( test_db, env["workspace"], target, ProposalType.UPDATE, proposed_changes={ "existing": {"value": target.value}, "proposed": {"value": "Should not be applied"}, "source_knowledge_item_id": str(source.id), }, ) service = ProposalReviewService(test_db) service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(target) test_db.refresh(source) assert target.status == KnowledgeStatus.ACTIVE assert target.value == original_value assert source.status == KnowledgeStatus.PENDING # ------------------------------------------------------------------ # DELETE + APPROVE # ------------------------------------------------------------------ def test_delete_approve(test_db, isolated_env): """DELETE proposal approval transitions knowledge item ACTIVE → ARCHIVED.""" env = isolated_env item = _create_knowledge_item( test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE ) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.DELETE) service = ProposalReviewService(test_db) commit = service.approve( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(item) assert item.status == KnowledgeStatus.ARCHIVED assert commit is not None # ------------------------------------------------------------------ # DELETE + REJECT # ------------------------------------------------------------------ def test_delete_reject(test_db, isolated_env): """DELETE proposal rejection leaves knowledge item ACTIVE.""" env = isolated_env item = _create_knowledge_item( test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE ) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.DELETE) service = ProposalReviewService(test_db) service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(item) assert item.status == KnowledgeStatus.ACTIVE # ------------------------------------------------------------------ # Guard: reject CREATE where item already ACTIVE # ------------------------------------------------------------------ def test_reject_create_already_active(test_db, isolated_env): """If knowledge item is already ACTIVE, CREATE rejection does NOT change it.""" env = isolated_env item = _create_knowledge_item( test_db, env["workspace"], env["version"], status=KnowledgeStatus.ACTIVE ) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE) service = ProposalReviewService(test_db) service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(item) assert item.status == KnowledgeStatus.ACTIVE # ------------------------------------------------------------------ # Guard: double-reject → 409 # ------------------------------------------------------------------ def test_double_reject_returns_409(test_db, isolated_env): """Rejecting an already-rejected proposal raises HTTP 409.""" env = isolated_env item = _create_knowledge_item(test_db, env["workspace"], env["version"]) proposal = _create_proposal(test_db, env["workspace"], item, ProposalType.CREATE) service = ProposalReviewService(test_db) service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) from fastapi import HTTPException with pytest.raises(HTTPException) as exc_info: service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) assert exc_info.value.status_code == 409 # ------------------------------------------------------------------ # Guard: reject CREATE with NULL knowledge_item_id # ------------------------------------------------------------------ def test_reject_create_null_knowledge_item_id(test_db, isolated_env): """Rejecting a CREATE proposal with no knowledge_item_id completes without error.""" env = isolated_env proposal = Proposal( workspace_id=env["workspace"].id, knowledge_item_id=None, proposal_type=ProposalType.CREATE, status=ProposalStatus.PENDING, summary=f"Test CREATE with no item {uuid.uuid4().hex[:6]}", rationale="Edge case test", proposed_changes={"title": "ghost"}, ) test_db.add(proposal) test_db.commit() test_db.refresh(proposal) service = ProposalReviewService(test_db) review = service.reject( proposal_id=proposal.id, user_id=env["user"].id, ) test_db.refresh(proposal) assert proposal.status == ProposalStatus.REJECTED assert review is not None # ------------------------------------------------------------------ # Sentinel: verify no pre-existing records were modified # ------------------------------------------------------------------ def test_sentinel_no_real_data_modified(test_db, isolated_env): """ Verify that no KnowledgeItem outside our test workspace was modified during this test session. The test workspace has a unique ID. Any knowledge item NOT in our workspace should have its status unchanged by our tests. """ env = isolated_env # Count items in the real Heart workspace that are REJECTED # with updated_at after our test workspace was created from sqlalchemy import text result = test_db.execute(text(""" SELECT COUNT(*) FROM knowledge_items WHERE workspace_id != :ws_id AND updated_at > :threshold """), { "ws_id": str(env["workspace"].id), "threshold": env["workspace"].created_at, }) modified_count = result.scalar() # If any non-test items were modified after our workspace was created, # something is wrong with test isolation assert modified_count == 0, ( f"{modified_count} knowledge items outside the test workspace were " f"modified during this test run. Test isolation is broken." )