core-framework 0.12.6__py3-none-any.whl → 0.12.8__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/auth/decorators.py +28 -7
- core/cli/__init__.py +2 -0
- core/cli/main.py +163 -0
- core/permissions.py +29 -8
- 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.6.dist-info → core_framework-0.12.8.dist-info}/METADATA +6 -1
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/RECORD +16 -9
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/entry_points.txt +3 -0
- {core_framework-0.12.6.dist-info → core_framework-0.12.8.dist-info}/WHEEL +0 -0
|
@@ -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
|
core/testing/client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP test clients with automatic app setup.
|
|
3
|
+
|
|
4
|
+
Provides TestClient and AuthenticatedClient for testing core-framework
|
|
5
|
+
applications with minimal boilerplate.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# Basic client
|
|
9
|
+
async with TestClient(app) as client:
|
|
10
|
+
response = await client.get("/health")
|
|
11
|
+
assert response.status_code == 200
|
|
12
|
+
|
|
13
|
+
# Authenticated client
|
|
14
|
+
async with AuthenticatedClient(app) as client:
|
|
15
|
+
response = await client.get("/auth/me")
|
|
16
|
+
assert response.status_code == 200
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from httpx import AsyncClient
|
|
27
|
+
from collections.abc import AsyncGenerator
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("core.testing")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestClient:
|
|
33
|
+
"""
|
|
34
|
+
Test client that auto-initializes the test environment.
|
|
35
|
+
|
|
36
|
+
Features:
|
|
37
|
+
- Automatic database setup with in-memory SQLite
|
|
38
|
+
- Automatic table creation
|
|
39
|
+
- Proper cleanup on exit
|
|
40
|
+
- Full async support
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
async with TestClient(app) as client:
|
|
44
|
+
response = await client.post(
|
|
45
|
+
"/api/v1/users",
|
|
46
|
+
json={"email": "test@example.com"}
|
|
47
|
+
)
|
|
48
|
+
assert response.status_code == 201
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
app: FastAPI/Starlette application instance
|
|
52
|
+
database_url: Database URL for tests (default: in-memory SQLite)
|
|
53
|
+
base_url: Base URL for requests (default: "http://test")
|
|
54
|
+
auto_create_tables: Whether to create tables automatically
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
app: Any,
|
|
60
|
+
database_url: str = "sqlite+aiosqlite:///:memory:",
|
|
61
|
+
base_url: str = "http://test",
|
|
62
|
+
auto_create_tables: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.app = app
|
|
65
|
+
self.database_url = database_url
|
|
66
|
+
self.base_url = base_url
|
|
67
|
+
self.auto_create_tables = auto_create_tables
|
|
68
|
+
self._client: "AsyncClient | None" = None
|
|
69
|
+
self._engine: Any = None
|
|
70
|
+
|
|
71
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
72
|
+
"""Setup test environment and return HTTP client."""
|
|
73
|
+
from httpx import ASGITransport, AsyncClient
|
|
74
|
+
|
|
75
|
+
# Setup database
|
|
76
|
+
await self._setup_database()
|
|
77
|
+
|
|
78
|
+
# Create HTTP client
|
|
79
|
+
self._client = AsyncClient(
|
|
80
|
+
transport=ASGITransport(app=self.app),
|
|
81
|
+
base_url=self.base_url,
|
|
82
|
+
follow_redirects=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logger.debug(f"TestClient initialized with database: {self.database_url}")
|
|
86
|
+
return self._client
|
|
87
|
+
|
|
88
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
89
|
+
"""Cleanup test environment."""
|
|
90
|
+
if self._client:
|
|
91
|
+
await self._client.aclose()
|
|
92
|
+
self._client = None
|
|
93
|
+
|
|
94
|
+
await self._teardown_database()
|
|
95
|
+
logger.debug("TestClient cleaned up")
|
|
96
|
+
|
|
97
|
+
async def _setup_database(self) -> None:
|
|
98
|
+
"""Initialize test database."""
|
|
99
|
+
from core.testing.database import setup_test_db
|
|
100
|
+
self._engine = await setup_test_db(
|
|
101
|
+
self.database_url,
|
|
102
|
+
create_tables=self.auto_create_tables,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def _teardown_database(self) -> None:
|
|
106
|
+
"""Cleanup test database."""
|
|
107
|
+
from core.testing.database import teardown_test_db
|
|
108
|
+
await teardown_test_db()
|
|
109
|
+
self._engine = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AuthenticatedClient(TestClient):
|
|
113
|
+
"""
|
|
114
|
+
Test client with automatic user registration and authentication.
|
|
115
|
+
|
|
116
|
+
Creates a test user on setup and includes the authentication token
|
|
117
|
+
in all subsequent requests.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
async with AuthenticatedClient(app) as client:
|
|
121
|
+
# Already authenticated!
|
|
122
|
+
response = await client.get("/api/v1/auth/me")
|
|
123
|
+
assert response.status_code == 200
|
|
124
|
+
assert response.json()["email"] == "test@example.com"
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
app: FastAPI/Starlette application instance
|
|
128
|
+
email: Email for test user (default: "test@example.com")
|
|
129
|
+
password: Password for test user (default: "TestPass123!")
|
|
130
|
+
register_url: URL for registration endpoint
|
|
131
|
+
login_url: URL for login endpoint
|
|
132
|
+
extra_register_data: Additional data for registration
|
|
133
|
+
**kwargs: Additional arguments for TestClient
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
app: Any,
|
|
139
|
+
email: str = "test@example.com",
|
|
140
|
+
password: str = "TestPass123!",
|
|
141
|
+
register_url: str = "/api/v1/auth/register",
|
|
142
|
+
login_url: str = "/api/v1/auth/login",
|
|
143
|
+
extra_register_data: dict[str, Any] | None = None,
|
|
144
|
+
**kwargs,
|
|
145
|
+
) -> None:
|
|
146
|
+
super().__init__(app, **kwargs)
|
|
147
|
+
self.email = email
|
|
148
|
+
self.password = password
|
|
149
|
+
self.register_url = register_url
|
|
150
|
+
self.login_url = login_url
|
|
151
|
+
self.extra_register_data = extra_register_data or {}
|
|
152
|
+
self._user_data: dict[str, Any] | None = None
|
|
153
|
+
self._token: str | None = None
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def user(self) -> dict[str, Any] | None:
|
|
157
|
+
"""Get authenticated user data."""
|
|
158
|
+
return self._user_data
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def token(self) -> str | None:
|
|
162
|
+
"""Get authentication token."""
|
|
163
|
+
return self._token
|
|
164
|
+
|
|
165
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
166
|
+
"""Setup, register user, and authenticate."""
|
|
167
|
+
client = await super().__aenter__()
|
|
168
|
+
|
|
169
|
+
# Register user
|
|
170
|
+
register_data = {
|
|
171
|
+
"email": self.email,
|
|
172
|
+
"password": self.password,
|
|
173
|
+
**self.extra_register_data,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
register_response = await client.post(
|
|
177
|
+
self.register_url,
|
|
178
|
+
json=register_data,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if register_response.status_code not in (200, 201):
|
|
182
|
+
# User might already exist, try login directly
|
|
183
|
+
logger.debug(f"Registration returned {register_response.status_code}, trying login")
|
|
184
|
+
|
|
185
|
+
# Login
|
|
186
|
+
login_response = await client.post(
|
|
187
|
+
self.login_url,
|
|
188
|
+
json={"email": self.email, "password": self.password},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if login_response.status_code != 200:
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
f"Failed to authenticate test user: {login_response.status_code} "
|
|
194
|
+
f"{login_response.text}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Extract token
|
|
198
|
+
login_data = login_response.json()
|
|
199
|
+
self._token = login_data.get("access_token") or login_data.get("token")
|
|
200
|
+
self._user_data = login_data.get("user", {"email": self.email})
|
|
201
|
+
|
|
202
|
+
if not self._token:
|
|
203
|
+
raise RuntimeError(f"No token in login response: {login_data}")
|
|
204
|
+
|
|
205
|
+
# Set authorization header
|
|
206
|
+
client.headers["Authorization"] = f"Bearer {self._token}"
|
|
207
|
+
|
|
208
|
+
logger.debug(f"AuthenticatedClient ready with user: {self.email}")
|
|
209
|
+
return client
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@asynccontextmanager
|
|
213
|
+
async def create_test_client(
|
|
214
|
+
app: Any,
|
|
215
|
+
**kwargs,
|
|
216
|
+
) -> "AsyncGenerator[AsyncClient, None]":
|
|
217
|
+
"""
|
|
218
|
+
Context manager to create a test client.
|
|
219
|
+
|
|
220
|
+
Convenience function for creating TestClient.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
async with create_test_client(app) as client:
|
|
224
|
+
response = await client.get("/health")
|
|
225
|
+
"""
|
|
226
|
+
async with TestClient(app, **kwargs) as client:
|
|
227
|
+
yield client
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@asynccontextmanager
|
|
231
|
+
async def create_auth_client(
|
|
232
|
+
app: Any,
|
|
233
|
+
email: str = "test@example.com",
|
|
234
|
+
password: str = "TestPass123!",
|
|
235
|
+
**kwargs,
|
|
236
|
+
) -> "AsyncGenerator[AsyncClient, None]":
|
|
237
|
+
"""
|
|
238
|
+
Context manager to create an authenticated test client.
|
|
239
|
+
|
|
240
|
+
Convenience function for creating AuthenticatedClient.
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
async with create_auth_client(app) as client:
|
|
244
|
+
response = await client.get("/auth/me")
|
|
245
|
+
"""
|
|
246
|
+
async with AuthenticatedClient(app, email=email, password=password, **kwargs) as client:
|
|
247
|
+
yield client
|