core-framework 0.12.7__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/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
@@ -0,0 +1,307 @@
1
+ """
2
+ Test database utilities.
3
+
4
+ Provides functions for setting up and tearing down test databases,
5
+ with support for both SQLite (in-memory) and PostgreSQL.
6
+
7
+ Usage:
8
+ # Setup
9
+ engine = await setup_test_db("sqlite+aiosqlite:///:memory:")
10
+
11
+ # Get session
12
+ async with get_test_session() as session:
13
+ user = User(email="test@example.com")
14
+ session.add(user)
15
+ await session.commit()
16
+
17
+ # Cleanup
18
+ await teardown_test_db()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import TYPE_CHECKING, Any
25
+ from contextlib import asynccontextmanager
26
+
27
+ if TYPE_CHECKING:
28
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
29
+ from collections.abc import AsyncGenerator
30
+
31
+ logger = logging.getLogger("core.testing")
32
+
33
+ # Global test database state
34
+ _test_engine: "AsyncEngine | None" = None
35
+ _test_session_factory: Any = None
36
+
37
+
38
+ class TestDatabase:
39
+ """
40
+ Test database manager.
41
+
42
+ Manages test database lifecycle including setup, session management,
43
+ and cleanup. Supports both SQLite and PostgreSQL.
44
+
45
+ Example:
46
+ db = TestDatabase("sqlite+aiosqlite:///:memory:")
47
+ await db.setup()
48
+
49
+ async with db.session() as session:
50
+ # Use session
51
+ pass
52
+
53
+ await db.teardown()
54
+
55
+ Args:
56
+ database_url: Database URL
57
+ echo: Whether to log SQL statements
58
+ create_tables: Whether to create tables on setup
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ database_url: str = "sqlite+aiosqlite:///:memory:",
64
+ echo: bool = False,
65
+ create_tables: bool = True,
66
+ ) -> None:
67
+ self.database_url = database_url
68
+ self.echo = echo
69
+ self.create_tables = create_tables
70
+ self._engine: "AsyncEngine | None" = None
71
+ self._session_factory: Any = None
72
+
73
+ async def setup(self) -> "AsyncEngine":
74
+ """
75
+ Initialize test database.
76
+
77
+ Creates engine, session factory, and optionally creates all tables.
78
+
79
+ Returns:
80
+ The database engine
81
+ """
82
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
83
+
84
+ # Create engine with test-specific settings
85
+ self._engine = create_async_engine(
86
+ self.database_url,
87
+ echo=self.echo,
88
+ # SQLite-specific settings
89
+ connect_args={"check_same_thread": False} if "sqlite" in self.database_url else {},
90
+ )
91
+
92
+ # Create session factory
93
+ self._session_factory = async_sessionmaker(
94
+ self._engine,
95
+ class_=AsyncSession,
96
+ expire_on_commit=False,
97
+ )
98
+
99
+ # Create tables if requested
100
+ if self.create_tables:
101
+ await self._create_tables()
102
+
103
+ logger.debug(f"Test database initialized: {self.database_url}")
104
+ return self._engine
105
+
106
+ async def teardown(self) -> None:
107
+ """
108
+ Cleanup test database.
109
+
110
+ Drops all tables and disposes of the engine.
111
+ """
112
+ if self._engine:
113
+ # Drop tables
114
+ await self._drop_tables()
115
+
116
+ # Dispose engine
117
+ await self._engine.dispose()
118
+ self._engine = None
119
+ self._session_factory = None
120
+
121
+ logger.debug("Test database cleaned up")
122
+
123
+ async def _create_tables(self) -> None:
124
+ """Create all tables from registered models."""
125
+ try:
126
+ from core.models import Model
127
+ metadata = Model.metadata
128
+ except ImportError:
129
+ logger.warning("Could not import Model, tables not created")
130
+ return
131
+
132
+ async with self._engine.begin() as conn:
133
+ await conn.run_sync(metadata.create_all)
134
+
135
+ logger.debug("Test database tables created")
136
+
137
+ async def _drop_tables(self) -> None:
138
+ """Drop all tables."""
139
+ try:
140
+ from core.models import Model
141
+ metadata = Model.metadata
142
+ except ImportError:
143
+ return
144
+
145
+ async with self._engine.begin() as conn:
146
+ await conn.run_sync(metadata.drop_all)
147
+
148
+ @asynccontextmanager
149
+ async def session(self) -> "AsyncGenerator[AsyncSession, None]":
150
+ """
151
+ Get a database session.
152
+
153
+ Session is automatically committed on success and rolled back on error.
154
+
155
+ Yields:
156
+ Database session
157
+ """
158
+ if not self._session_factory:
159
+ raise RuntimeError("Database not initialized. Call setup() first.")
160
+
161
+ session = self._session_factory()
162
+ try:
163
+ yield session
164
+ await session.commit()
165
+ except Exception:
166
+ await session.rollback()
167
+ raise
168
+ finally:
169
+ await session.close()
170
+
171
+ async def truncate_all(self) -> None:
172
+ """
173
+ Truncate all tables (faster than drop/create for resetting between tests).
174
+ """
175
+ try:
176
+ from core.models import Model
177
+ metadata = Model.metadata
178
+ except ImportError:
179
+ return
180
+
181
+ async with self._engine.begin() as conn:
182
+ for table in reversed(metadata.sorted_tables):
183
+ await conn.execute(table.delete())
184
+
185
+ logger.debug("Test database tables truncated")
186
+
187
+ async def __aenter__(self) -> "TestDatabase":
188
+ """Setup on context enter."""
189
+ await self.setup()
190
+ return self
191
+
192
+ async def __aexit__(self, *args) -> None:
193
+ """Teardown on context exit."""
194
+ await self.teardown()
195
+
196
+
197
+ # =============================================================================
198
+ # Module-level functions for simpler usage
199
+ # =============================================================================
200
+
201
+ async def setup_test_db(
202
+ database_url: str = "sqlite+aiosqlite:///:memory:",
203
+ create_tables: bool = True,
204
+ echo: bool = False,
205
+ ) -> "AsyncEngine":
206
+ """
207
+ Setup test database.
208
+
209
+ Initializes the global test database state.
210
+
211
+ Args:
212
+ database_url: Database URL
213
+ create_tables: Whether to create tables
214
+ echo: Whether to log SQL
215
+
216
+ Returns:
217
+ Database engine
218
+
219
+ Example:
220
+ engine = await setup_test_db()
221
+ # ... run tests ...
222
+ await teardown_test_db()
223
+ """
224
+ global _test_engine, _test_session_factory
225
+
226
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
227
+
228
+ _test_engine = create_async_engine(
229
+ database_url,
230
+ echo=echo,
231
+ connect_args={"check_same_thread": False} if "sqlite" in database_url else {},
232
+ )
233
+
234
+ _test_session_factory = async_sessionmaker(
235
+ _test_engine,
236
+ class_=AsyncSession,
237
+ expire_on_commit=False,
238
+ )
239
+
240
+ if create_tables:
241
+ try:
242
+ from core.models import Model
243
+ async with _test_engine.begin() as conn:
244
+ await conn.run_sync(Model.metadata.create_all)
245
+ except ImportError:
246
+ pass
247
+
248
+ # Also register with core.database for dependencies
249
+ try:
250
+ from core import database
251
+ database._write_session_factory = _test_session_factory
252
+ database._read_session_factory = _test_session_factory
253
+ except (ImportError, AttributeError):
254
+ pass
255
+
256
+ logger.debug(f"Test database setup: {database_url}")
257
+ return _test_engine
258
+
259
+
260
+ async def teardown_test_db() -> None:
261
+ """
262
+ Teardown test database.
263
+
264
+ Cleans up the global test database state.
265
+ """
266
+ global _test_engine, _test_session_factory
267
+
268
+ if _test_engine:
269
+ # Drop tables
270
+ try:
271
+ from core.models import Model
272
+ async with _test_engine.begin() as conn:
273
+ await conn.run_sync(Model.metadata.drop_all)
274
+ except ImportError:
275
+ pass
276
+
277
+ await _test_engine.dispose()
278
+ _test_engine = None
279
+ _test_session_factory = None
280
+
281
+ logger.debug("Test database teardown complete")
282
+
283
+
284
+ @asynccontextmanager
285
+ async def get_test_session() -> "AsyncGenerator[AsyncSession, None]":
286
+ """
287
+ Get a test database session.
288
+
289
+ Yields:
290
+ Database session
291
+
292
+ Example:
293
+ async with get_test_session() as session:
294
+ user = await User.objects.using(session).get(id=1)
295
+ """
296
+ if not _test_session_factory:
297
+ raise RuntimeError("Test database not initialized. Call setup_test_db() first.")
298
+
299
+ session = _test_session_factory()
300
+ try:
301
+ yield session
302
+ await session.commit()
303
+ except Exception:
304
+ await session.rollback()
305
+ raise
306
+ finally:
307
+ await session.close()