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.
@@ -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