# Canvas Plugin Testing Guide

This document provides testing patterns for Canvas plugins.

## ⚠️ CRITICAL TESTING RULES ⚠️

### Rule #1: Verify EVERY Mock Object

**EVERY SINGLE MOCK in EVERY SINGLE TEST must be verified using `mock.mock_calls`.**

This is mandatory. No exceptions. No shortcuts.

- If you create a mock, verify it
- If you receive a mock fixture, verify it
- If you patch something, verify it
- If a mock is never used, verify `mock_calls == []`

### Rule #2: Use `mock.mock_calls` ONLY

**ONLY use `mock.mock_calls` for verification:**
```python
# ✅ CORRECT
assert mock_object.mock_calls == [call.method(), call.other()]

# ❌ FORBIDDEN - Never use these
mock.assert_called()
mock.assert_called_once_with()
mock.call_count
```

### Rule #3: Every Test Must Follow This Pattern

```python
def test_something(mock_fixture):
    # 1. Setup
    mock_local = MagicMock()

    # 2. Execute
    with patch("module.Thing") as mock_patched:
        result = function_under_test(mock_fixture, mock_local)

        # 3. Verify ALL mocks (in order: patched, fixtures, local)
        assert mock_patched.mock_calls == [...]
        assert mock_fixture.mock_calls == [...]
        assert mock_local.mock_calls == [...]

        # 4. Then verify output
        assert result == expected
```

**If you skip verifying any mock, the test is incomplete.**

## Test Data: Use Manual Mocking, NOT Factories

**Do NOT use factory libraries** (factory_boy, etc.) or look for existing factories. They don't exist for Canvas plugins and you don't need them.

**Always create test data manually using MagicMock:**

```python
# GOOD - manual mock, simple and direct
mock_event = MagicMock()
mock_event.context = {"patient": {"id": "test-123"}}
mock_event.target.instance.blood_pressure_systolic = 150

# BAD - don't do this
from some_factory import EventFactory  # Doesn't exist
event = EventFactory.create(patient_id="test-123")  # Won't work
```

**Why manual mocking:**
- Canvas SDK objects are simple to mock
- No factory setup/maintenance overhead
- Tests are explicit about what data matters
- MagicMock handles any attribute access automatically

**Pattern: Create fixtures for common test data in conftest.py, then customize per-test:**

```python
# conftest.py
@pytest.fixture
def mock_event():
    event = MagicMock()
    event.context = {"patient": {"id": "test-patient"}}
    return event

# test_handler.py
def test_high_bp(mock_event):
    mock_event.target.instance.blood_pressure_systolic = 180  # Customize for this test
    # ...
    calls = [...]
    assert mock_event.mock_calls = calls
```

---

## Test Setup

### Directory Structure - MUST Mirror Source Code

**The test directory structure MUST exactly mirror the source code structure.**

```
plugin_name/
├── plugin_name/
│   ├── handlers/
│   │   ├── vitals_handler.py
│   │   └── lab_handler.py
│   ├── api/
│   │   └── routes.py
│   ├── helpers/
│   │   └── utils.py
│   └── CANVAS_MANIFEST.json
├── tests/
│   ├── __init__.py
│   ├── conftest.py                   # Shared fixtures only
│   ├── handlers/
│   │   ├── __init__.py
│   │   ├── test_vitals_handler.py    # tests vitals_handler.py
│   │   └── test_lab_handler.py       # tests lab_handler.py
│   ├── api/
│   │   ├── __init__.py
│   │   └── test_routes.py            # tests routes.py
│   └── helpers/
│       ├── __init__.py
│       └── test_utils.py             # tests utils.py
├── pyproject.toml
└── README.md
```

**Rules:**
- One test file per source file
- Test file name = `test_` + source file name
- Test file location mirrors source file location
- `conftest.py` at tests root contains only shared fixtures

### pyproject.toml Test Configuration

```toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v"

[tool.coverage.run]
source = ["plugin_name"]
omit = ["tests/*", "*/tests/*", "*/__pycache__/*"]

[tool.coverage.report]
omit = ["tests/*", "*/tests/*"]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
]
```

**CRITICAL:** The `omit` patterns in both `[tool.coverage.run]` and `[tool.coverage.report]` ensure test files are excluded from coverage measurement. Without this, test code inflates coverage numbers.

---

## Mocking Patterns

### Mocking Event Context

```python
import pytest
from unittest.mock import MagicMock, patch

@pytest.fixture
def mock_event():
    """Create a mock event with patient context."""
    event = MagicMock()
    event.context = {
        "patient": {"id": "patient-123"},
        "user": {"id": "staff-456", "type": "Staff"}
    }
    return event

@pytest.fixture
def mock_vitals_event(mock_event):
    """Create a mock vitals event."""
    mock_event.target.instance.blood_pressure_systolic = 150
    mock_event.target.instance.blood_pressure_diastolic = 95
    mock_event.target.instance.heart_rate = 72
    return mock_event
```

---

### Mocking Data Models

```python
@pytest.fixture
def mock_patient():
    """Create a mock patient."""
    patient = MagicMock()
    patient.id = "patient-123"
    patient.first_name = "John"
    patient.last_name = "Doe"
    patient.date_of_birth = "1980-01-15"
    return patient

def test_handler_with_patient(mock_event, mock_patient):
    with patch("canvas_sdk.v1.data.patient.Patient.objects") as mock_objects:
        mock_objects.get.return_value = mock_patient

        # Test handler logic
        handler = MyHandler()
        handler.event = mock_event
        effects = handler.compute()

        # REQUIRED: Always verify mock calls themselves
        calls = [call.get(id="patient-123"), call.get(id="patient-456")]
        mock_objects.mock_calls == calls
```

---

### CRITICAL: Always Verify Mock Calls

**EVERY SINGLE MOCK OBJECT IN EVERY TEST MUST HAVE ITS CALLS VERIFIED.**

This is a hard requirement with no exceptions. A mock without verification is incomplete testing.

#### Verification Checklist

For EVERY test, after executing the code under test, verify ALL mocks in this order:

1. **Patched dependencies** (e.g., `@patch` decorators) - verify these first
2. **Mock objects passed to the code** (e.g., `mock_event`, `mock_patient`)
3. **Mock objects created in the test** (e.g., `mock_credentials = MagicMock()`)
4. **Fixtures** (e.g., `mock_secrets`)

**If you create or use a mock, you MUST verify it. No exceptions.**

#### Complete Example Showing ALL Mock Verifications

```python
# BAD - Only checks output, doesn't verify ANY of the 3 mocks!
def test_bad_example(mock_event, mock_patient):
    with patch("canvas_sdk.v1.data.patient.Patient.objects") as mock_objects:
        mock_objects.get.return_value = mock_patient

        handler = MyHandler()
        handler.event = mock_event
        effects = handler.compute()

        assert len(effects) == 1  # ❌ Only checks output


# GOOD - Verifies ALL THREE mocks
def test_good_example(mock_event, mock_patient):
    with patch("canvas_sdk.v1.data.patient.Patient.objects") as mock_objects:
        mock_objects.get.return_value = mock_patient

        handler = MyHandler()
        handler.event = mock_event
        effects = handler.compute()

        # ✅ 1. Verify patched mock_objects
        calls = [call.get(id="patient-123")]
        assert mock_objects.mock_calls == calls

        # ✅ 2. Verify mock_event
        calls = [call.target.id]
        assert mock_event.mock_calls == calls

        # ✅ 3. Verify mock_patient
        calls = [call.__bool__()]  # if patient: check
        assert mock_patient.mock_calls == calls

        # Then check output
        assert len(effects) == 1
```

#### Verification Requirements

**ALWAYS** check the `mock_calls` property:
```python
assert mock_object.mock_calls == [call.method1(), call.method2()]
```

**NEVER USE** these assertion helper methods (they are FORBIDDEN):
- ❌ `mock.assert_called()`
- ❌ `mock.assert_called_once()`
- ❌ `mock.assert_called_with(...)`
- ❌ `mock.assert_called_once_with(...)`
- ❌ `mock.assert_not_called()`
- ❌ `mock.assert_any_call(...)`
- ❌ `mock.assert_has_calls(...)`
- ❌ `mock.call_count`
- ❌ `mock.call_args`
- ❌ `mock.call_args_list`

**Using `mock.called` is USELESS** - it only tells you if the mock was called at all, not HOW it was called.

#### Understanding Mock Calls

**Key patterns to understand:**

1. **Conditional checks generate `__bool__()` calls:**
```python
if patient:  # This generates call.__bool__()
    name = patient.first_name
```

2. **`None` doesn't generate calls:**
```python
patient = None
if patient:  # No mock calls, patient is None
    pass
```

3. **Nested attribute access:**
```python
note = Note.objects.get(id=123)  # Generates call.objects.get(id=123)
if note.patient:  # Generates call.objects.get().patient.__bool__()
    pass
```

4. **Setup calls can appear in mock_calls:**
```python
# Setting return_value can create calls
mock_datetime.now().date.return_value = date(2025, 1, 1)
# This creates call.now() in mock_datetime.mock_calls
```

5. **Method chaining:**
```python
result = mock.method1().method2()
# Generates: [call.method1(), call.method1().method2()]
```

#### Empty Mock Calls Are Valid

If a mock is created but never used, verify it has NO calls:

```python
mock_event = MagicMock()
tested = MyClass(event=mock_event)
result = tested.helper_method()

# Verify event was only used for initialization, not accessed during helper_method
assert mock_event.mock_calls == []
```

#### Examples of Complete Test Functions

```python
def test_example_with_all_verifications():
    """Every mock is verified."""
    # Create mocks
    mock_event = MagicMock()
    mock_event.target.id = "encounter-123"

    mock_credentials = MagicMock()
    mock_credentials.key = "test-key"

    # Patch and execute
    with patch("module.SomeClass") as mock_class:
        mock_instance = MagicMock()
        mock_class.return_value = mock_instance

        result = function_under_test(mock_event, mock_credentials)

        # ✅ Verify ALL mocks

        # 1. Patched mock_class
        calls = [call(param="value"), call().method()]
        assert mock_class.mock_calls == calls

        # 2. mock_event
        calls = [call.target.id]
        assert mock_event.mock_calls == calls

        # 3. mock_credentials
        calls = [call.key.__eq__("test-key")]  # if credentials.key == "test-key"
        assert mock_credentials.mock_calls == calls

        # Then verify output
        assert result == expected_value
```

#### When Tests Have Multiple Mocks

**List ALL mocks at the start of verification:**

```python
def test_complex_scenario(mock_event, mock_patient, mock_secrets):
    with patch("module.Database") as mock_db:
        with patch("module.Logger") as mock_logger:
            # Execute test
            result = handler.process(mock_event, mock_patient)

            # VERIFY ALL 5 MOCKS:
            # 1. mock_db
            assert mock_db.mock_calls == [...]

            # 2. mock_logger
            assert mock_logger.mock_calls == [...]

            # 3. mock_event
            assert mock_event.mock_calls == [...]

            # 4. mock_patient
            assert mock_patient.mock_calls == [...]

            # 5. mock_secrets
            assert mock_secrets.mock_calls == [...]

            # Then check result
            assert result == expected
```

#### Debugging Mock Calls

If you're unsure what calls a mock receives, print them:

```python
result = function_under_test(mock_event)
print(f"mock_event.mock_calls: {mock_event.mock_calls}")
# Then copy the output into your test
```

**Remember: Humans can follow strict comparisons with `mock.mock_calls`. There is no reason to skip this.**

---

### Mocking Secrets

```python
@pytest.fixture
def mock_secrets():
    """Create mock secrets."""
    return {
        "API_KEY": "test-api-key-12345",
        "WEBHOOK_SECRET": "test-webhook-secret"
    }

def test_webhook_auth(mock_secrets):
    handler = WebhookAPI()
    handler.secrets = mock_secrets

    # Test authentication
    credentials = MagicMock()
    credentials.key = "test-api-key-12345"

    assert handler.authenticate(credentials) is True
    calls = [...]
    assert mock_secrets == calls
    calls = [...]
    assert credentials == calls
```

---

## Testing Protocol Handlers

### Basic Event Handler Test

```python
from plugin_name.protocols.handler import AlertProtocol

class TestAlertProtocol:
    def test_high_bp_creates_alert(self, mock_vitals_event):
        """Test that high blood pressure creates a banner alert."""
        handler = AlertProtocol()
        handler.event = mock_vitals_event

        effects = handler.compute()

        # ✅ Verify mock_vitals_event
        calls = [
            call.target.instance.blood_pressure_systolic,
            call.target.instance.blood_pressure_diastolic,
            call.context.__getitem__('patient'),
            call.context.__getitem__().__getitem__('id'),
        ]
        assert mock_vitals_event.mock_calls == calls

        # Then verify output
        assert len(effects) == 1
        effect = effects[0]
        assert "AddBannerAlert" in str(type(effect))

    def test_normal_bp_no_alert(self, mock_event):
        """Test that normal blood pressure creates no alert."""
        mock_event.target.instance.blood_pressure_systolic = 120
        mock_event.target.instance.blood_pressure_diastolic = 80

        handler = AlertProtocol()
        handler.event = mock_event

        effects = handler.compute()

        # ✅ Verify mock_event
        calls = [
            call.target.instance.blood_pressure_systolic,
            call.target.instance.blood_pressure_diastolic,
        ]
        assert mock_event.mock_calls == calls

        # Then verify output
        assert len(effects) == 0

    def test_missing_bp_no_alert(self, mock_event):
        """Test graceful handling of missing vitals data."""
        mock_event.target.instance.blood_pressure_systolic = None
        mock_event.target.instance.blood_pressure_diastolic = None

        handler = AlertProtocol()
        handler.event = mock_event

        effects = handler.compute()

        # ✅ Verify mock_event
        calls = [
            call.target.instance.blood_pressure_systolic,
            # No access to diastolic if systolic is None
        ]
        assert mock_event.mock_calls == calls

        # Then verify output
        assert len(effects) == 0

    def test_missing_patient_id(self, mock_vitals_event):
        """Test graceful handling of missing patient context."""
        mock_vitals_event.context = {}

        handler = AlertProtocol()
        handler.event = mock_vitals_event

        effects = handler.compute()

        # ✅ Verify mock_vitals_event
        calls = [
            call.target.instance.blood_pressure_systolic,
            call.target.instance.blood_pressure_diastolic,
            call.context.__getitem__('patient'),  # Raises KeyError
        ]
        assert mock_vitals_event.mock_calls == calls

        # Then verify output
        assert len(effects) == 0
```

---

## Testing API Handlers

### Authentication Tests

```python
from plugin_name.api.routes import MyAPI

class TestMyAPIAuthentication:
    def test_valid_api_key_authenticates(self, mock_secrets):
        """Test that valid API key passes authentication."""
        handler = MyAPI()
        handler.secrets = mock_secrets

        credentials = MagicMock()
        credentials.key = "test-api-key-12345"

        assert handler.authenticate(credentials) is True
        calls = [...]
        assert mock_secrets.mock_calls == calls
        calls = [...]
        assert credentials.mock_calls == calls

    def test_invalid_api_key_rejected(self, mock_secrets):
        """Test that invalid API key fails authentication."""
        handler = MyAPI()
        handler.secrets = mock_secrets

        credentials = MagicMock()
        credentials.key = "wrong-key"

        assert handler.authenticate(credentials) is False
        calls = [...]
        assert mock_secrets.mock_calls == calls
        calls = [...]
        assert credentials.mock_calls == calls

    def test_missing_api_key_rejected(self, mock_secrets):
        """Test that missing API key fails authentication."""
        handler = MyAPI()
        handler.secrets = mock_secrets

        credentials = MagicMock()
        credentials.key = None

        assert handler.authenticate(credentials) is False
        calls = [...]
        assert mock_secrets.mock_calls == calls
        calls = [...]
        assert credentials.mock_calls == calls

    def test_missing_secret_rejected(self):
        """Test that unconfigured secret fails authentication."""
        handler = MyAPI()
        handler.secrets = {}

        credentials = MagicMock()
        credentials.key = "any-key"

        assert handler.authenticate(credentials) is False
        calls = [...]
        assert mock_secrets.mock_calls == calls
        calls = [...]
        assert credentials.mock_calls == calls
```

### Session Credential Tests

```python
class TestSessionAuthentication:
    def test_staff_user_authenticated(self):
        """Test that staff users pass authentication."""
        handler = StaffOnlyAPI()

        credentials = MagicMock()
        credentials.logged_in_user = {"id": "staff-123", "type": "Staff"}

        assert handler.authenticate(credentials) is True
        calls = [...]
        assert credentials.mock_calls == calls

    def test_patient_user_rejected_for_staff_endpoint(self):
        """Test that patient users are rejected from staff-only endpoints."""
        handler = StaffOnlyAPI()

        credentials = MagicMock()
        credentials.logged_in_user = {"id": "patient-123", "type": "Patient"}

        assert handler.authenticate(credentials) is False
        calls = [...]
        assert credentials.mock_calls == calls

    def test_patient_accessing_own_data(self):
        """Test that patients can access their own data."""
        request = MagicMock(path_params={"patient_id": "patient-123"})
        handler = PatientDataAPI()
        handler.request = request

        credentials = MagicMock()
        credentials.logged_in_user = {"id": "patient-123", "type": "Patient"}

        assert handler.authenticate(credentials) is True
        calls = [...]
        assert credentials.mock_calls == calls
        calls = [...]
        assert request.mock_calls == calls

    def test_patient_accessing_other_patient_data_rejected(self):
        """Test that patients cannot access other patients' data."""
        request = MagicMock(path_params={"patient_id": "patient-456"})
        handler = PatientDataAPI()
        handler.request = request

        credentials = MagicMock()
        credentials.logged_in_user = {"id": "patient-123", "type": "Patient"}

        assert handler.authenticate(credentials) is False
        calls = [...]
        assert credentials.mock_calls == calls
```

---

## Coverage Checking

### Running Coverage

```bash
# Basic coverage report
uv run pytest --cov=plugin_name --cov-report=term-missing --cov-branch

# With HTML report
uv run pytest --cov=plugin_name --cov-report=html

# With specific coverage threshold
uv run pytest --cov=plugin_name --cov-fail-under=90  --cov-branch
```

### Interpreting Coverage Report

```
Name                              Stmts   Miss  Cover   Missing
---------------------------------------------------------------
plugin_name/__init__.py               0      0   100%
plugin_name/handlers/handler.py     45      5    89%   34-38
plugin_name/api/routes.py            32      2    94%   67, 89
---------------------------------------------------------------
TOTAL                                77      7    91%
```

- **Stmts**: Total statements in file
- **Miss**: Statements not covered by tests
- **Cover**: Percentage of statements covered
- **Missing**: Line numbers not covered

---

## Coverage Improvement Strategies

### Identify Uncovered Code

1. Run coverage with `--cov-report=term-missing --cov-branch`
2. Look at "Missing" column for uncovered lines
3. Prioritize error handling paths and edge cases

### Common Uncovered Areas

1. **Error handling branches**
   - Test with invalid input
   - Test with missing data
   - Test exception scenarios

2. **Conditional logic**
   - Test both true and false branches
   - Test boundary conditions

3. **Edge cases**
   - Empty lists/dicts
   - None values
   - Maximum/minimum values

### Writing Tests for Uncovered Lines

```python
# If line 34-38 are uncovered and handle missing patient:
def test_missing_patient_context(self, mock_event):
    """Cover lines 34-38: missing patient handling."""
    mock_event.context = {}  # No patient in context

    handler = MyHandler()
    handler.event = mock_event
    effects = handler.compute()

    assert len(effects) == 0
    calls = [...]
    assert mock_event.mock_calls == calls
```

---

## Test Organization Best Practices

### Group Tests by Functionality

```python
class TestAlertTriggers:
    """Tests for alert triggering conditions."""

    def test_high_systolic_triggers_alert(self): ...
    def test_high_diastolic_triggers_alert(self): ...
    def test_both_high_triggers_alert(self): ...

class TestAlertContent:
    """Tests for alert content and formatting."""

    def test_alert_contains_bp_values(self): ...
    def test_alert_has_correct_intent(self): ...

class TestEdgeCases:
    """Tests for edge cases and error handling."""

    def test_missing_vitals(self): ...
    def test_missing_patient(self): ...
    def test_invalid_bp_values(self): ...
```

### Use Descriptive Test Names

```python
# Good: describes scenario and expected outcome
def test_blood_pressure_above_180_creates_hypertensive_crisis_alert(self): ...

# Bad: vague
def test_alert(self): ...
```

### Use Fixtures for Common Setup

```python
# conftest.py
@pytest.fixture
def high_bp_event():
    """Event with hypertensive crisis blood pressure."""
    event = MagicMock()
    event.context = {"patient": {"id": "test-patient"}}
    event.target.instance.blood_pressure_systolic = 190
    event.target.instance.blood_pressure_diastolic = 125
    return event

@pytest.fixture
def normal_bp_event():
    """Event with normal blood pressure."""
    event = MagicMock()
    event.context = {"patient": {"id": "test-patient"}}
    event.target.instance.blood_pressure_systolic = 120
    event.target.instance.blood_pressure_diastolic = 80
    return event
```

---

## Coverage Report Template

When reporting coverage results:

```markdown
## Test Coverage Report: {plugin_name}

**Overall Coverage:** {X}%
**Target:** 90%
**Status:** {PASS / NEEDS IMPROVEMENT}

### File Coverage

| File | Coverage | Missing Lines |
|------|----------|---------------|
| handlers/handler.py | 89% | 34-38 |
| api/routes.py | 94% | 67, 89 |

### Recommended Tests to Add

1. **handler.py:34-38** - Missing patient context handling
   - Add test for empty context dict

2. **routes.py:67** - Error response branch
   - Add test for invalid request body

3. **routes.py:89** - Exception handling
   - Add test that triggers exception
```
