Files
crm-system/tests/test_live_endpoints.py
T

171 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]}"