simple-module-test 0.0.17__tar.gz → 0.0.19__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.4
2
2
  Name: simple_module_test
3
- Version: 0.0.17
3
+ Version: 0.0.19
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.17
29
- Requires-Dist: simple-module-db==0.0.17
30
- Requires-Dist: simple-module-hosting==0.0.17
28
+ Requires-Dist: simple-module-core==0.0.19
29
+ Requires-Dist: simple-module-db==0.0.19
30
+ Requires-Dist: simple-module-hosting==0.0.19
31
31
  Requires-Dist: sqlalchemy>=2.0
32
32
  Description-Content-Type: text/markdown
33
33
 
@@ -47,11 +47,15 @@ pip install "simple_module_hosting[dev]"
47
47
 
48
48
  ## What it provides
49
49
 
50
+ All fixtures below are auto-registered via the `pytest11` entry point — installing the package is enough.
51
+
52
+ - `fake_event_bus` — a fresh recording `EventBus` per test that captures every `publish`/`publish_nowait` call.
53
+ - `build_test_app` — callable returning a minimal FastAPI app that loads exactly one module.
50
54
  - `settings` — a ready-to-use `Settings` instance with an in-memory SQLite database and multi-tenancy enabled.
51
55
  - `db_state`, `engine`, `db_session` — fresh `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes.
52
56
  - `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
53
57
  - `client` — an `httpx.AsyncClient` bound to the test app.
54
- - `authenticated_client` — same but with an admin user seeded and a forged session cookie attached.
58
+ - `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. **Requires the `users` module** to be installed (it seeds the admin via `users.bootstrap`); apps scaffolded by `smpy` include it.
55
59
 
56
60
  ## Usage
57
61
 
@@ -14,11 +14,15 @@ pip install "simple_module_hosting[dev]"
14
14
 
15
15
  ## What it provides
16
16
 
17
+ All fixtures below are auto-registered via the `pytest11` entry point — installing the package is enough.
18
+
19
+ - `fake_event_bus` — a fresh recording `EventBus` per test that captures every `publish`/`publish_nowait` call.
20
+ - `build_test_app` — callable returning a minimal FastAPI app that loads exactly one module.
17
21
  - `settings` — a ready-to-use `Settings` instance with an in-memory SQLite database and multi-tenancy enabled.
18
22
  - `db_state`, `engine`, `db_session` — fresh `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes.
19
23
  - `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
20
24
  - `client` — an `httpx.AsyncClient` bound to the test app.
21
- - `authenticated_client` — same but with an admin user seeded and a forged session cookie attached.
25
+ - `authenticated_client` — same but with an admin user seeded and a forged session cookie attached. **Requires the `users` module** to be installed (it seeds the admin via `users.bootstrap`); apps scaffolded by `smpy` include it.
22
26
 
23
27
  ## Usage
24
28
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_test"
3
- version = "0.0.17"
3
+ version = "0.0.19"
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.17",
29
- "simple_module_db==0.0.17",
30
- "simple_module_hosting==0.0.17",
28
+ "simple_module_core==0.0.19",
29
+ "simple_module_db==0.0.19",
30
+ "simple_module_hosting==0.0.19",
31
31
  "sqlalchemy>=2.0",
32
32
  ]
33
33
 
@@ -12,9 +12,12 @@ Primary exports:
12
12
  * :func:`build_test_app` — return a minimal FastAPI app loading exactly
13
13
  one module, with an in-memory SQLite DB.
14
14
 
15
- The corresponding pytest fixtures are ``fake_event_bus``, ``test_app``,
16
- ``test_client``, and ``test_db_session``. See ``simple_module_test.plugin``
17
- for their definitions.
15
+ The pytest plugin auto-registers these fixtures (no imports needed):
16
+ ``fake_event_bus``, ``build_test_app``, ``settings``, ``db_state``,
17
+ ``engine``, ``db_session``, ``app``, ``client``, and ``authenticated_client``.
18
+ See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for
19
+ their definitions. ``authenticated_client`` additionally requires the ``users``
20
+ module to be installed — it seeds an admin via ``users.bootstrap``.
18
21
  """
19
22
 
20
23
  from simple_module_test.app_factory import build_test_app
@@ -0,0 +1,197 @@
1
+ """Headline app/db/client fixtures shipped by the pytest plugin.
2
+
3
+ These are the fixtures the README advertises — ``settings``, ``db_state``,
4
+ ``engine``, ``db_session``, ``app``, ``client``, ``authenticated_client`` —
5
+ made available to any test run in an environment that installs
6
+ ``simple_module_test`` (the ``pytest11`` entry point re-exports them from
7
+ :mod:`simple_module_test.plugin`). They build a real ``create_app`` against an
8
+ in-memory SQLite DB with every installed module's tables created and the
9
+ ``alembic_version`` row stamped at head, so the boot-time migration check
10
+ passes.
11
+
12
+ Module-scope imports here reference only framework packages
13
+ (``simple_module_core`` / ``simple_module_db`` / ``simple_module_hosting``),
14
+ which are this package's declared dependencies. The one plugin-module coupling
15
+ — ``authenticated_client`` seeding an admin via ``users.bootstrap`` — is a lazy
16
+ import inside the fixture body, so importing this module never requires the
17
+ ``users`` module to be installed (mirroring ``plugin._bootstrap_eager_celery``,
18
+ which soft-imports ``background_tasks``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import importlib
25
+ from collections.abc import AsyncGenerator
26
+ from functools import lru_cache
27
+
28
+ import httpx
29
+ import pytest
30
+ from simple_module_core.discovery import discover_modules
31
+ from simple_module_db.base import all_module_bases
32
+ from simple_module_db.session import DatabaseState, init_db
33
+ from simple_module_hosting.settings import Settings
34
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
35
+
36
+ from simple_module_test.session_cookie import forge_session_cookie
37
+
38
+
39
+ @pytest.fixture
40
+ def settings() -> Settings:
41
+ """Settings configured for testing with in-memory SQLite.
42
+
43
+ Multi-tenancy stays on so the existing ``TenantMiddleware`` tests
44
+ (and the ``X-Tenant-ID`` header paths they rely on) keep working.
45
+ Individual tests that want the tenant middleware absent construct
46
+ their own ``Settings(multi_tenant=False, ...)`` in the test body.
47
+ """
48
+ return Settings(
49
+ database_url="sqlite+aiosqlite:///:memory:",
50
+ environment="testing",
51
+ secret_key="test-secret-key",
52
+ multi_tenant=True,
53
+ tenant_header="X-Tenant-ID",
54
+ )
55
+
56
+
57
+ @pytest.fixture
58
+ async def db_state() -> AsyncGenerator[DatabaseState, None]:
59
+ """Create a fresh in-memory DatabaseState with listeners registered."""
60
+ from simple_module_db.listeners import register_listeners
61
+
62
+ state = init_db("sqlite+aiosqlite:///:memory:")
63
+ register_listeners(state)
64
+ yield state
65
+ await state.engine.dispose()
66
+
67
+
68
+ @pytest.fixture
69
+ async def engine(db_state: DatabaseState) -> AsyncEngine:
70
+ """Return the engine from the test DatabaseState."""
71
+ return db_state.engine
72
+
73
+
74
+ @lru_cache(maxsize=1)
75
+ def _ensure_models_imported() -> list:
76
+ """Import all module models so all_module_bases is populated (cached)."""
77
+ for mod in discover_modules():
78
+ pkg = type(mod).__module__.split(".")[0]
79
+ with contextlib.suppress(ModuleNotFoundError):
80
+ importlib.import_module(f"{pkg}.models")
81
+ return list(all_module_bases)
82
+
83
+
84
+ @lru_cache(maxsize=1)
85
+ def _alembic_head() -> str | None:
86
+ """Cached head revision — cannot change within a pytest run."""
87
+ from simple_module_hosting.migrations import resolve_head_revision
88
+
89
+ return resolve_head_revision()
90
+
91
+
92
+ async def _create_all_tables(engine) -> None:
93
+ """Create all module tables in a single connection.
94
+
95
+ Also stamps the alembic_version table at head so the app's startup
96
+ migration check (``check_migrations``) treats the test DB as current.
97
+ Without the stamp the check would raise because ``create_all`` doesn't
98
+ touch alembic_version.
99
+ """
100
+ from sqlalchemy import text
101
+
102
+ bases = _ensure_models_imported()
103
+ head = _alembic_head()
104
+
105
+ async with engine.begin() as conn:
106
+
107
+ def _sync_create_all(sync_conn):
108
+ for base in bases:
109
+ base.metadata.create_all(sync_conn)
110
+
111
+ await conn.run_sync(_sync_create_all)
112
+
113
+ if head:
114
+ await conn.execute(
115
+ text(
116
+ "CREATE TABLE IF NOT EXISTS alembic_version "
117
+ "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
118
+ )
119
+ )
120
+ await conn.execute(text("DELETE FROM alembic_version"))
121
+ await conn.execute(
122
+ text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
123
+ {"v": head},
124
+ )
125
+
126
+
127
+ @pytest.fixture
128
+ async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
129
+ """Yield an async session backed by in-memory SQLite."""
130
+ await _create_all_tables(db_state.engine)
131
+
132
+ async with db_state.session_factory() as session:
133
+ yield session
134
+
135
+
136
+ @pytest.fixture
137
+ async def app(settings: Settings):
138
+ """Create a FastAPI app with tables pre-created and lifespan triggered."""
139
+ from simple_module_hosting.app_builder import create_app
140
+
141
+ application = create_app(settings)
142
+
143
+ await _create_all_tables(application.state.sm.db.engine)
144
+
145
+ # Trigger lifespan startup so app.state.migration is populated
146
+ ctx = application.router.lifespan_context(application)
147
+ await ctx.__aenter__()
148
+
149
+ yield application
150
+
151
+ # Lifespan shutdown disposes the engine
152
+ await ctx.__aexit__(None, None, None)
153
+
154
+
155
+ @pytest.fixture
156
+ async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
157
+ """Unauthenticated async HTTP client."""
158
+ transport = httpx.ASGITransport(app=app)
159
+ async with httpx.AsyncClient(
160
+ transport=transport,
161
+ base_url="http://testserver",
162
+ ) as c:
163
+ yield c
164
+
165
+
166
+ @pytest.fixture
167
+ async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
168
+ """HTTPX client with a signed session cookie carrying a seeded admin user's id.
169
+
170
+ Requires the ``users`` module to be installed — it seeds the admin via
171
+ ``users.bootstrap.create_admin``. The import is deferred to here (not module
172
+ scope) so this plugin imports cleanly without ``users``; a consumer app that
173
+ uses this fixture without ``users`` gets a clear ImportError naming it.
174
+ """
175
+ from users.bootstrap import create_admin
176
+
177
+ async with app.state.sm.db.session_factory() as session:
178
+ result = await create_admin(
179
+ session,
180
+ email="admin@test",
181
+ password="test-password",
182
+ full_name="Test Admin",
183
+ )
184
+ user_id = str(result.user.id)
185
+
186
+ signed = forge_session_cookie(
187
+ app.state.sm.settings.secret_key,
188
+ {"user_id": user_id},
189
+ )
190
+
191
+ transport = httpx.ASGITransport(app=app)
192
+ async with httpx.AsyncClient(
193
+ transport=transport,
194
+ base_url="http://testserver",
195
+ cookies={"session": signed},
196
+ ) as c:
197
+ yield c
@@ -15,6 +15,21 @@ import pytest
15
15
  from simple_module_test.app_factory import build_test_app as _build_test_app
16
16
  from simple_module_test.fake_events import FakeEventBus
17
17
 
18
+ # Re-export the headline app/db/client fixtures the README advertises. pytest
19
+ # collects fixture-decorated attributes of the plugin module, so importing them
20
+ # here registers them for every test run that installs simple_module_test —
21
+ # without a per-consumer conftest. Treat this surface like a public API: deleting
22
+ # one breaks external modules' suites. See GH #200.
23
+ from simple_module_test.fixtures import ( # noqa: F401
24
+ app,
25
+ authenticated_client,
26
+ client,
27
+ db_session,
28
+ db_state,
29
+ engine,
30
+ settings,
31
+ )
32
+
18
33
 
19
34
  def _bootstrap_eager_celery() -> None:
20
35
  """Register a process-wide eager Celery app before any test runs.
@@ -0,0 +1,62 @@
1
+ """GH #200: the published ``simple_module_test`` plugin must register the
2
+ headline fixtures its README/``__init__`` advertise — not just ``fake_event_bus``
3
+ + ``build_test_app``. Before the fix those fixtures lived only in the framework
4
+ repo's root ``conftest.py``, so a consumer that installed the plugin (but wasn't
5
+ the framework repo) got "fixture not found".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # The fixtures the plugin promises. Treat this set as the public fixture
11
+ # surface — dropping one breaks external modules' suites.
12
+ _HEADLINE_FIXTURES = (
13
+ "fake_event_bus",
14
+ "build_test_app",
15
+ "settings",
16
+ "db_state",
17
+ "engine",
18
+ "db_session",
19
+ "app",
20
+ "client",
21
+ "authenticated_client",
22
+ )
23
+
24
+
25
+ def _is_pytest_fixture(obj) -> bool:
26
+ """True if ``obj`` was produced by ``@pytest.fixture`` (version-robust).
27
+
28
+ pytest >=8.4 returns a ``FixtureFunctionDefinition`` instance; older
29
+ versions left the function in place with a ``_pytestfixturefunction``
30
+ marker attribute. Cover both so this test doesn't pin a pytest version.
31
+ """
32
+ return hasattr(obj, "_pytestfixturefunction") or (
33
+ type(obj).__name__ == "FixtureFunctionDefinition"
34
+ )
35
+
36
+
37
+ def test_plugin_module_exports_every_headline_fixture():
38
+ """Each advertised fixture is a real ``@pytest.fixture`` on the plugin module."""
39
+ import simple_module_test.plugin as plugin
40
+
41
+ for name in _HEADLINE_FIXTURES:
42
+ obj = getattr(plugin, name, None)
43
+ assert obj is not None, f"{name!r} is not exported by simple_module_test.plugin"
44
+ # A plain function imported by mistake would not satisfy this.
45
+ assert _is_pytest_fixture(obj), f"{name!r} is not a pytest fixture"
46
+
47
+
48
+ def test_plugin_is_registered_via_pytest11_entry_point(pytestconfig):
49
+ """pytest discovered the plugin through its ``pytest11`` entry point, so a
50
+ consumer gets the fixtures by installing the package alone."""
51
+ assert pytestconfig.pluginmanager.hasplugin("simple_module_test")
52
+
53
+
54
+ async def test_db_session_fixture_injects_without_a_local_conftest(db_session):
55
+ """``db_session`` resolves in this test dir, which has no conftest defining
56
+ it — it can only come from the installed plugin (root conftest no longer
57
+ provides it). Proves the moved fixture is genuinely shipped, not just
58
+ re-exported on paper."""
59
+ from sqlalchemy import text
60
+
61
+ result = await db_session.execute(text("SELECT 1"))
62
+ assert result.scalar_one() == 1