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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_test
3
- Version: 0.0.32
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.32
29
- Requires-Dist: simple-module-db==0.0.32
30
- Requires-Dist: simple-module-hosting==0.0.32
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.32"
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.32",
29
- "simple_module_db==0.0.32",
30
- "simple_module_hosting==0.0.32",
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, discover_modules
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
- @lru_cache(maxsize=1)
111
- def _ensure_models_imported() -> list:
112
- """Import all module models so all_module_bases is populated (cached)."""
113
- for mod in discover_modules():
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
- Also stamps the alembic_version table at head so the app's startup
132
- migration check (``check_migrations``) treats the test DB as current.
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
- async with engine.begin() as conn:
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
- await conn.run_sync(_sync_create_all)
120
+ async def _seed_setup_admin(application) -> None:
121
+ """Make the ``app`` fixture model a *configured* install.
148
122
 
149
- if head:
150
- await conn.execute(
151
- text(
152
- "CREATE TABLE IF NOT EXISTS alembic_version "
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
- @pytest.fixture
164
- async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
165
- """Yield an async session backed by in-memory SQLite."""
166
- await _create_all_tables(db_state.engine)
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 db_state.session_factory() as session:
169
- yield session
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
- @pytest.fixture
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="admin@test",
217
- password="test-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)
@@ -29,6 +29,7 @@ from simple_module_test.fixtures import ( # noqa: F401
29
29
  engine,
30
30
  pinned_auth_provider,
31
31
  settings,
32
+ setup_pending_app,
32
33
  )
33
34
 
34
35