# Testing Reference

## Test Layout

```
tests/
├── conftest.py           # Session-scoped autouse fixtures: mock permissions, mock dates
├── unit/
│   ├── conftest.py       # Flask test client; mock Snowflake; mock Redis
│   ├── test_handlers.py  # One TestClass per endpoint group
│   └── logic/
│       └── test_<domain>.py
└── integration/
    ├── conftest.py       # assert_endpoint() helper; pre-defined header sets; QA base URL
    └── test_api_endpoints.py
```

## `tests/conftest.py` — Session-Scoped Autouse Fixtures

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


@pytest.fixture(scope="session", autouse=True)
def mock_has_access():
    """Bypass permission checks in all unit tests."""
    with patch("<svc>.validation.access.has_access") as m:
        m.return_value = True
        yield m


@pytest.fixture(scope="session", autouse=True)
def mock_get_max_date():
    """Prevents real Snowflake calls for date-range helpers."""
    with patch("<svc>.logic.<domain>.get_max_available_date") as m:
        m.return_value = "2024-01-01"
        yield m
```

## `tests/unit/conftest.py` — Unit Test Client & DB Mocking

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

from <svc>.api import app


@pytest.fixture(scope="session", autouse=True)
def mock_snowflake_execute():
    """Patch sqlalchemy Session.execute so no real Snowflake connections are opened."""
    with patch("sqlalchemy.orm.session.Session.execute") as m:
        yield m


@pytest.fixture(scope="session", autouse=True)
def mock_redis_get():
    """Prevent real Redis calls; all cache misses by default."""
    with patch("<svc>.connectors.redis.client.get", return_value=None):
        with patch("<svc>.connectors.redis.client.setex"):
            yield


@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as c:
        yield c


@pytest.fixture
def client_with_context():
    """Use when the handler or logic uses Flask's `g` or `url_for`."""
    app.config["TESTING"] = True
    ctx = app.test_request_context()
    ctx.push()
    with app.test_client() as c:
        yield c
    ctx.pop()
```

## `tests/unit/test_handlers.py` — Handler Unit Tests

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

from <svc> import handlers  # noqa — import registers routes


VALID_HEADERS = {
    "Orchard-Profile-Id": "12345",
    "Orchard-Profile-Type": "InsightsProfile",
}


class TestHealthCheck:
    def test_returns_ok(self, client):
        response = client.get("/hello/")
        assert response.status_code == 200
        assert response.json == {"status": "ok"}


class TestGetMyResource:
    def test_success(self, client):
        expected = {"id": "abc-123", "name": "Test Resource", "createdAt": "2024-01-01T00:00:00Z"}
        with patch.object(handlers.<domain>, "get_resource", return_value=expected):
            response = client.get("/my-resource/abc-123", headers=VALID_HEADERS)
        assert response.status_code == 200
        assert response.json["data"] == expected

    def test_not_found_returns_404(self, client):
        with patch.object(handlers.<domain>, "get_resource", return_value=None):
            response = client.get("/my-resource/nonexistent", headers=VALID_HEADERS)
        assert response.status_code == 404

    def test_logic_exception_returns_500(self, client):
        with patch.object(handlers.<domain>, "get_resource", side_effect=Exception("boom")):
            response = client.get("/my-resource/abc-123", headers=VALID_HEADERS)
        assert response.status_code == 500
        assert "message" in response.json


class TestCreateMyResource:
    def test_success(self, client):
        payload = {"name": "New Resource"}
        expected = {"id": "new-123", "name": "New Resource"}
        with patch.object(handlers.<domain>, "create_resource", return_value=expected):
            response = client.post("/my-resource/", json=payload, headers=VALID_HEADERS)
        assert response.status_code == 200

    def test_missing_required_field_returns_500(self, client):
        # Marshmallow validation errors map to 500 (Orchard standard)
        response = client.post("/my-resource/", json={}, headers=VALID_HEADERS)
        assert response.status_code == 500
```

## `tests/unit/logic/test_<domain>.py` — Logic Unit Tests

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

from <svc>.logic import <domain>
from <svc>.connectors import snowflake


MOCK_RAW_RESULT = [("abc-123", "Test Resource", "2024-01-01")]
COLUMN_NAMES = ["id", "name", "created_at"]


class TestGetResource:
    def test_returns_mapped_dict(self):
        with patch.object(snowflake, "fetchall", return_value=MOCK_RAW_RESULT):
            result = <domain>.get_resource("abc-123")
        assert result == {"id": "abc-123", "name": "Test Resource", "created_at": "2024-01-01"}

    def test_returns_none_when_not_found(self):
        with patch.object(snowflake, "fetchall", return_value=[]):
            result = <domain>.get_resource("nonexistent")
        assert result is None

    @pytest.mark.parametrize("resource_id,expected_count", [
        ("abc-123", 1),
        ("def-456", 1),
    ])
    def test_parametrized(self, resource_id, expected_count):
        with patch.object(snowflake, "fetchall", return_value=MOCK_RAW_RESULT):
            result = <domain>.get_resource(resource_id)
        assert result is not None
```

## `tests/integration/conftest.py` — Integration Test Helpers

```python
import pytest
import requests

QA_BASE_URL = "https://ows-<domain>.qa.theorchard.io"

# Pre-defined identity header sets for different user types
INSIGHTS_EMPLOYEE = {
    "Orchard-Profile-Id": "12345",
    "Orchard-Profile-Type": "InsightsProfile",
}
INSIGHTS_LABEL = {
    "Orchard-Profile-Id": "67890",
    "Orchard-Profile-Type": "LabelProfile",
}
ALL_HEADERS = [INSIGHTS_EMPLOYEE, INSIGHTS_LABEL]


_cache: dict = {}


def request_get_cache(url: str, headers: dict) -> requests.Response:
    """Cached GET to avoid hammering QA on parametrized runs."""
    key = (url, frozenset(headers.items()))
    if key not in _cache:
        _cache[key] = requests.get(url, headers=headers)
    return _cache[key]


def assert_endpoint(
    path: str,
    *,
    headers: dict,
    method: str = "GET",
    json_body: dict | None = None,
    expected_status: int = 200,
    items_key: str | None = None,
):
    url = f"{QA_BASE_URL}{path}"
    if method == "GET":
        response = request_get_cache(url, headers)
    else:
        response = requests.request(method, url, json=json_body, headers=headers)

    assert response.status_code == expected_status, (
        f"{method} {path} returned {response.status_code}, expected {expected_status}. "
        f"Body: {response.text[:500]}"
    )

    if items_key and expected_status == 200:
        data = response.json()
        assert items_key in data, f"Key '{items_key}' missing from response"
        assert isinstance(data[items_key], list)

    return response
```

## Test Runner Configuration (`setup.cfg` / `pyproject.toml`)

```ini
# setup.cfg
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    --cov=<svc>
    --cov-report=xml:coverage.xml
    --cov-report=term-missing
    --junitxml=pyunit.xml
    -v
```

## Makefile Test Targets

```makefile
.PHONY: test_unit test_integration unit_lint_job integration_job

test_unit:
	ENVIRONMENT=test pytest tests/unit

test_integration_qa:
	pytest tests/integration

lint:
	flake8 <svc>/ tests/
	isort --check-only <svc>/ tests/
	black --check <svc>/ tests/

mypy:
	mypy <svc>/

unit_lint_job: lint test_unit mypy   # matches Jenkins "Unit Tests and Style Checks" stage

integration_job: test_integration_qa  # matches Jenkins "Integration Tests" stage
```

## Coverage Expectations

| Layer | Minimum coverage |
|---|---|
| `logic/` modules | 90% |
| `handlers.py` | 85% (every endpoint: success + 500 path) |
| `schemas/` | 70% (normalize/dump paths) |
| `connectors/` | 60% (mocked in unit tests) |

## Test Rules

1. **Unit tests must never open a real Snowflake connection.** The `mock_snowflake_execute` fixture in `tests/unit/conftest.py` patches `Session.execute` at the session scope — this prevents connections on import.
2. **Unit tests must never open a real Redis connection.** Module-level `fakeredis` in the connector handles dev/test; unit tests additionally patch `client.get` and `client.setex` to control cache behavior.
3. **Mock the logic layer in handler tests, not the DB layer.** `patch.object(handlers.<domain>, "get_resource", ...)` is the correct granularity for handler tests. DB mocking belongs in logic tests.
4. **Integration tests run against the QA environment.** They require real credentials (provided via Jenkins `withCredentials`). Never run integration tests in the unit stage.
5. **Test every endpoint for both the success case and the 500 case.** The 500 case verifies the global error handler returns `{"message": "..."}` JSON.
6. **Marshmallow validation errors must map to 500.** Add a test that sends a request missing a required field and asserts HTTP 500 (not 400/422).
