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/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
|
core/testing/database.py
ADDED
|
@@ -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()
|