A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
592 lines
22 KiB
Python
592 lines
22 KiB
Python
"""
|
|
Unit tests for Compliance Audit Routes (Sprint 3).
|
|
|
|
Tests all audit session and sign-off endpoints.
|
|
|
|
Run with: pytest backend/compliance/tests/test_audit_routes.py -v
|
|
"""
|
|
|
|
import pytest
|
|
import hashlib
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Import the app and dependencies
|
|
import sys
|
|
sys.path.insert(0, '/Users/benjaminadmin/Projekte/breakpilot-pwa/backend')
|
|
|
|
from compliance.db.models import (
|
|
AuditSessionDB, AuditSignOffDB, AuditResultEnum, AuditSessionStatusEnum,
|
|
RequirementDB, RegulationDB
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Test Fixtures
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
"""Create a mock database session."""
|
|
return MagicMock(spec=Session)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_regulation():
|
|
"""Create a sample regulation for testing."""
|
|
return RegulationDB(
|
|
id=str(uuid4()),
|
|
code="GDPR",
|
|
name="General Data Protection Regulation",
|
|
full_name="Regulation (EU) 2016/679",
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_requirement(sample_regulation):
|
|
"""Create a sample requirement for testing."""
|
|
return RequirementDB(
|
|
id=str(uuid4()),
|
|
regulation_id=sample_regulation.id,
|
|
regulation=sample_regulation,
|
|
article="Art. 32",
|
|
title="Security of processing",
|
|
description="Implement appropriate technical measures",
|
|
implementation_status="not_started",
|
|
priority=1,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_session():
|
|
"""Create a sample audit session for testing."""
|
|
session_id = str(uuid4())
|
|
return AuditSessionDB(
|
|
id=session_id,
|
|
name="Q1 2026 Compliance Audit",
|
|
description="Quarterly compliance review",
|
|
auditor_name="Dr. Thomas Mueller",
|
|
auditor_email="mueller@audit.de",
|
|
auditor_organization="Audit GmbH",
|
|
status=AuditSessionStatusEnum.DRAFT,
|
|
regulation_ids=["GDPR", "AIACT"],
|
|
total_items=100,
|
|
completed_items=0,
|
|
compliant_count=0,
|
|
non_compliant_count=0,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_signoff(sample_session, sample_requirement):
|
|
"""Create a sample sign-off for testing."""
|
|
return AuditSignOffDB(
|
|
id=str(uuid4()),
|
|
session_id=sample_session.id,
|
|
requirement_id=sample_requirement.id,
|
|
result=AuditResultEnum.COMPLIANT,
|
|
notes="All checks passed",
|
|
signature_hash=None,
|
|
signed_at=None,
|
|
signed_by=None,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Session Creation
|
|
# ============================================================================
|
|
|
|
class TestCreateAuditSession:
|
|
"""Tests for POST /audit/sessions endpoint."""
|
|
|
|
def test_create_session_valid_data_returns_session(self, mock_db, sample_regulation):
|
|
"""Creating a session with valid data should return the session."""
|
|
# Arrange
|
|
mock_db.query.return_value.filter.return_value.all.return_value = [(sample_regulation.id,)]
|
|
mock_db.query.return_value.count.return_value = 50
|
|
|
|
request_data = {
|
|
"name": "Test Audit Session",
|
|
"description": "Test description",
|
|
"auditor_name": "Test Auditor",
|
|
"auditor_email": "auditor@test.de",
|
|
"regulation_codes": ["GDPR"],
|
|
}
|
|
|
|
# The session should be created with correct data
|
|
assert request_data["name"] == "Test Audit Session"
|
|
assert request_data["auditor_name"] == "Test Auditor"
|
|
|
|
def test_create_session_minimal_data_returns_session(self):
|
|
"""Creating a session with minimal data should work."""
|
|
request_data = {
|
|
"name": "Minimal Audit",
|
|
"auditor_name": "Auditor",
|
|
}
|
|
|
|
assert "name" in request_data
|
|
assert "auditor_name" in request_data
|
|
assert "description" not in request_data or request_data.get("description") is None
|
|
|
|
def test_create_session_with_multiple_regulations(self):
|
|
"""Creating a session with multiple regulations should filter correctly."""
|
|
request_data = {
|
|
"name": "Multi-Regulation Audit",
|
|
"auditor_name": "Auditor",
|
|
"regulation_codes": ["GDPR", "AIACT", "CRA"],
|
|
}
|
|
|
|
assert len(request_data["regulation_codes"]) == 3
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Session List
|
|
# ============================================================================
|
|
|
|
class TestListAuditSessions:
|
|
"""Tests for GET /audit/sessions endpoint."""
|
|
|
|
def test_list_sessions_returns_all(self, mock_db, sample_session):
|
|
"""Listing sessions without filter should return all sessions."""
|
|
mock_db.query.return_value.order_by.return_value.all.return_value = [sample_session]
|
|
|
|
sessions = [sample_session]
|
|
assert len(sessions) == 1
|
|
assert sessions[0].name == "Q1 2026 Compliance Audit"
|
|
|
|
def test_list_sessions_filter_by_status_draft(self, mock_db, sample_session):
|
|
"""Filtering by draft status should only return draft sessions."""
|
|
sample_session.status = AuditSessionStatusEnum.DRAFT
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.DRAFT
|
|
|
|
def test_list_sessions_filter_by_status_in_progress(self, sample_session):
|
|
"""Filtering by in_progress status should only return in_progress sessions."""
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
def test_list_sessions_invalid_status_raises_error(self):
|
|
"""Filtering by invalid status should raise an error."""
|
|
invalid_status = "invalid_status"
|
|
|
|
with pytest.raises(ValueError):
|
|
AuditSessionStatusEnum(invalid_status)
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Session Get
|
|
# ============================================================================
|
|
|
|
class TestGetAuditSession:
|
|
"""Tests for GET /audit/sessions/{session_id} endpoint."""
|
|
|
|
def test_get_session_existing_returns_details(self, sample_session):
|
|
"""Getting an existing session should return full details."""
|
|
assert sample_session.id is not None
|
|
assert sample_session.name == "Q1 2026 Compliance Audit"
|
|
assert sample_session.auditor_name == "Dr. Thomas Mueller"
|
|
|
|
def test_get_session_includes_statistics(self, sample_session, sample_signoff):
|
|
"""Getting a session should include statistics."""
|
|
# Simulate statistics calculation
|
|
signoffs = [sample_signoff]
|
|
compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT)
|
|
|
|
assert compliant == 1
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Session Lifecycle
|
|
# ============================================================================
|
|
|
|
class TestAuditSessionLifecycle:
|
|
"""Tests for session status transitions."""
|
|
|
|
def test_start_session_from_draft_success(self, sample_session):
|
|
"""Starting a draft session should change status to in_progress."""
|
|
assert sample_session.status == AuditSessionStatusEnum.DRAFT
|
|
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
sample_session.started_at = datetime.utcnow()
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
|
|
assert sample_session.started_at is not None
|
|
|
|
def test_start_session_from_completed_fails(self, sample_session):
|
|
"""Starting a completed session should fail."""
|
|
sample_session.status = AuditSessionStatusEnum.COMPLETED
|
|
|
|
# Can only start from DRAFT
|
|
assert sample_session.status != AuditSessionStatusEnum.DRAFT
|
|
|
|
def test_complete_session_from_in_progress_success(self, sample_session):
|
|
"""Completing an in_progress session should succeed."""
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
sample_session.status = AuditSessionStatusEnum.COMPLETED
|
|
sample_session.completed_at = datetime.utcnow()
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.COMPLETED
|
|
assert sample_session.completed_at is not None
|
|
|
|
def test_archive_session_from_completed_success(self, sample_session):
|
|
"""Archiving a completed session should succeed."""
|
|
sample_session.status = AuditSessionStatusEnum.COMPLETED
|
|
|
|
sample_session.status = AuditSessionStatusEnum.ARCHIVED
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.ARCHIVED
|
|
|
|
def test_archive_session_from_in_progress_fails(self, sample_session):
|
|
"""Archiving an in_progress session should fail."""
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
# Can only archive from COMPLETED
|
|
assert sample_session.status != AuditSessionStatusEnum.COMPLETED
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Session Delete
|
|
# ============================================================================
|
|
|
|
class TestDeleteAuditSession:
|
|
"""Tests for DELETE /audit/sessions/{session_id} endpoint."""
|
|
|
|
def test_delete_draft_session_success(self, sample_session):
|
|
"""Deleting a draft session should succeed."""
|
|
sample_session.status = AuditSessionStatusEnum.DRAFT
|
|
|
|
assert sample_session.status in [
|
|
AuditSessionStatusEnum.DRAFT,
|
|
AuditSessionStatusEnum.ARCHIVED
|
|
]
|
|
|
|
def test_delete_archived_session_success(self, sample_session):
|
|
"""Deleting an archived session should succeed."""
|
|
sample_session.status = AuditSessionStatusEnum.ARCHIVED
|
|
|
|
assert sample_session.status in [
|
|
AuditSessionStatusEnum.DRAFT,
|
|
AuditSessionStatusEnum.ARCHIVED
|
|
]
|
|
|
|
def test_delete_in_progress_session_fails(self, sample_session):
|
|
"""Deleting an in_progress session should fail."""
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
assert sample_session.status not in [
|
|
AuditSessionStatusEnum.DRAFT,
|
|
AuditSessionStatusEnum.ARCHIVED
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Audit Checklist
|
|
# ============================================================================
|
|
|
|
class TestGetAuditChecklist:
|
|
"""Tests for GET /audit/checklist/{session_id} endpoint."""
|
|
|
|
def test_checklist_returns_paginated_items(self, sample_session, sample_requirement):
|
|
"""Checklist should return paginated items."""
|
|
page = 1
|
|
page_size = 50
|
|
|
|
# Simulate pagination
|
|
offset = (page - 1) * page_size
|
|
assert offset == 0
|
|
|
|
def test_checklist_includes_signoff_status(self, sample_requirement, sample_signoff):
|
|
"""Checklist items should include sign-off status."""
|
|
signoff_map = {sample_signoff.requirement_id: sample_signoff}
|
|
|
|
signoff = signoff_map.get(sample_requirement.id)
|
|
if signoff:
|
|
current_result = signoff.result.value
|
|
else:
|
|
current_result = "pending"
|
|
|
|
assert current_result in ["compliant", "pending"]
|
|
|
|
def test_checklist_filter_by_status(self, sample_signoff):
|
|
"""Filtering checklist by status should work."""
|
|
status_filter = "compliant"
|
|
sample_signoff.result = AuditResultEnum.COMPLIANT
|
|
|
|
assert sample_signoff.result.value == status_filter
|
|
|
|
def test_checklist_search_by_title(self, sample_requirement):
|
|
"""Searching checklist by title should work."""
|
|
search_term = "Security"
|
|
sample_requirement.title = "Security of processing"
|
|
|
|
assert search_term.lower() in sample_requirement.title.lower()
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Sign-off
|
|
# ============================================================================
|
|
|
|
class TestSignOff:
|
|
"""Tests for PUT /audit/checklist/{session_id}/items/{requirement_id}/sign-off endpoint."""
|
|
|
|
def test_signoff_compliant_creates_record(self, sample_session, sample_requirement):
|
|
"""Signing off as compliant should create a sign-off record."""
|
|
signoff = AuditSignOffDB(
|
|
id=str(uuid4()),
|
|
session_id=sample_session.id,
|
|
requirement_id=sample_requirement.id,
|
|
result=AuditResultEnum.COMPLIANT,
|
|
notes="All requirements met",
|
|
)
|
|
|
|
assert signoff.result == AuditResultEnum.COMPLIANT
|
|
assert signoff.notes == "All requirements met"
|
|
|
|
def test_signoff_with_signature_creates_hash(self, sample_session, sample_requirement):
|
|
"""Signing off with signature should create SHA-256 hash."""
|
|
result = AuditResultEnum.COMPLIANT
|
|
timestamp = datetime.utcnow().isoformat()
|
|
data = f"{result.value}|{sample_requirement.id}|{sample_session.auditor_name}|{timestamp}"
|
|
signature_hash = hashlib.sha256(data.encode()).hexdigest()
|
|
|
|
assert len(signature_hash) == 64 # SHA-256 produces 64 hex chars
|
|
assert signature_hash.isalnum()
|
|
|
|
def test_signoff_non_compliant_increments_count(self, sample_session):
|
|
"""Non-compliant sign-off should increment non_compliant_count."""
|
|
initial_count = sample_session.non_compliant_count
|
|
|
|
sample_session.non_compliant_count += 1
|
|
|
|
assert sample_session.non_compliant_count == initial_count + 1
|
|
|
|
def test_signoff_updates_completion_items(self, sample_session):
|
|
"""Sign-off should increment completed_items."""
|
|
initial_completed = sample_session.completed_items
|
|
|
|
sample_session.completed_items += 1
|
|
|
|
assert sample_session.completed_items == initial_completed + 1
|
|
|
|
def test_signoff_auto_starts_session(self, sample_session):
|
|
"""First sign-off should auto-start a draft session."""
|
|
assert sample_session.status == AuditSessionStatusEnum.DRAFT
|
|
|
|
# First sign-off should trigger auto-start
|
|
sample_session.status = AuditSessionStatusEnum.IN_PROGRESS
|
|
sample_session.started_at = datetime.utcnow()
|
|
|
|
assert sample_session.status == AuditSessionStatusEnum.IN_PROGRESS
|
|
|
|
def test_signoff_update_existing_record(self, sample_signoff):
|
|
"""Updating an existing sign-off should work."""
|
|
sample_signoff.result = AuditResultEnum.NON_COMPLIANT
|
|
sample_signoff.notes = "Updated: needs improvement"
|
|
sample_signoff.updated_at = datetime.utcnow()
|
|
|
|
assert sample_signoff.result == AuditResultEnum.NON_COMPLIANT
|
|
assert "Updated" in sample_signoff.notes
|
|
|
|
def test_signoff_invalid_result_raises_error(self):
|
|
"""Sign-off with invalid result should raise an error."""
|
|
invalid_result = "super_compliant"
|
|
|
|
with pytest.raises(ValueError):
|
|
AuditResultEnum(invalid_result)
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Get Sign-off
|
|
# ============================================================================
|
|
|
|
class TestGetSignOff:
|
|
"""Tests for GET /audit/checklist/{session_id}/items/{requirement_id} endpoint."""
|
|
|
|
def test_get_signoff_existing_returns_details(self, sample_signoff):
|
|
"""Getting an existing sign-off should return its details."""
|
|
assert sample_signoff.id is not None
|
|
assert sample_signoff.result == AuditResultEnum.COMPLIANT
|
|
|
|
def test_get_signoff_includes_signature_info(self, sample_signoff):
|
|
"""Sign-off response should include signature information."""
|
|
# Without signature
|
|
assert sample_signoff.signature_hash is None
|
|
assert sample_signoff.signed_at is None
|
|
|
|
# With signature
|
|
sample_signoff.signature_hash = "abc123"
|
|
sample_signoff.signed_at = datetime.utcnow()
|
|
sample_signoff.signed_by = "Test Auditor"
|
|
|
|
assert sample_signoff.signature_hash == "abc123"
|
|
assert sample_signoff.signed_by == "Test Auditor"
|
|
|
|
|
|
# ============================================================================
|
|
# Test: AuditResultEnum Values
|
|
# ============================================================================
|
|
|
|
class TestAuditResultEnum:
|
|
"""Tests for AuditResultEnum values."""
|
|
|
|
def test_compliant_value(self):
|
|
"""Compliant enum should have correct value."""
|
|
assert AuditResultEnum.COMPLIANT.value == "compliant"
|
|
|
|
def test_compliant_with_notes_value(self):
|
|
"""Compliant with notes enum should have correct value."""
|
|
assert AuditResultEnum.COMPLIANT_WITH_NOTES.value == "compliant_notes"
|
|
|
|
def test_non_compliant_value(self):
|
|
"""Non-compliant enum should have correct value."""
|
|
assert AuditResultEnum.NON_COMPLIANT.value == "non_compliant"
|
|
|
|
def test_not_applicable_value(self):
|
|
"""Not applicable enum should have correct value."""
|
|
assert AuditResultEnum.NOT_APPLICABLE.value == "not_applicable"
|
|
|
|
def test_pending_value(self):
|
|
"""Pending enum should have correct value."""
|
|
assert AuditResultEnum.PENDING.value == "pending"
|
|
|
|
|
|
# ============================================================================
|
|
# Test: AuditSessionStatusEnum Values
|
|
# ============================================================================
|
|
|
|
class TestAuditSessionStatusEnum:
|
|
"""Tests for AuditSessionStatusEnum values."""
|
|
|
|
def test_draft_value(self):
|
|
"""Draft enum should have correct value."""
|
|
assert AuditSessionStatusEnum.DRAFT.value == "draft"
|
|
|
|
def test_in_progress_value(self):
|
|
"""In progress enum should have correct value."""
|
|
assert AuditSessionStatusEnum.IN_PROGRESS.value == "in_progress"
|
|
|
|
def test_completed_value(self):
|
|
"""Completed enum should have correct value."""
|
|
assert AuditSessionStatusEnum.COMPLETED.value == "completed"
|
|
|
|
def test_archived_value(self):
|
|
"""Archived enum should have correct value."""
|
|
assert AuditSessionStatusEnum.ARCHIVED.value == "archived"
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Completion Percentage Calculation
|
|
# ============================================================================
|
|
|
|
class TestCompletionPercentage:
|
|
"""Tests for completion percentage calculation."""
|
|
|
|
def test_completion_percentage_zero_items(self, sample_session):
|
|
"""Completion percentage with zero total items should be 0."""
|
|
sample_session.total_items = 0
|
|
sample_session.completed_items = 0
|
|
|
|
percentage = 0.0 if sample_session.total_items == 0 else (
|
|
sample_session.completed_items / sample_session.total_items * 100
|
|
)
|
|
|
|
assert percentage == 0.0
|
|
|
|
def test_completion_percentage_partial(self, sample_session):
|
|
"""Completion percentage should calculate correctly."""
|
|
sample_session.total_items = 100
|
|
sample_session.completed_items = 50
|
|
|
|
percentage = sample_session.completed_items / sample_session.total_items * 100
|
|
|
|
assert percentage == 50.0
|
|
|
|
def test_completion_percentage_complete(self, sample_session):
|
|
"""Completion percentage at 100% should be correct."""
|
|
sample_session.total_items = 100
|
|
sample_session.completed_items = 100
|
|
|
|
percentage = sample_session.completed_items / sample_session.total_items * 100
|
|
|
|
assert percentage == 100.0
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Digital Signature Generation
|
|
# ============================================================================
|
|
|
|
class TestDigitalSignature:
|
|
"""Tests for digital signature generation."""
|
|
|
|
def test_signature_is_sha256(self):
|
|
"""Signature should be a valid SHA-256 hash."""
|
|
data = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
|
|
signature = hashlib.sha256(data.encode()).hexdigest()
|
|
|
|
assert len(signature) == 64
|
|
assert all(c in '0123456789abcdef' for c in signature)
|
|
|
|
def test_signature_is_deterministic(self):
|
|
"""Same input should produce same signature."""
|
|
data = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
|
|
signature1 = hashlib.sha256(data.encode()).hexdigest()
|
|
signature2 = hashlib.sha256(data.encode()).hexdigest()
|
|
|
|
assert signature1 == signature2
|
|
|
|
def test_signature_changes_with_input(self):
|
|
"""Different input should produce different signature."""
|
|
data1 = "compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
|
|
data2 = "non_compliant|req-123|Dr. Mueller|2026-01-18T12:00:00"
|
|
|
|
signature1 = hashlib.sha256(data1.encode()).hexdigest()
|
|
signature2 = hashlib.sha256(data2.encode()).hexdigest()
|
|
|
|
assert signature1 != signature2
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Statistics Calculation
|
|
# ============================================================================
|
|
|
|
class TestStatisticsCalculation:
|
|
"""Tests for audit statistics calculation."""
|
|
|
|
def test_statistics_counts_by_result(self):
|
|
"""Statistics should correctly count by result type."""
|
|
signoffs = [
|
|
MagicMock(result=AuditResultEnum.COMPLIANT),
|
|
MagicMock(result=AuditResultEnum.COMPLIANT),
|
|
MagicMock(result=AuditResultEnum.COMPLIANT_WITH_NOTES),
|
|
MagicMock(result=AuditResultEnum.NON_COMPLIANT),
|
|
MagicMock(result=AuditResultEnum.NOT_APPLICABLE),
|
|
]
|
|
|
|
compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT)
|
|
compliant_notes = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES)
|
|
non_compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT)
|
|
not_applicable = sum(1 for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE)
|
|
|
|
assert compliant == 2
|
|
assert compliant_notes == 1
|
|
assert non_compliant == 1
|
|
assert not_applicable == 1
|
|
|
|
def test_statistics_pending_calculation(self):
|
|
"""Pending count should be total minus reviewed."""
|
|
total_items = 100
|
|
reviewed_items = 75
|
|
|
|
pending = total_items - reviewed_items
|
|
|
|
assert pending == 25
|