simple-module-test 0.0.7__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.7/.gitignore +63 -0
- simple_module_test-0.0.7/LICENSE +21 -0
- simple_module_test-0.0.7/PKG-INFO +84 -0
- simple_module_test-0.0.7/README.md +51 -0
- simple_module_test-0.0.7/pyproject.toml +53 -0
- simple_module_test-0.0.7/simple_module_test/__init__.py +29 -0
- simple_module_test-0.0.7/simple_module_test/app_factory.py +23 -0
- simple_module_test-0.0.7/simple_module_test/fake_events.py +59 -0
- simple_module_test-0.0.7/simple_module_test/plugin.py +50 -0
- simple_module_test-0.0.7/simple_module_test/session_cookie.py +26 -0
- simple_module_test-0.0.7/tests/test_app_factory.py +88 -0
- simple_module_test-0.0.7/tests/test_fake_events.py +103 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
43
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
44
|
+
# or via `make gen-pages`).
|
|
45
|
+
host/client_app/modules.manifest.json
|
|
46
|
+
host/client_app/modules.generated.ts
|
|
47
|
+
host/client_app/modules.generated.css
|
|
48
|
+
|
|
49
|
+
# Worktrees
|
|
50
|
+
.worktrees/
|
|
51
|
+
|
|
52
|
+
# Performance profiles
|
|
53
|
+
.memray/
|
|
54
|
+
.benchmarks/
|
|
55
|
+
|
|
56
|
+
# OS
|
|
57
|
+
.DS_Store
|
|
58
|
+
Thumbs.db
|
|
59
|
+
|
|
60
|
+
.playwright-cli/*
|
|
61
|
+
.playwright-mcp/*
|
|
62
|
+
host/client_app/.playwright-cli/*
|
|
63
|
+
.superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_test
|
|
3
|
+
Version: 0.0.7
|
|
4
|
+
Summary: Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: fixtures,pytest,pytest-plugin,simple-module,testing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: fastapi>=0.115
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24
|
|
27
|
+
Requires-Dist: pytest>=8.0
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.7
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.7
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.7
|
|
31
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# simple_module_test
|
|
35
|
+
|
|
36
|
+
Shared pytest fixtures and helpers for writing tests against [simple_module](https://github.com/antosubash/simple_module_python) apps and modules.
|
|
37
|
+
|
|
38
|
+
Fixtures are exposed via a `pytest11` entry point, so installing the package is enough — no `conftest.py` import needed.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install simple_module_test
|
|
44
|
+
# or, if you already pulled in the framework:
|
|
45
|
+
pip install "simple_module_hosting[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## What it provides
|
|
49
|
+
|
|
50
|
+
- `settings` — a ready-to-use `Settings` instance with an in-memory SQLite database and multi-tenancy enabled.
|
|
51
|
+
- `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
|
+
- `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
|
|
53
|
+
- `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.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
In a module's `tests/test_something.py`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import pytest
|
|
62
|
+
|
|
63
|
+
pytestmark = pytest.mark.asyncio
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_create_order(authenticated_client):
|
|
67
|
+
resp = await authenticated_client.post(
|
|
68
|
+
"/api/orders",
|
|
69
|
+
json={"customer_id": 1, "total_cents": 9900},
|
|
70
|
+
)
|
|
71
|
+
assert resp.status_code == 201
|
|
72
|
+
assert resp.json()["total_cents"] == 9900
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
No fixture imports, no `conftest.py` — the `pytest11` entry point auto-loads them.
|
|
76
|
+
|
|
77
|
+
## Depends on
|
|
78
|
+
|
|
79
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`
|
|
80
|
+
- `pytest`, `pytest-asyncio`, `httpx`, `sqlalchemy`
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# simple_module_test
|
|
2
|
+
|
|
3
|
+
Shared pytest fixtures and helpers for writing tests against [simple_module](https://github.com/antosubash/simple_module_python) apps and modules.
|
|
4
|
+
|
|
5
|
+
Fixtures are exposed via a `pytest11` entry point, so installing the package is enough — no `conftest.py` import needed.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install simple_module_test
|
|
11
|
+
# or, if you already pulled in the framework:
|
|
12
|
+
pip install "simple_module_hosting[dev]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it provides
|
|
16
|
+
|
|
17
|
+
- `settings` — a ready-to-use `Settings` instance with an in-memory SQLite database and multi-tenancy enabled.
|
|
18
|
+
- `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
|
+
- `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
|
|
20
|
+
- `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.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
In a module's `tests/test_something.py`:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
pytestmark = pytest.mark.asyncio
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def test_create_order(authenticated_client):
|
|
34
|
+
resp = await authenticated_client.post(
|
|
35
|
+
"/api/orders",
|
|
36
|
+
json={"customer_id": 1, "total_cents": 9900},
|
|
37
|
+
)
|
|
38
|
+
assert resp.status_code == 201
|
|
39
|
+
assert resp.json()["total_cents"] == 9900
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
No fixture imports, no `conftest.py` — the `pytest11` entry point auto-loads them.
|
|
43
|
+
|
|
44
|
+
## Depends on
|
|
45
|
+
|
|
46
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`
|
|
47
|
+
- `pytest`, `pytest-asyncio`, `httpx`, `sqlalchemy`
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_test"
|
|
3
|
+
version = "0.0.7"
|
|
4
|
+
description = "Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "pytest", "pytest-plugin", "fixtures", "testing"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Framework :: Pytest",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"fastapi>=0.115",
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"pytest>=8.0",
|
|
27
|
+
"pytest-asyncio>=0.24",
|
|
28
|
+
"simple_module_core==0.0.7",
|
|
29
|
+
"simple_module_db==0.0.7",
|
|
30
|
+
"simple_module_hosting==0.0.7",
|
|
31
|
+
"sqlalchemy>=2.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.entry-points.pytest11]
|
|
35
|
+
simple_module_test = "simple_module_test.plugin"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
39
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
40
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
41
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["hatchling"]
|
|
45
|
+
build-backend = "hatchling.build"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["simple_module_test"]
|
|
49
|
+
|
|
50
|
+
[tool.uv.sources]
|
|
51
|
+
simple_module_core = { workspace = true }
|
|
52
|
+
simple_module_db = { workspace = true }
|
|
53
|
+
simple_module_hosting = { workspace = true }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared pytest fixtures and helpers for SimpleModule module authors.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install simple_module_test[dev]`` (or add it to
|
|
4
|
+
``[project.optional-dependencies].dev`` of your module). The pytest plugin
|
|
5
|
+
registers its fixtures automatically via the ``pytest11`` entry_point — no
|
|
6
|
+
imports needed in your test files.
|
|
7
|
+
|
|
8
|
+
Primary exports:
|
|
9
|
+
|
|
10
|
+
* :class:`FakeEventBus` — records every ``publish``/``publish_nowait`` call
|
|
11
|
+
so tests can assert on emitted events without wiring real subscribers.
|
|
12
|
+
* :func:`build_test_app` — return a minimal FastAPI app loading exactly
|
|
13
|
+
one module, with an in-memory SQLite DB.
|
|
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.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from simple_module_test.app_factory import build_test_app
|
|
21
|
+
from simple_module_test.fake_events import FakeEventBus, RecordedEvent
|
|
22
|
+
from simple_module_test.session_cookie import forge_session_cookie
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FakeEventBus",
|
|
26
|
+
"RecordedEvent",
|
|
27
|
+
"build_test_app",
|
|
28
|
+
"forge_session_cookie",
|
|
29
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Build a minimal FastAPI app wrapping a single module for isolated testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from simple_module_core import ModuleBase
|
|
7
|
+
from simple_module_hosting.app_builder import wire_module_routes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_test_app(module: ModuleBase | type[ModuleBase]) -> FastAPI:
|
|
11
|
+
"""Return a FastAPI app that registers only the given module's routes.
|
|
12
|
+
|
|
13
|
+
Accepts either a module class (instantiated lazily) or a pre-built
|
|
14
|
+
instance. The resulting app has the module's registered routes and
|
|
15
|
+
empty framework registries so tests don't need the full
|
|
16
|
+
:func:`simple_module_hosting.create_app` machinery.
|
|
17
|
+
"""
|
|
18
|
+
instance = module() if isinstance(module, type) else module
|
|
19
|
+
|
|
20
|
+
app = FastAPI()
|
|
21
|
+
app.state.module = instance
|
|
22
|
+
wire_module_routes(app, instance)
|
|
23
|
+
return app
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""A recording EventBus for tests.
|
|
2
|
+
|
|
3
|
+
Subclasses the real :class:`simple_module_core.EventBus` so any handlers
|
|
4
|
+
subscribed during a test still run — ``FakeEventBus`` just *additionally*
|
|
5
|
+
records every publish for later assertion. This keeps the surface area
|
|
6
|
+
test-behaves-like-real-behaves small: there's no chance of a test passing
|
|
7
|
+
against the fake but failing against the real bus.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from simple_module_core import Event, EventBus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RecordedEvent[E: Event]:
|
|
19
|
+
"""An event observed by the :class:`FakeEventBus` in test order.
|
|
20
|
+
|
|
21
|
+
Wrapping the raw event lets us extend this later (e.g. capture timestamp,
|
|
22
|
+
source module, or stack frame) without changing the ``bus.events`` shape.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
event: E
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeEventBus(EventBus):
|
|
29
|
+
"""An EventBus subclass that records every published event."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.events: list[RecordedEvent] = []
|
|
34
|
+
|
|
35
|
+
async def publish(self, event: Event) -> None: # type: ignore[override]
|
|
36
|
+
self.events.append(RecordedEvent(event=event))
|
|
37
|
+
return await super().publish(event)
|
|
38
|
+
|
|
39
|
+
def publish_nowait(self, event: Event) -> None:
|
|
40
|
+
self.events.append(RecordedEvent(event=event))
|
|
41
|
+
super().publish_nowait(event)
|
|
42
|
+
|
|
43
|
+
# ── Assertion helpers ──────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
"""Discard every recorded event (for resetting state between phases)."""
|
|
47
|
+
self.events.clear()
|
|
48
|
+
|
|
49
|
+
def find_by_type[E: Event](self, event_type: type[E]) -> list[E]:
|
|
50
|
+
"""Return every recorded event whose type is exactly ``event_type``."""
|
|
51
|
+
return [r.event for r in self.events if type(r.event) is event_type]
|
|
52
|
+
|
|
53
|
+
def assert_published[E: Event](self, event_type: type[E]) -> list[E]:
|
|
54
|
+
"""Assert at least one event of ``event_type`` was published and return them."""
|
|
55
|
+
matches = self.find_by_type(event_type)
|
|
56
|
+
if not matches:
|
|
57
|
+
seen = ", ".join(sorted({type(r.event).__name__ for r in self.events})) or "<nothing>"
|
|
58
|
+
raise AssertionError(f"Expected at least one {event_type.__name__} event; saw: {seen}")
|
|
59
|
+
return matches
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pytest plugin — registered via the ``pytest11`` entry_point in pyproject.toml.
|
|
2
|
+
|
|
3
|
+
Fixtures declared here are available to every test run in any environment
|
|
4
|
+
that has ``simple_module_test`` installed, without a ``conftest.py``
|
|
5
|
+
import. Delete a fixture from this file and you break external modules'
|
|
6
|
+
test suites — treat the fixture surface like a public API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from simple_module_test.app_factory import build_test_app as _build_test_app
|
|
16
|
+
from simple_module_test.fake_events import FakeEventBus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bootstrap_eager_celery() -> None:
|
|
20
|
+
"""Register a process-wide eager Celery app before any test runs.
|
|
21
|
+
|
|
22
|
+
Tests that don't use the ``client`` fixture never trigger the lifespan,
|
|
23
|
+
so the host's ``build_celery`` call never runs and ``task.delay()``
|
|
24
|
+
falls through to the broker. Skipped silently when ``background_tasks``
|
|
25
|
+
isn't installed.
|
|
26
|
+
"""
|
|
27
|
+
with contextlib.suppress(ImportError):
|
|
28
|
+
from background_tasks.celery_app import build_celery
|
|
29
|
+
from background_tasks.settings import BackgroundTasksSettings
|
|
30
|
+
|
|
31
|
+
build_celery(BackgroundTasksSettings(task_always_eager=True, task_eager_propagates=True))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_bootstrap_eager_celery()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def fake_event_bus() -> FakeEventBus:
|
|
39
|
+
"""Fresh recording EventBus for each test."""
|
|
40
|
+
return FakeEventBus()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def build_test_app():
|
|
45
|
+
"""Callable that wraps a module class in a minimal FastAPI app.
|
|
46
|
+
|
|
47
|
+
Exposed as a fixture (not a direct import) so tests can compose it with
|
|
48
|
+
other fixtures via pytest's dependency resolution.
|
|
49
|
+
"""
|
|
50
|
+
return _build_test_app
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Helpers for forging signed Starlette session cookies in tests.
|
|
2
|
+
|
|
3
|
+
Starlette's ``SessionMiddleware`` encodes sessions as base64-encoded JSON
|
|
4
|
+
signed with an ``itsdangerous`` ``TimestampSigner``. Tests that want to
|
|
5
|
+
skip the HTTP flow (login / locale-switcher / etc.) to establish a session
|
|
6
|
+
can call :func:`forge_session_cookie` to build the exact cookie value the
|
|
7
|
+
middleware would emit, then inject it via ``httpx.AsyncClient(cookies=...)``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from base64 import b64encode
|
|
14
|
+
|
|
15
|
+
from itsdangerous import TimestampSigner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def forge_session_cookie(secret_key: str, session_data: dict) -> str:
|
|
19
|
+
"""Return the signed cookie value Starlette's SessionMiddleware would emit.
|
|
20
|
+
|
|
21
|
+
The encoding (``b64(json)`` signed with ``TimestampSigner``) must match
|
|
22
|
+
Starlette exactly — otherwise the middleware rejects the cookie and the
|
|
23
|
+
test request arrives with an empty session.
|
|
24
|
+
"""
|
|
25
|
+
data = b64encode(json.dumps(session_data).encode())
|
|
26
|
+
return TimestampSigner(str(secret_key)).sign(data).decode("utf-8")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Tests for build_test_app + the bundled pytest fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, FastAPI
|
|
8
|
+
from simple_module_core import Event, ModuleBase, ModuleMeta
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _PingSent(Event):
|
|
13
|
+
target: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _EchoModule(ModuleBase):
|
|
17
|
+
"""A toy module used to exercise build_test_app — defines a single GET /ping route."""
|
|
18
|
+
|
|
19
|
+
meta = ModuleMeta(
|
|
20
|
+
name="Echo",
|
|
21
|
+
route_prefix="/api/echo",
|
|
22
|
+
view_prefix="/echo",
|
|
23
|
+
requires_framework=">=1.0,<2.0",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
27
|
+
@api_router.get("/ping")
|
|
28
|
+
async def ping() -> dict:
|
|
29
|
+
return {"pong": True}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestBuildTestApp:
|
|
33
|
+
async def test_returns_fastapi_instance_from_class(self):
|
|
34
|
+
"""Passing a ModuleBase subclass instantiates it and returns a FastAPI app."""
|
|
35
|
+
from simple_module_test import build_test_app
|
|
36
|
+
|
|
37
|
+
app = build_test_app(_EchoModule)
|
|
38
|
+
assert isinstance(app, FastAPI)
|
|
39
|
+
|
|
40
|
+
async def test_returns_fastapi_instance_from_instance(self):
|
|
41
|
+
"""Accepts an already-instantiated module too."""
|
|
42
|
+
from simple_module_test import build_test_app
|
|
43
|
+
|
|
44
|
+
app = build_test_app(_EchoModule())
|
|
45
|
+
assert isinstance(app, FastAPI)
|
|
46
|
+
|
|
47
|
+
async def test_registers_module_routes_with_prefix(self):
|
|
48
|
+
"""The module's register_routes() runs and its api routes appear under the prefix."""
|
|
49
|
+
from simple_module_test import build_test_app
|
|
50
|
+
|
|
51
|
+
app = build_test_app(_EchoModule)
|
|
52
|
+
paths = {getattr(r, "path", None) for r in app.routes}
|
|
53
|
+
assert "/api/echo/ping" in paths
|
|
54
|
+
|
|
55
|
+
async def test_module_accessible_on_app_state(self):
|
|
56
|
+
"""The instance is stored on app.state.module so tests can poke at it."""
|
|
57
|
+
from simple_module_test import build_test_app
|
|
58
|
+
|
|
59
|
+
app = build_test_app(_EchoModule)
|
|
60
|
+
assert isinstance(app.state.module, _EchoModule)
|
|
61
|
+
|
|
62
|
+
async def test_isolates_from_other_installed_modules(self):
|
|
63
|
+
"""build_test_app only mounts the given module — Products/Auth routes are absent."""
|
|
64
|
+
from simple_module_test import build_test_app
|
|
65
|
+
|
|
66
|
+
app = build_test_app(_EchoModule)
|
|
67
|
+
paths = {getattr(r, "path", None) for r in app.routes}
|
|
68
|
+
assert not any(p and p.startswith("/api/products") for p in paths)
|
|
69
|
+
assert not any(p and p.startswith("/auth") for p in paths)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── pytest plugin fixtures ──────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestPluginFixtures:
|
|
76
|
+
async def test_fake_event_bus_fixture_is_fresh_each_test_part1(self, fake_event_bus):
|
|
77
|
+
"""First of a pair: publish an event and verify recording."""
|
|
78
|
+
await fake_event_bus.publish(_PingSent(target="first"))
|
|
79
|
+
assert len(fake_event_bus.events) == 1
|
|
80
|
+
|
|
81
|
+
async def test_fake_event_bus_fixture_is_fresh_each_test_part2(self, fake_event_bus):
|
|
82
|
+
"""Second of the pair: if the fixture leaked state, we'd see the previous event."""
|
|
83
|
+
assert fake_event_bus.events == []
|
|
84
|
+
|
|
85
|
+
async def test_build_test_app_fixture_returns_callable(self, build_test_app):
|
|
86
|
+
"""The fixture exposes the helper as a callable, composable with other fixtures."""
|
|
87
|
+
app = build_test_app(_EchoModule)
|
|
88
|
+
assert isinstance(app, FastAPI)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Tests for FakeEventBus — the event bus test double."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from simple_module_core import Event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class OrderCreated(Event):
|
|
13
|
+
order_id: int = 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OrderShipped(Event):
|
|
18
|
+
order_id: int = 0
|
|
19
|
+
tracking: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestFakeEventBus:
|
|
23
|
+
async def test_records_publish(self):
|
|
24
|
+
"""publish() records the event for later assertion."""
|
|
25
|
+
from simple_module_test import FakeEventBus
|
|
26
|
+
|
|
27
|
+
bus = FakeEventBus()
|
|
28
|
+
await bus.publish(OrderCreated(order_id=42))
|
|
29
|
+
|
|
30
|
+
assert len(bus.events) == 1
|
|
31
|
+
assert bus.events[0].event.order_id == 42
|
|
32
|
+
assert type(bus.events[0].event) is OrderCreated
|
|
33
|
+
|
|
34
|
+
async def test_records_publish_nowait(self):
|
|
35
|
+
"""publish_nowait() also records without awaiting."""
|
|
36
|
+
from simple_module_test import FakeEventBus
|
|
37
|
+
|
|
38
|
+
bus = FakeEventBus()
|
|
39
|
+
bus.publish_nowait(OrderCreated(order_id=7))
|
|
40
|
+
assert len(bus.events) == 1
|
|
41
|
+
|
|
42
|
+
async def test_find_by_type(self):
|
|
43
|
+
"""find_by_type filters the recorded log to a single event class."""
|
|
44
|
+
from simple_module_test import FakeEventBus
|
|
45
|
+
|
|
46
|
+
bus = FakeEventBus()
|
|
47
|
+
await bus.publish(OrderCreated(order_id=1))
|
|
48
|
+
await bus.publish(OrderShipped(order_id=1, tracking="ABC"))
|
|
49
|
+
await bus.publish(OrderCreated(order_id=2))
|
|
50
|
+
|
|
51
|
+
created = bus.find_by_type(OrderCreated)
|
|
52
|
+
shipped = bus.find_by_type(OrderShipped)
|
|
53
|
+
assert len(created) == 2
|
|
54
|
+
assert len(shipped) == 1
|
|
55
|
+
assert shipped[0].tracking == "ABC"
|
|
56
|
+
|
|
57
|
+
async def test_clear_resets_log(self):
|
|
58
|
+
"""clear() empties the recorded events so test phases can isolate state."""
|
|
59
|
+
from simple_module_test import FakeEventBus
|
|
60
|
+
|
|
61
|
+
bus = FakeEventBus()
|
|
62
|
+
await bus.publish(OrderCreated())
|
|
63
|
+
bus.clear()
|
|
64
|
+
assert bus.events == []
|
|
65
|
+
|
|
66
|
+
async def test_subscribed_handlers_still_fire(self):
|
|
67
|
+
"""FakeEventBus is a real EventBus — subscribers still run; recording is additive."""
|
|
68
|
+
from simple_module_test import FakeEventBus
|
|
69
|
+
|
|
70
|
+
bus = FakeEventBus()
|
|
71
|
+
received: list[int] = []
|
|
72
|
+
|
|
73
|
+
async def handler(event: OrderCreated) -> None:
|
|
74
|
+
received.append(event.order_id)
|
|
75
|
+
|
|
76
|
+
bus.subscribe(OrderCreated, handler)
|
|
77
|
+
await bus.publish(OrderCreated(order_id=99))
|
|
78
|
+
|
|
79
|
+
# Handler fired AND event recorded.
|
|
80
|
+
assert received == [99]
|
|
81
|
+
assert len(bus.events) == 1
|
|
82
|
+
|
|
83
|
+
async def test_assert_published_raises_when_missing(self):
|
|
84
|
+
"""assert_published raises AssertionError when no matching event was recorded."""
|
|
85
|
+
from simple_module_test import FakeEventBus
|
|
86
|
+
|
|
87
|
+
bus = FakeEventBus()
|
|
88
|
+
await bus.publish(OrderCreated(order_id=1))
|
|
89
|
+
|
|
90
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
91
|
+
bus.assert_published(OrderShipped)
|
|
92
|
+
assert "OrderShipped" in str(exc_info.value)
|
|
93
|
+
|
|
94
|
+
async def test_assert_published_succeeds_when_present(self):
|
|
95
|
+
"""assert_published returns the matching events when at least one is found."""
|
|
96
|
+
from simple_module_test import FakeEventBus
|
|
97
|
+
|
|
98
|
+
bus = FakeEventBus()
|
|
99
|
+
await bus.publish(OrderCreated(order_id=1))
|
|
100
|
+
await bus.publish(OrderCreated(order_id=2))
|
|
101
|
+
|
|
102
|
+
matches = bus.assert_published(OrderCreated)
|
|
103
|
+
assert len(matches) == 2
|