simple-module-test 0.0.32__tar.gz → 0.0.34__tar.gz
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.
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/PKG-INFO +4 -4
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/pyproject.toml +4 -4
- simple_module_test-0.0.34/simple_module_test/_schema.py +77 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/fixtures.py +83 -61
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/plugin.py +1 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/.gitignore +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/LICENSE +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/README.md +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/__init__.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/app_factory.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/fake_events.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/routes.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/session_cookie.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/tests/test_app_factory.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/tests/test_fake_events.py +0 -0
- {simple_module_test-0.0.32 → simple_module_test-0.0.34}/tests/test_plugin_fixtures.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_test
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.34
|
|
4
4
|
Summary: Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -25,9 +25,9 @@ Requires-Dist: fastapi>=0.115
|
|
|
25
25
|
Requires-Dist: httpx>=0.27
|
|
26
26
|
Requires-Dist: pytest-asyncio>=0.24
|
|
27
27
|
Requires-Dist: pytest>=8.0
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.34
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.34
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.34
|
|
31
31
|
Requires-Dist: sqlalchemy>=2.0
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
33
33
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_test"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.34"
|
|
4
4
|
description = "Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -25,9 +25,9 @@ dependencies = [
|
|
|
25
25
|
"httpx>=0.27",
|
|
26
26
|
"pytest>=8.0",
|
|
27
27
|
"pytest-asyncio>=0.24",
|
|
28
|
-
"simple_module_core==0.0.
|
|
29
|
-
"simple_module_db==0.0.
|
|
30
|
-
"simple_module_hosting==0.0.
|
|
28
|
+
"simple_module_core==0.0.34",
|
|
29
|
+
"simple_module_db==0.0.34",
|
|
30
|
+
"simple_module_hosting==0.0.34",
|
|
31
31
|
"sqlalchemy>=2.0",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Schema construction for the plugin's app fixtures.
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`simple_module_test.fixtures` so that module declares
|
|
4
|
+
fixtures and this one holds the machinery underneath them: importing every
|
|
5
|
+
installed module's models, resolving the migration heads, creating the tables
|
|
6
|
+
and stamping ``alembic_version`` so the boot-time migration check passes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import importlib
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from simple_module_core.discovery import discover_modules
|
|
16
|
+
from simple_module_db.base import all_module_bases
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=1)
|
|
20
|
+
def _ensure_models_imported() -> list:
|
|
21
|
+
"""Import all module models so all_module_bases is populated (cached)."""
|
|
22
|
+
for mod in discover_modules():
|
|
23
|
+
pkg = type(mod).__module__.split(".")[0]
|
|
24
|
+
with contextlib.suppress(ModuleNotFoundError):
|
|
25
|
+
importlib.import_module(f"{pkg}.models")
|
|
26
|
+
return list(all_module_bases)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def _alembic_heads() -> tuple[str, ...]:
|
|
31
|
+
"""Cached head revisions — cannot change within a pytest run.
|
|
32
|
+
|
|
33
|
+
Plural: each module's first migration sets its own ``branch_labels``, so
|
|
34
|
+
the history has one head per module and a real upgraded database carries
|
|
35
|
+
an ``alembic_version`` row for each. Stamping only one leaves the rest
|
|
36
|
+
looking un-applied, which now reads as a behind-head schema and puts every
|
|
37
|
+
test app behind the setup gate.
|
|
38
|
+
"""
|
|
39
|
+
from simple_module_hosting.migrations import resolve_head_revisions
|
|
40
|
+
|
|
41
|
+
return resolve_head_revisions()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _create_all_tables(engine) -> None:
|
|
45
|
+
"""Create all module tables in a single connection.
|
|
46
|
+
|
|
47
|
+
Also stamps the alembic_version table at heads so the app's startup
|
|
48
|
+
migration check (``check_migrations``) treats the test DB as current.
|
|
49
|
+
Without the stamp the check would raise because ``create_all`` doesn't
|
|
50
|
+
touch alembic_version.
|
|
51
|
+
"""
|
|
52
|
+
from sqlalchemy import text
|
|
53
|
+
|
|
54
|
+
bases = _ensure_models_imported()
|
|
55
|
+
heads = _alembic_heads()
|
|
56
|
+
|
|
57
|
+
async with engine.begin() as conn:
|
|
58
|
+
|
|
59
|
+
def _sync_create_all(sync_conn):
|
|
60
|
+
for base in bases:
|
|
61
|
+
base.metadata.create_all(sync_conn)
|
|
62
|
+
|
|
63
|
+
await conn.run_sync(_sync_create_all)
|
|
64
|
+
|
|
65
|
+
if heads:
|
|
66
|
+
await conn.execute(
|
|
67
|
+
text(
|
|
68
|
+
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
69
|
+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
await conn.execute(text("DELETE FROM alembic_version"))
|
|
73
|
+
for head in heads:
|
|
74
|
+
await conn.execute(
|
|
75
|
+
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
|
|
76
|
+
{"v": head},
|
|
77
|
+
)
|
|
@@ -20,20 +20,17 @@ which soft-imports ``background_tasks``).
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
import contextlib
|
|
24
|
-
import importlib
|
|
25
23
|
import os
|
|
26
24
|
from collections.abc import AsyncGenerator, Iterator
|
|
27
|
-
from functools import lru_cache
|
|
28
25
|
|
|
29
26
|
import httpx
|
|
30
27
|
import pytest
|
|
31
|
-
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
|
|
32
|
-
from simple_module_db.base import all_module_bases
|
|
28
|
+
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
|
|
33
29
|
from simple_module_db.session import DatabaseState, init_db
|
|
34
30
|
from simple_module_hosting.settings import Settings
|
|
35
31
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
36
32
|
|
|
33
|
+
from simple_module_test._schema import _create_all_tables
|
|
37
34
|
from simple_module_test.session_cookie import forge_session_cookie
|
|
38
35
|
|
|
39
36
|
_AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER"
|
|
@@ -107,76 +104,56 @@ async def engine(db_state: DatabaseState) -> AsyncEngine:
|
|
|
107
104
|
return db_state.engine
|
|
108
105
|
|
|
109
106
|
|
|
110
|
-
@
|
|
111
|
-
def
|
|
112
|
-
"""
|
|
113
|
-
|
|
114
|
-
pkg = type(mod).__module__.split(".")[0]
|
|
115
|
-
with contextlib.suppress(ModuleNotFoundError):
|
|
116
|
-
importlib.import_module(f"{pkg}.models")
|
|
117
|
-
return list(all_module_bases)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
@lru_cache(maxsize=1)
|
|
121
|
-
def _alembic_head() -> str | None:
|
|
122
|
-
"""Cached head revision — cannot change within a pytest run."""
|
|
123
|
-
from simple_module_hosting.migrations import resolve_head_revision
|
|
124
|
-
|
|
125
|
-
return resolve_head_revision()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
async def _create_all_tables(engine) -> None:
|
|
129
|
-
"""Create all module tables in a single connection.
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
|
|
109
|
+
"""Yield an async session backed by in-memory SQLite."""
|
|
110
|
+
await _create_all_tables(db_state.engine)
|
|
130
111
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Without the stamp the check would raise because ``create_all`` doesn't
|
|
134
|
-
touch alembic_version.
|
|
135
|
-
"""
|
|
136
|
-
from sqlalchemy import text
|
|
112
|
+
async with db_state.session_factory() as session:
|
|
113
|
+
yield session
|
|
137
114
|
|
|
138
|
-
bases = _ensure_models_imported()
|
|
139
|
-
head = _alembic_head()
|
|
140
115
|
|
|
141
|
-
|
|
116
|
+
SETUP_ADMIN_EMAIL = "admin@test"
|
|
117
|
+
SETUP_ADMIN_PASSWORD = "test-password"
|
|
142
118
|
|
|
143
|
-
def _sync_create_all(sync_conn):
|
|
144
|
-
for base in bases:
|
|
145
|
-
base.metadata.create_all(sync_conn)
|
|
146
119
|
|
|
147
|
-
|
|
120
|
+
async def _seed_setup_admin(application) -> None:
|
|
121
|
+
"""Make the ``app`` fixture model a *configured* install.
|
|
148
122
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
154
|
-
)
|
|
155
|
-
)
|
|
156
|
-
await conn.execute(text("DELETE FROM alembic_version"))
|
|
157
|
-
await conn.execute(
|
|
158
|
-
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
|
|
159
|
-
{"v": head},
|
|
160
|
-
)
|
|
123
|
+
``SetupMiddleware`` redirects every request to ``/setup`` until an
|
|
124
|
+
administrator exists. That is right for a fresh deployment and wrong for a
|
|
125
|
+
suite asserting on ordinary pages — without this, every test using ``app``
|
|
126
|
+
gets a 302.
|
|
161
127
|
|
|
128
|
+
Seeds the same admin ``authenticated_client`` uses, so that fixture's own
|
|
129
|
+
``create_admin`` call stays idempotent rather than creating a second one.
|
|
162
130
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
131
|
+
Skipped when the ``users`` module isn't installed: an install with no
|
|
132
|
+
local-accounts provider registers no setup step and is never gated, so
|
|
133
|
+
there is nothing to satisfy. Use ``setup_pending_app`` to exercise the gate.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
from users.bootstrap import create_admin
|
|
137
|
+
except ImportError:
|
|
138
|
+
return
|
|
167
139
|
|
|
168
|
-
async with
|
|
169
|
-
|
|
140
|
+
async with application.state.sm.db.session_factory() as session:
|
|
141
|
+
await create_admin(
|
|
142
|
+
session,
|
|
143
|
+
email=SETUP_ADMIN_EMAIL,
|
|
144
|
+
password=SETUP_ADMIN_PASSWORD,
|
|
145
|
+
full_name="Test Admin",
|
|
146
|
+
)
|
|
170
147
|
|
|
171
148
|
|
|
172
|
-
|
|
173
|
-
async def app(settings: Settings):
|
|
174
|
-
"""Create a FastAPI app with tables pre-created and lifespan triggered."""
|
|
149
|
+
async def _build_app(settings: Settings, *, seed_admin: bool):
|
|
175
150
|
from simple_module_hosting.app_builder import create_app
|
|
176
151
|
|
|
177
152
|
application = create_app(settings)
|
|
178
153
|
|
|
179
154
|
await _create_all_tables(application.state.sm.db.engine)
|
|
155
|
+
if seed_admin:
|
|
156
|
+
await _seed_setup_admin(application)
|
|
180
157
|
|
|
181
158
|
# Trigger lifespan startup so app.state.migration is populated
|
|
182
159
|
ctx = application.router.lifespan_context(application)
|
|
@@ -188,6 +165,49 @@ async def app(settings: Settings):
|
|
|
188
165
|
await ctx.__aexit__(None, None, None)
|
|
189
166
|
|
|
190
167
|
|
|
168
|
+
@pytest.fixture
|
|
169
|
+
async def app(settings: Settings):
|
|
170
|
+
"""A configured app: tables created, an admin seeded, lifespan triggered."""
|
|
171
|
+
async for application in _build_app(settings, seed_admin=True):
|
|
172
|
+
yield application
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _disable_env_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
176
|
+
"""Stop ``bootstrap_admin_from_env`` seeding the app being built.
|
|
177
|
+
|
|
178
|
+
``UsersModule.on_startup`` creates an administrator from
|
|
179
|
+
``SM_USERS_BOOTSTRAP_*``, read from the environment *and* from a ``.env`` on
|
|
180
|
+
disk. A developer who followed ``.env.example`` has those set, so every app
|
|
181
|
+
this plugin builds gets an admin whether the fixture asked for one or not.
|
|
182
|
+
For ``setup_pending_app`` that is not a nuisance but the opposite of its
|
|
183
|
+
contract: the setup gate releases the moment an administrator exists, so the
|
|
184
|
+
wizard routes 404 and every test using it fails.
|
|
185
|
+
|
|
186
|
+
They fail *locally only* — CI has no ``.env`` — which is the worst shape for
|
|
187
|
+
a fixture to be wrong in, and left the failure looking like test-ordering
|
|
188
|
+
noise rather than a fixture that does not do what it says.
|
|
189
|
+
"""
|
|
190
|
+
for key in list(os.environ):
|
|
191
|
+
if key.startswith("SM_USERS_BOOTSTRAP_"):
|
|
192
|
+
monkeypatch.delenv(key, raising=False)
|
|
193
|
+
try:
|
|
194
|
+
from users import bootstrap as bootstrap_module
|
|
195
|
+
except ImportError:
|
|
196
|
+
return # No local-accounts provider installed; nothing bootstraps.
|
|
197
|
+
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.fixture
|
|
201
|
+
async def setup_pending_app(settings: Settings, monkeypatch: pytest.MonkeyPatch):
|
|
202
|
+
"""An app with no administrator, so the first-run setup gate is engaged.
|
|
203
|
+
|
|
204
|
+
The counterpart to ``app``: use this to assert on setup-mode behaviour.
|
|
205
|
+
"""
|
|
206
|
+
_disable_env_bootstrap(monkeypatch)
|
|
207
|
+
async for application in _build_app(settings, seed_admin=False):
|
|
208
|
+
yield application
|
|
209
|
+
|
|
210
|
+
|
|
191
211
|
@pytest.fixture
|
|
192
212
|
async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
193
213
|
"""Unauthenticated async HTTP client."""
|
|
@@ -211,10 +231,12 @@ async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
|
211
231
|
from users.bootstrap import create_admin
|
|
212
232
|
|
|
213
233
|
async with app.state.sm.db.session_factory() as session:
|
|
234
|
+
# Idempotent: the `app` fixture already seeded this same admin so the
|
|
235
|
+
# setup gate would release. This resolves its id for the cookie.
|
|
214
236
|
result = await create_admin(
|
|
215
237
|
session,
|
|
216
|
-
email=
|
|
217
|
-
password=
|
|
238
|
+
email=SETUP_ADMIN_EMAIL,
|
|
239
|
+
password=SETUP_ADMIN_PASSWORD,
|
|
218
240
|
full_name="Test Admin",
|
|
219
241
|
)
|
|
220
242
|
user_id = str(result.user.id)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_test-0.0.32 → simple_module_test-0.0.34}/simple_module_test/session_cookie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|