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
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()
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data factories for generating test data.
|
|
3
|
+
|
|
4
|
+
Provides a factory pattern for creating test instances with fake data.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
# Define a factory
|
|
8
|
+
class UserFactory(Factory):
|
|
9
|
+
model = User
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def build(cls, **overrides):
|
|
13
|
+
return {
|
|
14
|
+
"email": fake.email(),
|
|
15
|
+
"name": fake.name(),
|
|
16
|
+
**overrides,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Use in tests
|
|
20
|
+
async def test_user_creation(db):
|
|
21
|
+
user = await UserFactory.create(db)
|
|
22
|
+
assert user.id is not None
|
|
23
|
+
|
|
24
|
+
users = await UserFactory.create_batch(db, 5)
|
|
25
|
+
assert len(users) == 5
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from typing import TypeVar, Generic, Any, TYPE_CHECKING
|
|
32
|
+
from uuid import uuid4
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("core.testing")
|
|
38
|
+
|
|
39
|
+
# Try to import faker, provide fallback
|
|
40
|
+
try:
|
|
41
|
+
from faker import Faker
|
|
42
|
+
fake = Faker()
|
|
43
|
+
except ImportError:
|
|
44
|
+
# Minimal fake data generator if faker not installed
|
|
45
|
+
class MinimalFaker:
|
|
46
|
+
"""Minimal faker replacement when faker is not installed."""
|
|
47
|
+
|
|
48
|
+
_counter = 0
|
|
49
|
+
|
|
50
|
+
def email(self) -> str:
|
|
51
|
+
self._counter += 1
|
|
52
|
+
return f"user{self._counter}@example.com"
|
|
53
|
+
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
self._counter += 1
|
|
56
|
+
return f"User {self._counter}"
|
|
57
|
+
|
|
58
|
+
def first_name(self) -> str:
|
|
59
|
+
return "John"
|
|
60
|
+
|
|
61
|
+
def last_name(self) -> str:
|
|
62
|
+
return "Doe"
|
|
63
|
+
|
|
64
|
+
def text(self, max_nb_chars: int = 200) -> str:
|
|
65
|
+
return "Lorem ipsum dolor sit amet." * (max_nb_chars // 30 + 1)
|
|
66
|
+
|
|
67
|
+
def sentence(self) -> str:
|
|
68
|
+
return "This is a test sentence."
|
|
69
|
+
|
|
70
|
+
def paragraph(self) -> str:
|
|
71
|
+
return "This is a test paragraph with multiple sentences. It contains some text for testing purposes."
|
|
72
|
+
|
|
73
|
+
def url(self) -> str:
|
|
74
|
+
self._counter += 1
|
|
75
|
+
return f"https://example.com/{self._counter}"
|
|
76
|
+
|
|
77
|
+
def uuid4(self) -> str:
|
|
78
|
+
return str(uuid4())
|
|
79
|
+
|
|
80
|
+
def random_int(self, min: int = 0, max: int = 9999) -> int:
|
|
81
|
+
import random
|
|
82
|
+
return random.randint(min, max)
|
|
83
|
+
|
|
84
|
+
def boolean(self) -> bool:
|
|
85
|
+
import random
|
|
86
|
+
return random.choice([True, False])
|
|
87
|
+
|
|
88
|
+
def date_this_year(self) -> str:
|
|
89
|
+
from datetime import date
|
|
90
|
+
return date.today().isoformat()
|
|
91
|
+
|
|
92
|
+
def date_time_this_year(self):
|
|
93
|
+
from datetime import datetime
|
|
94
|
+
return datetime.now()
|
|
95
|
+
|
|
96
|
+
def company(self) -> str:
|
|
97
|
+
self._counter += 1
|
|
98
|
+
return f"Company {self._counter}"
|
|
99
|
+
|
|
100
|
+
def phone_number(self) -> str:
|
|
101
|
+
self._counter += 1
|
|
102
|
+
return f"+1-555-{self._counter:04d}"
|
|
103
|
+
|
|
104
|
+
def address(self) -> str:
|
|
105
|
+
return "123 Test Street, Test City, TC 12345"
|
|
106
|
+
|
|
107
|
+
fake = MinimalFaker()
|
|
108
|
+
logger.warning(
|
|
109
|
+
"faker not installed. Using minimal fake data generator. "
|
|
110
|
+
"Install with: pip install faker"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
T = TypeVar("T")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Factory(Generic[T]):
|
|
118
|
+
"""
|
|
119
|
+
Base factory for creating test instances.
|
|
120
|
+
|
|
121
|
+
Subclass this to create factories for your models.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
class UserFactory(Factory):
|
|
125
|
+
model = User
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def build(cls, **overrides):
|
|
129
|
+
return {
|
|
130
|
+
"email": fake.email(),
|
|
131
|
+
"name": fake.name(),
|
|
132
|
+
"is_active": True,
|
|
133
|
+
**overrides,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Build dict (no database)
|
|
137
|
+
data = UserFactory.build(name="Custom Name")
|
|
138
|
+
|
|
139
|
+
# Create instance (saves to database)
|
|
140
|
+
user = await UserFactory.create(db)
|
|
141
|
+
|
|
142
|
+
# Create multiple
|
|
143
|
+
users = await UserFactory.create_batch(db, 10)
|
|
144
|
+
|
|
145
|
+
Attributes:
|
|
146
|
+
model: The model class to create instances of
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
model: type[T]
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def build(cls, **overrides) -> dict[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
Build a dict of attributes without saving.
|
|
155
|
+
|
|
156
|
+
Override this method in subclasses to define default values.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
**overrides: Values to override defaults
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict of model attributes
|
|
163
|
+
"""
|
|
164
|
+
raise NotImplementedError(
|
|
165
|
+
f"{cls.__name__} must implement build() method"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
async def create(
|
|
170
|
+
cls,
|
|
171
|
+
db: "AsyncSession",
|
|
172
|
+
**overrides,
|
|
173
|
+
) -> T:
|
|
174
|
+
"""
|
|
175
|
+
Create and save an instance to the database.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
db: Database session
|
|
179
|
+
**overrides: Values to override defaults
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Created model instance
|
|
183
|
+
"""
|
|
184
|
+
data = cls.build(**overrides)
|
|
185
|
+
instance = cls.model(**data)
|
|
186
|
+
|
|
187
|
+
db.add(instance)
|
|
188
|
+
await db.commit()
|
|
189
|
+
await db.refresh(instance)
|
|
190
|
+
|
|
191
|
+
logger.debug(f"Factory created: {cls.model.__name__}")
|
|
192
|
+
return instance
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
async def create_batch(
|
|
196
|
+
cls,
|
|
197
|
+
db: "AsyncSession",
|
|
198
|
+
count: int,
|
|
199
|
+
**overrides,
|
|
200
|
+
) -> list[T]:
|
|
201
|
+
"""
|
|
202
|
+
Create multiple instances.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
db: Database session
|
|
206
|
+
count: Number of instances to create
|
|
207
|
+
**overrides: Values to override defaults for all instances
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of created instances
|
|
211
|
+
"""
|
|
212
|
+
instances = []
|
|
213
|
+
for _ in range(count):
|
|
214
|
+
instance = await cls.create(db, **overrides)
|
|
215
|
+
instances.append(instance)
|
|
216
|
+
|
|
217
|
+
logger.debug(f"Factory created batch: {count} x {cls.model.__name__}")
|
|
218
|
+
return instances
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def build_batch(cls, count: int, **overrides) -> list[dict[str, Any]]:
|
|
222
|
+
"""
|
|
223
|
+
Build multiple dicts without saving.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
count: Number of dicts to build
|
|
227
|
+
**overrides: Values to override defaults
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
List of dicts
|
|
231
|
+
"""
|
|
232
|
+
return [cls.build(**overrides) for _ in range(count)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class UserFactory(Factory):
|
|
236
|
+
"""
|
|
237
|
+
Factory for creating test users.
|
|
238
|
+
|
|
239
|
+
Works with AbstractUser-based models.
|
|
240
|
+
|
|
241
|
+
Example:
|
|
242
|
+
# Create user with random data
|
|
243
|
+
user = await UserFactory.create(db)
|
|
244
|
+
|
|
245
|
+
# Create with specific email
|
|
246
|
+
admin = await UserFactory.create(db, email="admin@example.com", is_superuser=True)
|
|
247
|
+
|
|
248
|
+
# Create batch
|
|
249
|
+
users = await UserFactory.create_batch(db, 10)
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
model: type = None # Set dynamically
|
|
253
|
+
_default_password: str = "TestPass123!"
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def _get_model(cls):
|
|
257
|
+
"""Get user model from auth config."""
|
|
258
|
+
if cls.model is not None:
|
|
259
|
+
return cls.model
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
from core.auth.models import get_user_model
|
|
263
|
+
return get_user_model()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
from core.auth.models import AbstractUser
|
|
269
|
+
return AbstractUser
|
|
270
|
+
except Exception:
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
"Could not determine User model. "
|
|
273
|
+
"Set UserFactory.model = YourUserModel"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def build(cls, **overrides) -> dict[str, Any]:
|
|
278
|
+
"""Build user data dict."""
|
|
279
|
+
password = overrides.pop("password", cls._default_password)
|
|
280
|
+
|
|
281
|
+
data = {
|
|
282
|
+
"email": fake.email(),
|
|
283
|
+
"is_active": True,
|
|
284
|
+
"is_superuser": False,
|
|
285
|
+
"is_staff": False,
|
|
286
|
+
}
|
|
287
|
+
data.update(overrides)
|
|
288
|
+
|
|
289
|
+
# Handle password hashing
|
|
290
|
+
if "password_hash" not in data:
|
|
291
|
+
try:
|
|
292
|
+
from core.auth.hashers import get_hasher
|
|
293
|
+
hasher = get_hasher()
|
|
294
|
+
data["password_hash"] = hasher.hash(password)
|
|
295
|
+
except Exception:
|
|
296
|
+
# Fallback: assume model handles password
|
|
297
|
+
data["_password"] = password
|
|
298
|
+
|
|
299
|
+
return data
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
async def create(
|
|
303
|
+
cls,
|
|
304
|
+
db: "AsyncSession",
|
|
305
|
+
**overrides,
|
|
306
|
+
) -> Any:
|
|
307
|
+
"""Create and save a user."""
|
|
308
|
+
model = cls._get_model()
|
|
309
|
+
|
|
310
|
+
data = cls.build(**overrides)
|
|
311
|
+
|
|
312
|
+
# Handle password separately if model has set_password
|
|
313
|
+
password = data.pop("_password", None)
|
|
314
|
+
|
|
315
|
+
instance = model(**data)
|
|
316
|
+
|
|
317
|
+
if password and hasattr(instance, "set_password"):
|
|
318
|
+
instance.set_password(password)
|
|
319
|
+
|
|
320
|
+
db.add(instance)
|
|
321
|
+
await db.commit()
|
|
322
|
+
await db.refresh(instance)
|
|
323
|
+
|
|
324
|
+
return instance
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# Additional common factories
|
|
328
|
+
|
|
329
|
+
class SequenceFactory:
|
|
330
|
+
"""
|
|
331
|
+
Generate sequential values for unique fields.
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
seq = SequenceFactory("user_{n}@example.com")
|
|
335
|
+
seq.next() # "user_1@example.com"
|
|
336
|
+
seq.next() # "user_2@example.com"
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(self, template: str, start: int = 1) -> None:
|
|
340
|
+
"""
|
|
341
|
+
Initialize sequence.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
template: String template with {n} placeholder
|
|
345
|
+
start: Starting number
|
|
346
|
+
"""
|
|
347
|
+
self.template = template
|
|
348
|
+
self.counter = start
|
|
349
|
+
|
|
350
|
+
def next(self) -> str:
|
|
351
|
+
"""Get next value in sequence."""
|
|
352
|
+
value = self.template.format(n=self.counter)
|
|
353
|
+
self.counter += 1
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
def reset(self, start: int = 1) -> None:
|
|
357
|
+
"""Reset counter."""
|
|
358
|
+
self.counter = start
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class LazyAttribute:
|
|
362
|
+
"""
|
|
363
|
+
Lazily compute attribute value.
|
|
364
|
+
|
|
365
|
+
Example:
|
|
366
|
+
class PostFactory(Factory):
|
|
367
|
+
model = Post
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def build(cls, **overrides):
|
|
371
|
+
return {
|
|
372
|
+
"title": fake.sentence(),
|
|
373
|
+
"slug": LazyAttribute(lambda obj: slugify(obj["title"])),
|
|
374
|
+
**overrides,
|
|
375
|
+
}
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self, func) -> None:
|
|
379
|
+
self.func = func
|
|
380
|
+
|
|
381
|
+
def __call__(self, obj: dict) -> Any:
|
|
382
|
+
return self.func(obj)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def resolve_lazy_attributes(data: dict) -> dict:
|
|
386
|
+
"""Resolve any LazyAttribute values in a dict."""
|
|
387
|
+
result = {}
|
|
388
|
+
for key, value in data.items():
|
|
389
|
+
if isinstance(value, LazyAttribute):
|
|
390
|
+
result[key] = value(data)
|
|
391
|
+
else:
|
|
392
|
+
result[key] = value
|
|
393
|
+
return result
|