171 lines
12 KiB
Python
171 lines
12 KiB
Python
"""Live endpoint smoke tests: every registered API endpoint returns the expected status.
|
||
|
||
Uses httpx.AsyncClient (via ASGITransport) – no real HTTP connection needed.
|
||
|
||
Coverage target: 100% of non-docs, non-static endpoints.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# List of (method, path, payload, auth_required, expected_without_auth, expected_with_auth)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Rules:
|
||
# - Endpoints marked JWT → 401 without token, 200/201/204 with valid token
|
||
# - Public endpoints → 200/201/422 (validation error payload still returns expected)
|
||
# - We only test that the endpoint *exists* and returns one of the expected codes
|
||
#
|
||
# When expected_without_auth is None, the test only sends the request *with* auth.
|
||
|
||
ENDPOINTS: list[dict[str, Any]] = [
|
||
# --- health (public) ---
|
||
{"method": "GET", "path": "/health", "auth_required": False, "expected": [200]},
|
||
{"method": "GET", "path": "/metrics", "auth_required": False, "expected": [200, 404]},
|
||
# --- auth (public) ---
|
||
{"method": "POST", "path": "/api/v1/auth/register", "json": {"email": "e2e@t.com", "password": "Test1234!", "name": "E2E"}, "auth_required": False, "expected": [201, 403]},
|
||
{"method": "POST", "path": "/api/v1/auth/login", "data": {"username": "x@y.com", "password": "wrong"}, "auth_required": False, "expected": [401]},
|
||
{"method": "POST", "path": "/api/v1/auth/refresh", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "POST", "path": "/api/v1/auth/logout", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "POST", "path": "/api/v1/auth/password-reset/request", "json": {"email": "x@y.com"}, "auth_required": False, "expected": [200, 404, 405]},
|
||
# --- users (JWT) ---
|
||
{"method": "GET", "path": "/api/v1/users/me", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "PATCH", "path": "/api/v1/users/me", "json": {"name": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/users/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "POST", "path": "/api/v1/users/", "json": {"email": "u2@t.com", "password": "Test1234!", "name": "U2", "role": "sales_rep"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201]},
|
||
{"method": "PATCH", "path": "/api/v1/users/999999", "json": {"name": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/users/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- accounts (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/accounts/", "json": {"name": "A"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201]},
|
||
{"method": "GET", "path": "/api/v1/accounts/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/accounts/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/accounts/999999", "json": {"name": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/accounts/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- contacts (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/contacts/", "json": {"first_name": "F", "last_name": "L"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201]},
|
||
{"method": "GET", "path": "/api/v1/contacts/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/contacts/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/contacts/999999", "json": {"first_name": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/contacts/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- deals (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/deals/", "json": {"title": "D", "account_id": "999999"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201, 404]},
|
||
{"method": "GET", "path": "/api/v1/deals/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/deals/pipeline", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/deals/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/deals/999999", "json": {"title": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/deals/999999/stage", "json": {"stage": "qualified"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/deals/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- activities (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/activities/", "json": {"type": "task", "subject": "A", "account_id": 999999}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201, 422]},
|
||
{"method": "GET", "path": "/api/v1/activities/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/activities/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/activities/999999", "json": {"subject": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/activities/999999/complete", "json": {"outcome": "done"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/activities/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- notes (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/notes/", "json": {"body": "N", "parent_type": "account", "parent_id": "999999"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201, 404]},
|
||
{"method": "GET", "path": "/api/v1/notes/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/notes/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "PATCH", "path": "/api/v1/notes/999999", "json": {"body": "X"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/notes/999999", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- tags (JWT) ---
|
||
{"method": "POST", "path": "/api/v1/tags/", "json": {"name": "t1"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201]},
|
||
{"method": "GET", "path": "/api/v1/tags/", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "POST", "path": "/api/v1/tags/link", "json": {"tag_id": "999999", "parent_type": "account", "parent_id": "999999"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [201, 404]},
|
||
{"method": "DELETE", "path": "/api/v1/tags/link", "json": {"tag_id": "999999", "parent_type": "account", "parent_id": "999999"}, "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [204, 404]},
|
||
# --- dashboard (JWT) ---
|
||
{"method": "GET", "path": "/api/v1/dashboard/kpis", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
{"method": "GET", "path": "/api/v1/dashboard/feed", "auth_required": True, "expected_without_auth": 401, "expected_with_auth": [200]},
|
||
]
|
||
|
||
|
||
# Derive pytest test IDs from the method+path
|
||
_ENDPOINT_IDS = [f"{e['method']} {e['path']}" for e in ENDPOINTS]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests WITHOUT auth (public endpoints)
|
||
# ---------------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"endpoint",
|
||
[e for e in ENDPOINTS if not e["auth_required"]],
|
||
ids=[f"{e['method']} {e['path']}" for e in ENDPOINTS if not e["auth_required"]],
|
||
)
|
||
@pytest.mark.asyncio
|
||
async def test_public_endpoint(client: AsyncClient, endpoint: dict[str, Any]) -> None:
|
||
"""Every public endpoint returns a status in the expected list."""
|
||
method = endpoint["method"].upper()
|
||
path = endpoint["path"]
|
||
|
||
kwargs: dict[str, Any] = {}
|
||
if "json" in endpoint:
|
||
kwargs["json"] = endpoint["json"]
|
||
if "data" in endpoint:
|
||
kwargs["data"] = endpoint["data"]
|
||
|
||
resp = await client.request(method, path, **kwargs)
|
||
assert resp.status_code in endpoint["expected"], \
|
||
f"{method} {path} → {resp.status_code}, expected one of {endpoint['expected']}. Body: {resp.text[:300]}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests WITHOUT auth → expect 401 for JWT endpoints
|
||
# ---------------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"endpoint",
|
||
[e for e in ENDPOINTS if e["auth_required"]],
|
||
ids=[f"{e['method']} {e['path']}" for e in ENDPOINTS if e["auth_required"]],
|
||
)
|
||
@pytest.mark.asyncio
|
||
async def test_endpoint_without_auth_returns_401(client: AsyncClient, endpoint: dict[str, Any]) -> None:
|
||
"""JWT endpoints return 401 when no Authorization header is present."""
|
||
method = endpoint["method"].upper()
|
||
path = endpoint["path"]
|
||
|
||
kwargs: dict[str, Any] = {}
|
||
if "json" in endpoint:
|
||
kwargs["json"] = endpoint["json"]
|
||
if "data" in endpoint:
|
||
kwargs["data"] = endpoint["data"]
|
||
|
||
resp = await client.request(method, path, **kwargs)
|
||
# Some endpoints might return 422 if the payload is invalid and auth is also missing.
|
||
# We accept 401 (no auth) or 422 (validation before auth). In any case, it must NOT return 200/201/204.
|
||
assert resp.status_code in (401, 422), \
|
||
f"{method} {path} (no auth) → {resp.status_code}, expected 401 or 422. Body: {resp.text[:300]}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests WITH auth → expect 200/201/204 for JWT endpoints
|
||
# ---------------------------------------------------------------------------
|
||
@pytest.mark.parametrize(
|
||
"endpoint",
|
||
[e for e in ENDPOINTS if e["auth_required"]],
|
||
ids=[f"{e['method']} {e['path']}" for e in ENDPOINTS if e["auth_required"]],
|
||
)
|
||
@pytest.mark.asyncio
|
||
async def test_endpoint_with_auth_returns_success(
|
||
client: AsyncClient, registered_user: dict[str, Any], endpoint: dict[str, Any]
|
||
) -> None:
|
||
"""JWT endpoints return one of the expected success codes with valid auth."""
|
||
method = endpoint["method"].upper()
|
||
path = endpoint["path"]
|
||
headers = registered_user["headers"]
|
||
|
||
kwargs: dict[str, Any] = {"headers": headers}
|
||
if "json" in endpoint:
|
||
kwargs["json"] = endpoint["json"]
|
||
if "data" in endpoint:
|
||
kwargs["data"] = endpoint["data"]
|
||
|
||
resp = await client.request(method, path, **kwargs)
|
||
expected = endpoint.get("expected_with_auth", [200, 201, 204])
|
||
assert resp.status_code in expected, \
|
||
f"{method} {path} (with auth) → {resp.status_code}, expected one of {expected}. Body: {resp.text[:300]}"
|