from __future__ import annotations import sqlite3 import tempfile import threading import unittest from pathlib import Path from project_bus.db import Database from project_bus.errors import AuthorizationError, ConflictError, ValidationError from project_bus.models import Principal from project_bus.service import ProjectBusService class ServiceTestCase(unittest.TestCase): def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.database_path = Path(self.temporary.name) / "bus.db" self.database = Database(self.database_path) self.service = ProjectBusService(self.database) self.service.initialize() self.system = Principal("system", "system", "System") self.service.create_project( self.system, project_id="sandbox", name="Sandbox", idempotency_key="create-sandbox", ) self.principals: dict[str, Principal] = {} for actor_id, role in ( ("po", "PO"), ("pm", "PM"), ("pa", "PA"), ("lead", "LEAD_ENGINEER"), ("impl2", "IMPLEMENTER"), ("reviewer", "REVIEWER"), ("security", "SECURITY_REVIEWER"), ): self.service.register_actor( self.system, project_id="sandbox", actor_id=actor_id, auth_subject=f"subject-{actor_id}", display_name=actor_id.title(), role=role, idempotency_key=f"register-{actor_id}", ) self.principals[actor_id] = Principal( actor_id, f"subject-{actor_id}", actor_id.title() ) def tearDown(self) -> None: self.temporary.cleanup() def issue(self, work_package_id: str = "WP-001") -> None: self.service.issue_work_package( self.principals["pm"], project_id="sandbox", work_package_id=work_package_id, title="Build it", description="Implement the bounded feature.", acceptance_criteria=["Tests pass", "Evidence linked"], idempotency_key=f"issue-{work_package_id}", ) def submit_and_review(self, work_package_id: str = "WP-001") -> str: self.service.claim_work_package( self.principals["lead"], project_id="sandbox", work_package_id=work_package_id, idempotency_key=f"claim-{work_package_id}", ) self.service.submit_result( self.principals["lead"], project_id="sandbox", work_package_id=work_package_id, summary="Implemented.", evidence=[{"type": "test", "value": "all green"}], idempotency_key=f"result-{work_package_id}", ) requested = self.service.request_review( self.principals["pa"], project_id="sandbox", work_package_id=work_package_id, reviewer_id="reviewer", review_type="technical", idempotency_key=f"request-review-{work_package_id}", ) self.service.submit_review( self.principals["reviewer"], project_id="sandbox", review_id=requested["review_id"], verdict="APPROVE", findings=[], summary="Approved independently.", idempotency_key=f"review-{work_package_id}", ) return requested["review_id"] def test_full_workflow_gate_and_baseline(self) -> None: self.issue() self.submit_and_review() linked = self.service.link_commit( self.principals["lead"], project_id="sandbox", work_package_id="WP-001", repository="git.example/project-bus", commit_sha="0123456789abcdef", relation="implementation", idempotency_key="link-commit", ) self.assertEqual(linked["event"]["event_type"], "commit.linked") gate = self.service.close_gate( self.principals["pm"], project_id="sandbox", work_package_id="WP-001", outcome="ACCEPT", rationale="Criteria and review satisfied.", idempotency_key="close-gate", ) self.assertEqual(gate["status"], "ACCEPTED") baseline = self.service.baseline_release( self.principals["pm"], project_id="sandbox", version="0.1.0", artifact_uri="https://artifacts.example/project-bus-0.1.0.tar.gz", digest="sha256:abc", included_work_packages=["WP-001"], idempotency_key="release-010", ) self.assertEqual(baseline["version"], "0.1.0") with self.assertRaises(ConflictError): self.service.close_gate( self.principals["pm"], project_id="sandbox", work_package_id="WP-001", outcome="HOLD", rationale="Cannot reopen a terminal gate.", idempotency_key="reopen-gate", ) with self.assertRaises(ConflictError): self.service.baseline_release( self.principals["pm"], project_id="sandbox", version="0.1.0", artifact_uri="https://artifacts.example/other.tar.gz", digest="sha256:different", included_work_packages=["WP-001"], idempotency_key="duplicate-release-version", ) events = self.service.sync_since( self.principals["pa"], project_id="sandbox", cursor=0, limit=100 ) self.assertEqual(events["events"][-1]["event_type"], "baseline.released") self.assertFalse(events["has_more"]) def test_pa_cannot_record_decision_or_close_gate(self) -> None: with self.assertRaises(AuthorizationError): self.service.record_decision( self.principals["pa"], project_id="sandbox", decision_id="DEC-001", kind="ARCHITECTURE", title="Architecture", decision="Use option A.", rationale="Because.", work_package_id=None, idempotency_key="pa-decision", ) self.issue() with self.assertRaises(AuthorizationError): self.service.close_gate( self.principals["pa"], project_id="sandbox", work_package_id="WP-001", outcome="HOLD", rationale="Waiting.", idempotency_key="pa-gate", ) def test_only_po_can_record_product_decision(self) -> None: with self.assertRaises(AuthorizationError): self.service.record_decision( self.principals["pm"], project_id="sandbox", decision_id="DEC-PRODUCT-1", kind="PRODUCT", title="Product scope", decision="Add a feature.", rationale="Customer need.", work_package_id=None, idempotency_key="pm-product", ) result = self.service.record_decision( self.principals["po"], project_id="sandbox", decision_id="DEC-PRODUCT-1", kind="PRODUCT", title="Product scope", decision="Add a feature.", rationale="Customer need.", work_package_id=None, idempotency_key="po-product", ) self.assertEqual(result["kind"], "PRODUCT") def test_reviewer_must_be_independent_and_have_review_role(self) -> None: self.issue() self.service.claim_work_package( self.principals["lead"], project_id="sandbox", work_package_id="WP-001", idempotency_key="claim", ) self.service.submit_result( self.principals["lead"], project_id="sandbox", work_package_id="WP-001", summary="Done.", evidence=[], idempotency_key="result", ) with self.assertRaises(ValidationError): self.service.request_review( self.principals["pa"], project_id="sandbox", work_package_id="WP-001", reviewer_id="lead", review_type="technical", idempotency_key="self-review", ) def test_gate_accept_requires_approved_review(self) -> None: self.issue() self.service.claim_work_package( self.principals["lead"], project_id="sandbox", work_package_id="WP-001", idempotency_key="claim", ) self.service.submit_result( self.principals["lead"], project_id="sandbox", work_package_id="WP-001", summary="Done.", evidence=[], idempotency_key="result", ) with self.assertRaises(ConflictError): self.service.close_gate( self.principals["pm"], project_id="sandbox", work_package_id="WP-001", outcome="ACCEPT", rationale="Premature.", idempotency_key="premature-gate", ) def test_idempotent_replay_and_key_mismatch(self) -> None: first = self.service.issue_work_package( self.principals["pm"], project_id="sandbox", work_package_id="WP-001", title="Build it", description="First request.", acceptance_criteria=["Pass"], idempotency_key="same-key", ) second = self.service.issue_work_package( self.principals["pm"], project_id="sandbox", work_package_id="WP-001", title="Build it", description="First request.", acceptance_criteria=["Pass"], idempotency_key="same-key", ) self.assertFalse(first["idempotent_replay"]) self.assertTrue(second["idempotent_replay"]) self.assertEqual(first["event"]["event_id"], second["event"]["event_id"]) with self.assertRaises(ConflictError): self.service.issue_work_package( self.principals["pm"], project_id="sandbox", work_package_id="WP-OTHER", title="Different", description="Different request.", acceptance_criteria=["Pass"], idempotency_key="same-key", ) def test_concurrent_claim_has_single_winner(self) -> None: self.issue() barrier = threading.Barrier(2) outcomes: list[str] = [] lock = threading.Lock() def claim(actor_id: str) -> None: barrier.wait() try: self.service.claim_work_package( self.principals[actor_id], project_id="sandbox", work_package_id="WP-001", idempotency_key=f"claim-{actor_id}", ) value = "won" except ConflictError: value = "lost" with lock: outcomes.append(value) first = threading.Thread(target=claim, args=("lead",)) second = threading.Thread(target=claim, args=("impl2",)) first.start() second.start() first.join() second.join() self.assertCountEqual(outcomes, ["won", "lost"]) def test_cursor_pagination_has_no_duplicates(self) -> None: self.issue("WP-001") self.issue("WP-002") first = self.service.sync_since( self.principals["pa"], project_id="sandbox", cursor=0, limit=3 ) second = self.service.sync_since( self.principals["pa"], project_id="sandbox", cursor=first["next_cursor"], limit=100, ) first_ids = {event["event_id"] for event in first["events"]} second_ids = {event["event_id"] for event in second["events"]} self.assertFalse(first_ids & second_ids) self.assertTrue(first["has_more"]) def test_work_package_ids_are_project_scoped_and_reads_are_isolated(self) -> None: self.issue("WP-001") self.service.create_project( self.system, project_id="other", name="Other", idempotency_key="create-other", ) self.service.register_actor( self.system, project_id="other", actor_id="pm", auth_subject="subject-pm", display_name="Pm", role="PM", idempotency_key="other-register-pm", ) self.service.issue_work_package( self.principals["pm"], project_id="other", work_package_id="WP-001", title="Same local identifier", description="Different project.", acceptance_criteria=["Isolated"], idempotency_key="other-issue-wp", ) with self.assertRaises(AuthorizationError): self.service.get_work_package( self.principals["pa"], project_id="other", work_package_id="WP-001", ) def test_event_log_rejects_update_and_delete(self) -> None: self.issue() with self.database.transaction(write=True) as connection: event_id = connection.execute( "SELECT event_id FROM event_log ORDER BY cursor LIMIT 1" ).fetchone()["event_id"] with self.assertRaises(sqlite3.IntegrityError): connection.execute( "UPDATE event_log SET event_type = 'tampered' WHERE event_id = ?", (event_id,), ) with self.database.transaction(write=True) as connection: with self.assertRaises(sqlite3.IntegrityError): connection.execute("DELETE FROM event_log") def test_baseline_rejects_unaccepted_work_package(self) -> None: self.issue() with self.assertRaises(ConflictError): self.service.baseline_release( self.principals["pm"], project_id="sandbox", version="bad", artifact_uri="artifact://bad", digest="sha256:bad", included_work_packages=["WP-001"], idempotency_key="bad-baseline", ) def test_escalation_can_be_raised_by_pa_but_only_resolved_by_pm(self) -> None: escalation = self.service.raise_escalation( self.principals["pa"], project_id="sandbox", severity="HIGH", title="Decision needed", description="Tolerance exceeded.", target_role="PM", idempotency_key="raise-escalation", ) with self.assertRaises(AuthorizationError): self.service.resolve_escalation( self.principals["pa"], project_id="sandbox", escalation_id=escalation["escalation_id"], resolution="Resolved by PA.", idempotency_key="pa-resolve", ) resolved = self.service.resolve_escalation( self.principals["pm"], project_id="sandbox", escalation_id=escalation["escalation_id"], resolution="PM supplied the required decision.", idempotency_key="pm-resolve", ) self.assertEqual(resolved["status"], "RESOLVED") if __name__ == "__main__": unittest.main()