core-framework 0.12.7__py3-none-any.whl → 0.12.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- core/__init__.py +1 -1
- core/cli/__init__.py +2 -0
- core/cli/main.py +163 -0
- core/testing/__init__.py +99 -0
- core/testing/assertions.py +347 -0
- core/testing/client.py +247 -0
- core/testing/database.py +307 -0
- core/testing/factories.py +393 -0
- core/testing/mocks.py +658 -0
- core/testing/plugin.py +635 -0
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/METADATA +6 -1
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/RECORD +14 -7
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/entry_points.txt +3 -0
- {core_framework-0.12.7.dist-info → core_framework-0.12.9.dist-info}/WHEEL +0 -0
core/__init__.py
CHANGED
core/cli/__init__.py
CHANGED
|
@@ -10,6 +10,8 @@ Comandos disponíveis:
|
|
|
10
10
|
- core run: Executa o servidor de desenvolvimento
|
|
11
11
|
- core shell: Abre shell interativo
|
|
12
12
|
- core routes: Lista rotas registradas
|
|
13
|
+
- core test: Executa testes com ambiente isolado
|
|
14
|
+
- core version: Mostra versão do framework
|
|
13
15
|
"""
|
|
14
16
|
|
|
15
17
|
from core.cli.main import cli, main
|
core/cli/main.py
CHANGED
|
@@ -13,7 +13,16 @@ Comandos:
|
|
|
13
13
|
run Executa servidor de desenvolvimento
|
|
14
14
|
shell Abre shell interativo async
|
|
15
15
|
routes Lista rotas registradas
|
|
16
|
+
test Executa testes com ambiente isolado
|
|
16
17
|
version Mostra versão do framework
|
|
18
|
+
|
|
19
|
+
Comando test:
|
|
20
|
+
core test # Roda todos os testes em tests/
|
|
21
|
+
core test tests/test_auth.py # Roda arquivo específico
|
|
22
|
+
core test -v # Saída verbosa
|
|
23
|
+
core test --cov # Com cobertura de código
|
|
24
|
+
core test -k "test_login" # Filtrar por keyword
|
|
25
|
+
core test -m unit # Apenas testes unitários
|
|
17
26
|
"""
|
|
18
27
|
|
|
19
28
|
from __future__ import annotations
|
|
@@ -472,6 +481,112 @@ def cmd_version(args: argparse.Namespace) -> int:
|
|
|
472
481
|
return 0
|
|
473
482
|
|
|
474
483
|
|
|
484
|
+
def cmd_test(args: argparse.Namespace) -> int:
|
|
485
|
+
"""
|
|
486
|
+
Run tests with auto-discovery and isolated environment.
|
|
487
|
+
|
|
488
|
+
This command:
|
|
489
|
+
- Automatically sets up an isolated test environment
|
|
490
|
+
- Initializes database with in-memory SQLite
|
|
491
|
+
- Configures auth, settings, and middleware
|
|
492
|
+
- Runs pytest with appropriate options
|
|
493
|
+
- Supports coverage reporting
|
|
494
|
+
|
|
495
|
+
Usage:
|
|
496
|
+
core test # Run all tests in tests/
|
|
497
|
+
core test tests/test_auth.py # Run specific file
|
|
498
|
+
core test -v # Verbose output
|
|
499
|
+
core test --cov # With coverage
|
|
500
|
+
core test -k "test_login" # Filter by keyword
|
|
501
|
+
core test -m unit # Only unit tests
|
|
502
|
+
"""
|
|
503
|
+
import subprocess
|
|
504
|
+
import shutil
|
|
505
|
+
|
|
506
|
+
# Check if pytest is installed
|
|
507
|
+
pytest_path = shutil.which("pytest")
|
|
508
|
+
if not pytest_path:
|
|
509
|
+
print(error("pytest not installed. Install with:"))
|
|
510
|
+
print(info(" pip install pytest pytest-asyncio"))
|
|
511
|
+
return 1
|
|
512
|
+
|
|
513
|
+
print(bold("🧪 Core Framework Test Runner"))
|
|
514
|
+
print()
|
|
515
|
+
|
|
516
|
+
# Build pytest command
|
|
517
|
+
cmd = ["python", "-m", "pytest"]
|
|
518
|
+
|
|
519
|
+
# Add test path
|
|
520
|
+
cmd.append(args.path)
|
|
521
|
+
|
|
522
|
+
# Verbose
|
|
523
|
+
if args.verbose:
|
|
524
|
+
cmd.append("-v")
|
|
525
|
+
|
|
526
|
+
# Keyword filter
|
|
527
|
+
if args.keyword:
|
|
528
|
+
cmd.extend(["-k", args.keyword])
|
|
529
|
+
|
|
530
|
+
# Exit on first failure
|
|
531
|
+
if args.exitfirst:
|
|
532
|
+
cmd.append("-x")
|
|
533
|
+
|
|
534
|
+
# Marker filter
|
|
535
|
+
if args.marker:
|
|
536
|
+
cmd.extend(["-m", args.marker])
|
|
537
|
+
|
|
538
|
+
# No header
|
|
539
|
+
if args.no_header:
|
|
540
|
+
cmd.append("--no-header")
|
|
541
|
+
|
|
542
|
+
# Coverage
|
|
543
|
+
if args.cov:
|
|
544
|
+
# Check if pytest-cov is installed
|
|
545
|
+
if not shutil.which("pytest-cov") and not _check_pytest_cov():
|
|
546
|
+
print(warning("pytest-cov not installed. Install with:"))
|
|
547
|
+
print(info(" pip install pytest-cov"))
|
|
548
|
+
print()
|
|
549
|
+
|
|
550
|
+
cmd.append(f"--cov={args.cov}")
|
|
551
|
+
cmd.append(f"--cov-report={args.cov_report}")
|
|
552
|
+
|
|
553
|
+
# Always use asyncio mode auto
|
|
554
|
+
cmd.extend(["--asyncio-mode=auto"])
|
|
555
|
+
|
|
556
|
+
# Show short traceback
|
|
557
|
+
cmd.append("--tb=short")
|
|
558
|
+
|
|
559
|
+
print(info(f"Running: {' '.join(cmd)}"))
|
|
560
|
+
print()
|
|
561
|
+
|
|
562
|
+
# Set environment variables for isolated testing
|
|
563
|
+
env = os.environ.copy()
|
|
564
|
+
env["TESTING"] = "true"
|
|
565
|
+
env["DEBUG"] = "true"
|
|
566
|
+
env.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
|
567
|
+
env.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
|
|
568
|
+
|
|
569
|
+
# Run pytest
|
|
570
|
+
try:
|
|
571
|
+
result = subprocess.run(cmd, env=env)
|
|
572
|
+
return result.returncode
|
|
573
|
+
except KeyboardInterrupt:
|
|
574
|
+
print(warning("\nTests interrupted"))
|
|
575
|
+
return 130
|
|
576
|
+
except Exception as e:
|
|
577
|
+
print(error(f"Error running tests: {e}"))
|
|
578
|
+
return 1
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _check_pytest_cov() -> bool:
|
|
582
|
+
"""Check if pytest-cov is installed as a module."""
|
|
583
|
+
try:
|
|
584
|
+
import pytest_cov
|
|
585
|
+
return True
|
|
586
|
+
except ImportError:
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
|
|
475
590
|
def check_uv_installed() -> bool:
|
|
476
591
|
"""Verifica se uv está instalado."""
|
|
477
592
|
import shutil
|
|
@@ -3747,6 +3862,54 @@ For more information, visit: https://github.com/SorPuti/core-framework
|
|
|
3747
3862
|
)
|
|
3748
3863
|
tasks_parser.set_defaults(func=cmd_tasks)
|
|
3749
3864
|
|
|
3865
|
+
# test
|
|
3866
|
+
test_parser = subparsers.add_parser(
|
|
3867
|
+
"test",
|
|
3868
|
+
help="Run tests with auto-discovery and isolated environment"
|
|
3869
|
+
)
|
|
3870
|
+
test_parser.add_argument(
|
|
3871
|
+
"path",
|
|
3872
|
+
nargs="?",
|
|
3873
|
+
default="tests",
|
|
3874
|
+
help="Test path or file (default: tests)"
|
|
3875
|
+
)
|
|
3876
|
+
test_parser.add_argument(
|
|
3877
|
+
"-v", "--verbose",
|
|
3878
|
+
action="store_true",
|
|
3879
|
+
help="Verbose output"
|
|
3880
|
+
)
|
|
3881
|
+
test_parser.add_argument(
|
|
3882
|
+
"-k", "--keyword",
|
|
3883
|
+
help="Only run tests matching keyword expression"
|
|
3884
|
+
)
|
|
3885
|
+
test_parser.add_argument(
|
|
3886
|
+
"-x", "--exitfirst",
|
|
3887
|
+
action="store_true",
|
|
3888
|
+
help="Exit on first failure"
|
|
3889
|
+
)
|
|
3890
|
+
test_parser.add_argument(
|
|
3891
|
+
"--cov",
|
|
3892
|
+
nargs="?",
|
|
3893
|
+
const=".",
|
|
3894
|
+
help="Enable coverage (optionally specify source)"
|
|
3895
|
+
)
|
|
3896
|
+
test_parser.add_argument(
|
|
3897
|
+
"--cov-report",
|
|
3898
|
+
choices=["term", "html", "xml", "json"],
|
|
3899
|
+
default="term",
|
|
3900
|
+
help="Coverage report format"
|
|
3901
|
+
)
|
|
3902
|
+
test_parser.add_argument(
|
|
3903
|
+
"-m", "--marker",
|
|
3904
|
+
help="Only run tests with this marker (e.g., 'unit', 'integration')"
|
|
3905
|
+
)
|
|
3906
|
+
test_parser.add_argument(
|
|
3907
|
+
"--no-header",
|
|
3908
|
+
action="store_true",
|
|
3909
|
+
help="Disable pytest header"
|
|
3910
|
+
)
|
|
3911
|
+
test_parser.set_defaults(func=cmd_test)
|
|
3912
|
+
|
|
3750
3913
|
return parser
|
|
3751
3914
|
|
|
3752
3915
|
|
core/testing/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing utilities for core-framework applications.
|
|
3
|
+
|
|
4
|
+
This module provides a complete testing toolkit for applications built
|
|
5
|
+
with core-framework, including:
|
|
6
|
+
|
|
7
|
+
- TestClient: HTTP client with automatic test database setup
|
|
8
|
+
- AuthenticatedClient: Pre-authenticated HTTP client
|
|
9
|
+
- TestDatabase: Database utilities for testing
|
|
10
|
+
- MockKafka, MockRedis, MockHTTP: Mock services
|
|
11
|
+
- Factory: Data factory pattern for test data generation
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
# In your conftest.py
|
|
15
|
+
import pytest
|
|
16
|
+
from your_app import app as _app
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="session")
|
|
19
|
+
def app():
|
|
20
|
+
return _app.app
|
|
21
|
+
|
|
22
|
+
# In your tests
|
|
23
|
+
class TestUsers:
|
|
24
|
+
async def test_register(self, client):
|
|
25
|
+
response = await client.post("/auth/register", json={...})
|
|
26
|
+
assert response.status_code == 201
|
|
27
|
+
|
|
28
|
+
async def test_profile(self, auth_client):
|
|
29
|
+
response = await auth_client.get("/auth/me")
|
|
30
|
+
assert response.status_code == 200
|
|
31
|
+
|
|
32
|
+
Quick Start:
|
|
33
|
+
1. Install test dependencies: pip install core-framework[testing]
|
|
34
|
+
2. Add to pyproject.toml:
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
37
|
+
plugins = ["core.testing.plugin"]
|
|
38
|
+
3. Create conftest.py with your app fixture
|
|
39
|
+
4. Write tests using provided fixtures
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from core.testing.client import (
|
|
43
|
+
TestClient,
|
|
44
|
+
AuthenticatedClient,
|
|
45
|
+
create_test_client,
|
|
46
|
+
create_auth_client,
|
|
47
|
+
)
|
|
48
|
+
from core.testing.database import (
|
|
49
|
+
TestDatabase,
|
|
50
|
+
setup_test_db,
|
|
51
|
+
teardown_test_db,
|
|
52
|
+
get_test_session,
|
|
53
|
+
)
|
|
54
|
+
from core.testing.mocks import (
|
|
55
|
+
MockKafka,
|
|
56
|
+
MockRedis,
|
|
57
|
+
MockHTTP,
|
|
58
|
+
MockMessage,
|
|
59
|
+
MockHTTPResponse,
|
|
60
|
+
)
|
|
61
|
+
from core.testing.factories import (
|
|
62
|
+
Factory,
|
|
63
|
+
UserFactory,
|
|
64
|
+
fake,
|
|
65
|
+
)
|
|
66
|
+
from core.testing.assertions import (
|
|
67
|
+
assert_status,
|
|
68
|
+
assert_json_contains,
|
|
69
|
+
assert_error_code,
|
|
70
|
+
assert_validation_error,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
# Client
|
|
75
|
+
"TestClient",
|
|
76
|
+
"AuthenticatedClient",
|
|
77
|
+
"create_test_client",
|
|
78
|
+
"create_auth_client",
|
|
79
|
+
# Database
|
|
80
|
+
"TestDatabase",
|
|
81
|
+
"setup_test_db",
|
|
82
|
+
"teardown_test_db",
|
|
83
|
+
"get_test_session",
|
|
84
|
+
# Mocks
|
|
85
|
+
"MockKafka",
|
|
86
|
+
"MockRedis",
|
|
87
|
+
"MockHTTP",
|
|
88
|
+
"MockMessage",
|
|
89
|
+
"MockHTTPResponse",
|
|
90
|
+
# Factories
|
|
91
|
+
"Factory",
|
|
92
|
+
"UserFactory",
|
|
93
|
+
"fake",
|
|
94
|
+
# Assertions
|
|
95
|
+
"assert_status",
|
|
96
|
+
"assert_json_contains",
|
|
97
|
+
"assert_error_code",
|
|
98
|
+
"assert_validation_error",
|
|
99
|
+
]
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom assertions for API testing.
|
|
3
|
+
|
|
4
|
+
Provides helper functions for common test assertions.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
response = await client.get("/users/1")
|
|
8
|
+
|
|
9
|
+
assert_status(response, 200)
|
|
10
|
+
assert_json_contains(response, {"email": "test@example.com"})
|
|
11
|
+
assert_error_code(response, "not_found")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from httpx import Response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assert_status(response: "Response", expected: int, msg: str = "") -> None:
|
|
23
|
+
"""
|
|
24
|
+
Assert response has expected status code.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
response: HTTP response
|
|
28
|
+
expected: Expected status code
|
|
29
|
+
msg: Optional message
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
AssertionError: If status doesn't match
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
assert_status(response, 200)
|
|
36
|
+
assert_status(response, 201, "User should be created")
|
|
37
|
+
"""
|
|
38
|
+
actual = response.status_code
|
|
39
|
+
if actual != expected:
|
|
40
|
+
body = _safe_json(response)
|
|
41
|
+
error_msg = f"Expected status {expected}, got {actual}. Response: {body}"
|
|
42
|
+
if msg:
|
|
43
|
+
error_msg = f"{msg}. {error_msg}"
|
|
44
|
+
raise AssertionError(error_msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def assert_status_ok(response: "Response", msg: str = "") -> None:
|
|
48
|
+
"""Assert response is 2xx."""
|
|
49
|
+
if not 200 <= response.status_code < 300:
|
|
50
|
+
body = _safe_json(response)
|
|
51
|
+
error_msg = f"Expected 2xx status, got {response.status_code}. Response: {body}"
|
|
52
|
+
if msg:
|
|
53
|
+
error_msg = f"{msg}. {error_msg}"
|
|
54
|
+
raise AssertionError(error_msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def assert_status_error(response: "Response", msg: str = "") -> None:
|
|
58
|
+
"""Assert response is 4xx or 5xx."""
|
|
59
|
+
if response.status_code < 400:
|
|
60
|
+
body = _safe_json(response)
|
|
61
|
+
error_msg = f"Expected error status, got {response.status_code}. Response: {body}"
|
|
62
|
+
if msg:
|
|
63
|
+
error_msg = f"{msg}. {error_msg}"
|
|
64
|
+
raise AssertionError(error_msg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def assert_json_contains(
|
|
68
|
+
response: "Response",
|
|
69
|
+
expected: dict[str, Any],
|
|
70
|
+
msg: str = "",
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Assert response JSON contains expected fields.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
response: HTTP response
|
|
77
|
+
expected: Dict of expected fields and values
|
|
78
|
+
msg: Optional message
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
AssertionError: If fields don't match
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
assert_json_contains(response, {"email": "test@example.com"})
|
|
85
|
+
assert_json_contains(response, {"status": "active", "role": "admin"})
|
|
86
|
+
"""
|
|
87
|
+
actual = _safe_json(response)
|
|
88
|
+
|
|
89
|
+
if not isinstance(actual, dict):
|
|
90
|
+
raise AssertionError(
|
|
91
|
+
f"Expected JSON object, got {type(actual).__name__}: {actual}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for key, value in expected.items():
|
|
95
|
+
if key not in actual:
|
|
96
|
+
error_msg = f"Missing key '{key}' in response. Response: {actual}"
|
|
97
|
+
if msg:
|
|
98
|
+
error_msg = f"{msg}. {error_msg}"
|
|
99
|
+
raise AssertionError(error_msg)
|
|
100
|
+
|
|
101
|
+
if actual[key] != value:
|
|
102
|
+
error_msg = (
|
|
103
|
+
f"Value mismatch for '{key}': expected {value!r}, "
|
|
104
|
+
f"got {actual[key]!r}. Response: {actual}"
|
|
105
|
+
)
|
|
106
|
+
if msg:
|
|
107
|
+
error_msg = f"{msg}. {error_msg}"
|
|
108
|
+
raise AssertionError(error_msg)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def assert_json_equals(
|
|
112
|
+
response: "Response",
|
|
113
|
+
expected: dict[str, Any] | list[Any],
|
|
114
|
+
msg: str = "",
|
|
115
|
+
) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Assert response JSON equals expected exactly.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
response: HTTP response
|
|
121
|
+
expected: Expected JSON value
|
|
122
|
+
msg: Optional message
|
|
123
|
+
"""
|
|
124
|
+
actual = _safe_json(response)
|
|
125
|
+
|
|
126
|
+
if actual != expected:
|
|
127
|
+
error_msg = f"JSON mismatch. Expected: {expected}. Got: {actual}"
|
|
128
|
+
if msg:
|
|
129
|
+
error_msg = f"{msg}. {error_msg}"
|
|
130
|
+
raise AssertionError(error_msg)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def assert_json_list(
|
|
134
|
+
response: "Response",
|
|
135
|
+
min_length: int = 0,
|
|
136
|
+
max_length: int | None = None,
|
|
137
|
+
msg: str = "",
|
|
138
|
+
) -> list[Any]:
|
|
139
|
+
"""
|
|
140
|
+
Assert response is a JSON list and return it.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
response: HTTP response
|
|
144
|
+
min_length: Minimum list length
|
|
145
|
+
max_length: Maximum list length (None = no limit)
|
|
146
|
+
msg: Optional message
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The response JSON list
|
|
150
|
+
"""
|
|
151
|
+
actual = _safe_json(response)
|
|
152
|
+
|
|
153
|
+
if not isinstance(actual, list):
|
|
154
|
+
raise AssertionError(
|
|
155
|
+
f"Expected JSON list, got {type(actual).__name__}: {actual}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if len(actual) < min_length:
|
|
159
|
+
error_msg = f"List too short: expected at least {min_length}, got {len(actual)}"
|
|
160
|
+
if msg:
|
|
161
|
+
error_msg = f"{msg}. {error_msg}"
|
|
162
|
+
raise AssertionError(error_msg)
|
|
163
|
+
|
|
164
|
+
if max_length is not None and len(actual) > max_length:
|
|
165
|
+
error_msg = f"List too long: expected at most {max_length}, got {len(actual)}"
|
|
166
|
+
if msg:
|
|
167
|
+
error_msg = f"{msg}. {error_msg}"
|
|
168
|
+
raise AssertionError(error_msg)
|
|
169
|
+
|
|
170
|
+
return actual
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def assert_error_code(
|
|
174
|
+
response: "Response",
|
|
175
|
+
code: str,
|
|
176
|
+
msg: str = "",
|
|
177
|
+
) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Assert response contains specific error code.
|
|
180
|
+
|
|
181
|
+
Looks for code in: detail.code, code, error.code
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
response: HTTP response
|
|
185
|
+
code: Expected error code
|
|
186
|
+
msg: Optional message
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
assert_error_code(response, "not_found")
|
|
190
|
+
assert_error_code(response, "validation_error")
|
|
191
|
+
"""
|
|
192
|
+
actual = _safe_json(response)
|
|
193
|
+
|
|
194
|
+
if not isinstance(actual, dict):
|
|
195
|
+
raise AssertionError(f"Expected JSON object, got: {actual}")
|
|
196
|
+
|
|
197
|
+
# Try different locations for error code
|
|
198
|
+
actual_code = (
|
|
199
|
+
actual.get("code") or
|
|
200
|
+
(actual.get("detail", {}).get("code") if isinstance(actual.get("detail"), dict) else None) or
|
|
201
|
+
actual.get("error", {}).get("code") if isinstance(actual.get("error"), dict) else None
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if actual_code != code:
|
|
205
|
+
error_msg = f"Expected error code '{code}', got '{actual_code}'. Response: {actual}"
|
|
206
|
+
if msg:
|
|
207
|
+
error_msg = f"{msg}. {error_msg}"
|
|
208
|
+
raise AssertionError(error_msg)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def assert_validation_error(
|
|
212
|
+
response: "Response",
|
|
213
|
+
field: str | None = None,
|
|
214
|
+
msg: str = "",
|
|
215
|
+
) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Assert response is a validation error.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
response: HTTP response
|
|
221
|
+
field: Optional field that should have error
|
|
222
|
+
msg: Optional message
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
assert_validation_error(response)
|
|
226
|
+
assert_validation_error(response, field="email")
|
|
227
|
+
"""
|
|
228
|
+
if response.status_code != 422:
|
|
229
|
+
raise AssertionError(
|
|
230
|
+
f"Expected 422 validation error, got {response.status_code}. "
|
|
231
|
+
f"Response: {_safe_json(response)}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if field is not None:
|
|
235
|
+
actual = _safe_json(response)
|
|
236
|
+
|
|
237
|
+
# Look for field in various error formats
|
|
238
|
+
found = False
|
|
239
|
+
|
|
240
|
+
# FastAPI format: detail[].loc
|
|
241
|
+
detail = actual.get("detail", [])
|
|
242
|
+
if isinstance(detail, list):
|
|
243
|
+
for error in detail:
|
|
244
|
+
loc = error.get("loc", [])
|
|
245
|
+
if field in loc or (len(loc) > 1 and loc[-1] == field):
|
|
246
|
+
found = True
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
# Our format: errors[].field
|
|
250
|
+
errors = actual.get("errors", [])
|
|
251
|
+
if isinstance(errors, list):
|
|
252
|
+
for error in errors:
|
|
253
|
+
if error.get("field") == field:
|
|
254
|
+
found = True
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
if not found:
|
|
258
|
+
error_msg = f"Expected validation error for field '{field}'. Response: {actual}"
|
|
259
|
+
if msg:
|
|
260
|
+
error_msg = f"{msg}. {error_msg}"
|
|
261
|
+
raise AssertionError(error_msg)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def assert_header(
|
|
265
|
+
response: "Response",
|
|
266
|
+
header: str,
|
|
267
|
+
expected: str | None = None,
|
|
268
|
+
msg: str = "",
|
|
269
|
+
) -> str | None:
|
|
270
|
+
"""
|
|
271
|
+
Assert response has header, optionally with specific value.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
response: HTTP response
|
|
275
|
+
header: Header name
|
|
276
|
+
expected: Expected value (None = just check exists)
|
|
277
|
+
msg: Optional message
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Header value
|
|
281
|
+
"""
|
|
282
|
+
actual = response.headers.get(header)
|
|
283
|
+
|
|
284
|
+
if actual is None:
|
|
285
|
+
error_msg = f"Missing header '{header}'. Headers: {dict(response.headers)}"
|
|
286
|
+
if msg:
|
|
287
|
+
error_msg = f"{msg}. {error_msg}"
|
|
288
|
+
raise AssertionError(error_msg)
|
|
289
|
+
|
|
290
|
+
if expected is not None and actual != expected:
|
|
291
|
+
error_msg = f"Header '{header}' mismatch: expected '{expected}', got '{actual}'"
|
|
292
|
+
if msg:
|
|
293
|
+
error_msg = f"{msg}. {error_msg}"
|
|
294
|
+
raise AssertionError(error_msg)
|
|
295
|
+
|
|
296
|
+
return actual
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def assert_no_error(response: "Response", msg: str = "") -> None:
|
|
300
|
+
"""
|
|
301
|
+
Assert response is not an error (2xx status).
|
|
302
|
+
|
|
303
|
+
Provides detailed error message on failure.
|
|
304
|
+
"""
|
|
305
|
+
if response.status_code >= 400:
|
|
306
|
+
body = _safe_json(response)
|
|
307
|
+
error_msg = (
|
|
308
|
+
f"Request failed with {response.status_code}. "
|
|
309
|
+
f"Response: {body}"
|
|
310
|
+
)
|
|
311
|
+
if msg:
|
|
312
|
+
error_msg = f"{msg}. {error_msg}"
|
|
313
|
+
raise AssertionError(error_msg)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def assert_created(response: "Response", msg: str = "") -> dict[str, Any]:
|
|
317
|
+
"""
|
|
318
|
+
Assert response is 201 Created and return JSON body.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Response JSON
|
|
322
|
+
"""
|
|
323
|
+
assert_status(response, 201, msg)
|
|
324
|
+
return _safe_json(response)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def assert_not_found(response: "Response", msg: str = "") -> None:
|
|
328
|
+
"""Assert response is 404 Not Found."""
|
|
329
|
+
assert_status(response, 404, msg)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def assert_unauthorized(response: "Response", msg: str = "") -> None:
|
|
333
|
+
"""Assert response is 401 Unauthorized."""
|
|
334
|
+
assert_status(response, 401, msg)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def assert_forbidden(response: "Response", msg: str = "") -> None:
|
|
338
|
+
"""Assert response is 403 Forbidden."""
|
|
339
|
+
assert_status(response, 403, msg)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _safe_json(response: "Response") -> Any:
|
|
343
|
+
"""Safely get JSON from response."""
|
|
344
|
+
try:
|
|
345
|
+
return response.json()
|
|
346
|
+
except Exception:
|
|
347
|
+
return response.text
|