DocWeave / backend /tests /test_proposal_lifecycle.py
shak3008's picture
fix: harden knowledge governance and reconciliation
35ee1ab
Raw
History Blame Contribute Delete
16.7 kB
"""
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."
)