| """ |
| 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 |
|
|
|
|
| |
| |
| |
| _TEST_RUN_ID = uuid.uuid4().hex[:8] |
|
|
|
|
| |
| |
| |
|
|
| @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. |
| """ |
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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, |
| } |
|
|
| |
| try: |
| |
| db.query(Commit).filter( |
| Commit.workspace_id == test_workspace.id |
| ).delete(synchronize_session=False) |
|
|
| |
| 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) |
|
|
| |
| db.query(Proposal).filter( |
| Proposal.workspace_id == test_workspace.id |
| ).delete(synchronize_session=False) |
|
|
| |
| db.query(KnowledgeItem).filter( |
| KnowledgeItem.workspace_id == test_workspace.id |
| ).delete(synchronize_session=False) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| commit = test_db.query(Commit).filter(Commit.proposal_id == proposal.id).first() |
| assert commit is None |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| |
| |
| 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() |
| |
| |
| assert modified_count == 0, ( |
| f"{modified_count} knowledge items outside the test workspace were " |
| f"modified during this test run. Test isolation is broken." |
| ) |
|
|