Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Meaningful codes when reading `make doctor` output: `SM001` missing meta (error)

## Tests & fixtures

Root `conftest.py` provides app-level fixtures available to every test directory:
The `simple_module_test` plugin provides app-level fixtures available to every test directory — auto-loaded via its `pytest11` entry point (defined in `framework/testing/simple_module_test/fixtures.py`), so the root `conftest.py` is intentionally thin:
- `settings` — in-memory SQLite `Settings` with `multi_tenant=True`.
- `db_state`, `engine`, `db_session` — fresh in-memory `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes.
- `app` — `create_app(settings)` with lifespan started/stopped.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,6 @@ Historical, point-in-time design docs live under [`docs/plans/`](docs/plans/) an

## Contributing

- Write tests with the fixtures in `conftest.py` (`db_session`, `authenticated_client`).
- Write tests with the fixtures from the `simple_module_test` plugin (`db_session`, `authenticated_client`).
- Lint with `make lint` before pushing; CI runs all four checks in parallel.
- Stick to the conventions in `docs/framework-conventions.md` — they're what diagnostics enforce.
184 changes: 12 additions & 172 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,175 +1,15 @@
"""Root conftest — shared fixtures available to all test directories."""
"""Root conftest — intentionally thin.

from __future__ import annotations

import contextlib
import importlib
from collections.abc import AsyncGenerator
from functools import lru_cache

import httpx
import pytest
from simple_module_core.discovery import discover_modules
from simple_module_db.base import all_module_bases
from simple_module_db.session import DatabaseState, init_db
from simple_module_hosting.settings import Settings
from simple_module_test import forge_session_cookie
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
)


@pytest.fixture
def settings() -> Settings:
"""Settings configured for testing with in-memory SQLite.

Multi-tenancy stays on so the existing ``TenantMiddleware`` tests
(and the ``X-Tenant-ID`` header paths they rely on) keep working.
Individual tests that want the tenant middleware absent construct
their own ``Settings(multi_tenant=False, ...)`` in the test body.
"""
return Settings(
database_url="sqlite+aiosqlite:///:memory:",
environment="testing",
secret_key="test-secret-key",
multi_tenant=True,
tenant_header="X-Tenant-ID",
)


@pytest.fixture
async def db_state() -> AsyncGenerator[DatabaseState, None]:
"""Create a fresh in-memory DatabaseState with listeners registered."""
from simple_module_db.listeners import register_listeners

state = init_db("sqlite+aiosqlite:///:memory:")
register_listeners(state)
yield state
await state.engine.dispose()


@pytest.fixture
async def engine(db_state: DatabaseState) -> AsyncEngine:
"""Return the engine from the test DatabaseState."""
return db_state.engine


@lru_cache(maxsize=1)
def _ensure_models_imported() -> list:
"""Import all module models so all_module_bases is populated (cached)."""
for mod in discover_modules():
pkg = type(mod).__module__.split(".")[0]
with contextlib.suppress(ModuleNotFoundError):
importlib.import_module(f"{pkg}.models")
return list(all_module_bases)


@lru_cache(maxsize=1)
def _alembic_head() -> str | None:
"""Cached head revision — cannot change within a pytest run."""
from simple_module_hosting.migrations import resolve_head_revision

return resolve_head_revision()


async def _create_all_tables(engine) -> None:
"""Create all module tables in a single connection.

Also stamps the alembic_version table at head so the app's startup
migration check (``check_migrations``) treats the test DB as current.
Without the stamp the check would raise because ``create_all`` doesn't
touch alembic_version.
"""
from sqlalchemy import text

bases = _ensure_models_imported()
head = _alembic_head()
The shared app/db/client fixtures (``settings``, ``db_state``, ``engine``,
``db_session``, ``app``, ``client``, ``authenticated_client``) live in the
``simple_module_test`` package and are auto-registered via its ``pytest11``
entry point — installing the package is enough, no conftest import needed. They
used to be duplicated here; they were moved so the *published* plugin actually
ships what its README advertises (GH #200), and this repo now dogfoods that
plugin like any consumer would.

async with engine.begin() as conn:
Add genuinely repo-local fixtures here if the need arises; shared ones belong in
``framework/testing/simple_module_test/fixtures.py``.
"""

def _sync_create_all(sync_conn):
for base in bases:
base.metadata.create_all(sync_conn)

await conn.run_sync(_sync_create_all)

if head:
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS alembic_version "
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
)
)
await conn.execute(text("DELETE FROM alembic_version"))
await conn.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
{"v": head},
)


@pytest.fixture
async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
"""Yield an async session backed by in-memory SQLite."""
await _create_all_tables(db_state.engine)

async with db_state.session_factory() as session:
yield session


@pytest.fixture
async def app(settings: Settings):
"""Create a FastAPI app with tables pre-created and lifespan triggered."""
from simple_module_hosting.app_builder import create_app

application = create_app(settings)

await _create_all_tables(application.state.sm.db.engine)

# Trigger lifespan startup so app.state.migration is populated
ctx = application.router.lifespan_context(application)
await ctx.__aenter__()

yield application

# Lifespan shutdown disposes the engine
await ctx.__aexit__(None, None, None)


@pytest.fixture
async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Unauthenticated async HTTP client."""
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as c:
yield c


@pytest.fixture
async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]:
"""HTTPX client with a signed session cookie carrying a seeded admin user's id."""
from users.bootstrap import create_admin

async with app.state.sm.db.session_factory() as session:
result = await create_admin(
session,
email="admin@test",
password="test-password",
full_name="Test Admin",
)
user_id = str(result.user.id)

signed = forge_session_cookie(
app.state.sm.settings.secret_key,
{"user_id": user_id},
)

transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": signed},
) as c:
yield c
from __future__ import annotations
2 changes: 1 addition & 1 deletion docs/database/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Cross-tenant admin work runs outside a tenant-scoped request (a CLI or worker wh

## Sessions in tests

The `db_session` fixture in `conftest.py` creates a fresh in-memory SQLite DB, creates every module's tables, stamps `alembic_version` at head, and yields an `AsyncSession`. Each test gets a fresh one — no shared state, no transaction rollback hacks.
The `db_session` fixture from the `simple_module_test` plugin creates a fresh in-memory SQLite DB, creates every module's tables, stamps `alembic_version` at head, and yields an `AsyncSession`. Each test gets a fresh one — no shared state, no transaction rollback hacks.

```python
@pytest.mark.asyncio
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async def test_place_order_publishes_event(db_session, app):
assert received[0].customer_email == "a@b.c"
```

The `app` fixture from `conftest.py` provides a fresh app with a fresh `EventBus` per test.
The `app` fixture from the `simple_module_test` plugin provides a fresh app with a fresh `EventBus` per test.

## Design guidelines

Expand Down
2 changes: 1 addition & 1 deletion docs/framework/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default function OrdersToolbar() {

## Testing with permissions

The `authenticated_client` fixture in `conftest.py` seeds an admin user (who has `*`). For tests that need a less-privileged user, build one from the `users` module fixtures or flip the principal temporarily:
The `authenticated_client` fixture from the `simple_module_test` plugin seeds an admin user (who has `*`). For tests that need a less-privileged user, build one from the `users` module fixtures or flip the principal temporarily:

```python
@pytest.mark.asyncio
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/first-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ async def test_create_and_list_orders(authenticated_client):
assert any(o["id"] == created["id"] for o in r.json())
```

The `authenticated_client` fixture from `conftest.py` seeds an admin user and carries a signed session cookie. The `db_session` fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See [Fixtures](/testing/fixtures).
The `authenticated_client` fixture from the `simple_module_test` plugin seeds an admin user and carries a signed session cookie. The `db_session` fixture creates all module tables and stamps the Alembic head so the boot-time migration check passes. See [Fixtures](/testing/fixtures).

Run:

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ features:
<a class="sm-card" href="/framework/overview"><h3>Framework</h3><p>Discovery, lifecycle hooks, middleware, permissions, events, i18n.</p></a>
<a class="sm-card" href="/database/models"><h3>Database</h3><p>SQLModel conventions, per-module Base, mixins, sessions, Alembic.</p></a>
<a class="sm-card" href="/frontend/inertia"><h3>Frontend</h3><p>Inertia page keys, shared props, page discovery, React layout.</p></a>
<a class="sm-card" href="/testing/overview"><h3>Testing</h3><p>The fixtures in <code>conftest.py</code>, unit tests, end-to-end tests.</p></a>
<a class="sm-card" href="/testing/overview"><h3>Testing</h3><p>The <code>simple_module_test</code> plugin fixtures, unit tests, end-to-end tests.</p></a>
<a class="sm-card" href="/modules/"><h3>Modules</h3><p>Reference for each bundled module: routes, contracts, settings.</p></a>
<a class="sm-card" href="/reference/make-commands"><h3>Reference</h3><p>CLI commands, env vars, diagnostic codes, deployment.</p></a>
</div>
Expand Down
5 changes: 5 additions & 0 deletions docs/module-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ The package registers pytest fixtures via a `pytest11` entry_point — no
|---|---|
| `build_test_app` | Callable `(ModuleCls) -> FastAPI` — wraps a single module in a minimal FastAPI app with its routes registered. |
| `fake_event_bus` | A `FakeEventBus` that records every `publish`/`publish_nowait` call so tests can assert emitted events. |
| `settings` | In-memory-SQLite `Settings` (`multi_tenant=True`) for the test app. |
| `db_state` / `engine` / `db_session` | Fresh in-memory `DatabaseState` per test; `db_session` creates every installed module's tables and stamps `alembic_version` at head. |
| `app` | A full `create_app(settings)` with lifespan started/stopped. |
| `client` | `httpx.AsyncClient` bound to the test app (anonymous). |
| `authenticated_client` | Same, with a seeded admin + signed session cookie. Requires the `users` module installed (seeds via `users.bootstrap`). |

Example test:

Expand Down
28 changes: 28 additions & 0 deletions docs/modules/background_tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ send_receipt.delay(order_id=42)

Every dispatch / start / completion / failure / retry / revoke is recorded in the `background_tasks_task_execution` table by Celery signal handlers, so you can see history even after the Celery result backend has expired the result.

## Scheduling periodic work (beat)

A module schedules recurring work the same way it registers tasks — by shipping a `tasks.py` — and additionally exporting a module-level `BEAT_SCHEDULE` dict. `build_celery` merges every installed module's `BEAT_SCHEDULE` into the beat schedule at boot (identically in the web and worker processes), so `make beat` runs them alongside the built-ins:

```python
# modules/invoices/invoices/tasks.py
from celery import shared_task
from celery.schedules import crontab

@shared_task(name="invoices.generate_recurring")
def generate_recurring() -> int:
...

# Discovered by build_celery and merged into the beat schedule.
BEAT_SCHEDULE = {
"invoices-generate-recurring-daily": {
"task": "invoices.generate_recurring",
"schedule": crontab(hour=6, minute=0), # 0 6 * * *
},
}
```

Entry values are plain Celery [beat entries](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#entries): `schedule` accepts a number of seconds, a `crontab(...)`, or a `solar(...)`. The two built-in entry names (`background-tasks-sweep-stuck`, `background-tasks-purge-old`) are authoritative — a module reusing one is ignored with a warning.

> **Don't reach for `from celery.signals import on_after_configure`.** It's an *app-instance* signal (not a member of `celery.signals`, so the import raises), and `build_celery` runs `conf.update(...)` — which fires it — *before* `autodiscover_tasks` imports your `tasks.py`, so a handler would miss the window. The declarative `BEAT_SCHEDULE` dict above is the supported mechanism. If you need entries computed at runtime, Celery's `@app.on_after_finalize.connect` + `sender.add_periodic_task(...)` also works.

## Running workers

| Command | Use case |
Expand Down Expand Up @@ -145,6 +171,8 @@ Bootstrap env-var equivalents (`SM_BG_TASKS_*`) only seed pydantic defaults at f

A `background_tasks.demo_echo` task is also registered for smoke tests; not scheduled.

Other modules contribute their own periodic entries via `tasks.BEAT_SCHEDULE` — see [Scheduling periodic work (beat)](#scheduling-periodic-work-beat).

## Events

Published (and consumable from any other module):
Expand Down
12 changes: 12 additions & 0 deletions docs/modules/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ Top-level keys in `dashboard/locales/en.json`:
- `home.system_info_title`, `home.system_info.python_version`, `home.system_info.health_checks`, `home.system_info.modules`
- `home.welcome_card_title`, `home.description_body`

## Extending it

The bundled dashboard renders a **fixed** set of cards — `dashboard/stats.py` returns a hardcoded shape and the module exposes only `register_routes` + `register_menu_items`. This is intentional: there is **no** `register_dashboard_cards` hook or card/widget registry, and the module deliberately keeps no extension surface so it stays a small, predictable landing template rather than a framework subsystem to maintain.

To show app-specific tiles, **build your own dashboard page in your module** rather than contributing to this one:

- Add an Inertia view route + page (`register_routes` → `pages/Home.tsx`) and a sidebar entry (`register_menu_items`), exactly like any other module page.
- Make it the post-login landing page by pointing `users.login_redirect_url` at your route (see [Replacing it](#replacing-it)). You can drop the bundled dashboard entirely via `SM_MODULES_ENABLED` minus `dashboard`.
- Your page can still reuse this module's data — call `GET /api/dashboard/stats` for the system overview alongside your own module's endpoints.

> Resolved as **by design** (GH #203): consumer modules build their own dashboard page; the bundled one is not a contribution point.

## Replacing it

If you want a different post-login landing page, set `users.login_redirect_url` in the [admin settings UI](/modules/settings) (or via `smpy settings import-from-env` from `SM_USERS_LOGIN_REDIRECT_URL`) to your route. You can keep the dashboard module installed for the menu entry, or set `SM_MODULES_ENABLED` without `dashboard` to drop it entirely. The `users` module auto-detects whether `dashboard` is installed; if not, it redirects to the first other module that exposes view routes (e.g. the GIS module on apps like smpy_gis), falling back to `/` only as a last resort.
4 changes: 3 additions & 1 deletion docs/testing/fixtures.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Fixtures

The root `conftest.py` provides app-level fixtures that every test directory inherits. Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up.
The `simple_module_test` plugin provides app-level fixtures that every test directory inherits — auto-loaded via its `pytest11` entry point, so installing the package is enough (no `conftest.py` import needed). Everything you need for a typical integration test — app, DB session, HTTP client, authenticated client — is already wired up.

## Available fixtures

Expand Down Expand Up @@ -90,6 +90,8 @@ Same as `client`, but the fixture also:
2. Forges a signed session cookie for that user.
3. Attaches the cookie to the client.

> Because it seeds via `users.bootstrap`, this fixture requires the `users` module to be installed (the import is deferred to the fixture body, so the rest of the plugin loads without it). Apps scaffolded by `smpy` include `users`.

```python
@pytest.mark.asyncio
async def test_create_order_as_admin(authenticated_client):
Expand Down
5 changes: 3 additions & 2 deletions docs/testing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ The root `pyproject.toml` sets `asyncio_mode = "auto"`. Async tests don't need `
## File layout

```text
conftest.py # root fixtures: app, db_session, client, authenticated_client
conftest.py # intentionally thin — shared fixtures ship in the simple_module_test plugin
framework/testing/ # simple_module_test plugin: app, db_session, client, authenticated_client fixtures
framework/<pkg>/tests/ # tests against each framework package (core, db, hosting, cli, testing)
host/tests/ # host-level tests
modules/<name>/tests/ # per-module pytest tests
Expand Down Expand Up @@ -93,5 +94,5 @@ Before marking a test flaky, check:

## Next steps

- [Fixtures](/testing/fixtures) — the shared fixtures in `conftest.py` and how to extend them.
- [Fixtures](/testing/fixtures) — the shared fixtures from the `simple_module_test` plugin and how to extend them.
- [E2E tests](/e2e-testing) — the Playwright suite.
12 changes: 11 additions & 1 deletion framework/cli/simple_module_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,17 @@ def create_host(
target = dest or Path.cwd() / name
selected = [m.strip() for m in modules.split(",") if m.strip()]
try:
_create_host(target, name=name, modules=selected)
# Pin the host's framework + module deps to the installed framework
# version so the generated host's first `uv sync` resolves (the
# template's >=1.0,<2.0 / >=0.1,<1.0 ranges don't exist pre-1.0). The
# workspace `smpy new` path rewrites these via _rewrite_pyproject, but
# standalone create-host never did — see GH #206.
_create_host(
target,
name=name,
modules=selected,
framework_version=resolve_framework_version(),
)
except FileExistsError as exc:
typer.echo(f"ERROR: {exc}", err=True)
raise typer.Exit(code=1) from exc
Expand Down
Loading
Loading