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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ make migrate
make dev
```

Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home, `/products` is a fully-working example module.
Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home.

## Create a new module

Expand Down Expand Up @@ -73,7 +73,7 @@ framework/
core/ # module system, discovery, events, diagnostics
db/ # per-module Base, session, mixins, listeners
hosting/ # app_builder, middleware, settings, Inertia glue
modules/ # plugin modules (auth, dashboard, products, ...)
modules/ # plugin modules (auth, dashboard, users, settings, ...)
host/
main.py # FastAPI entry point
routes.py # host-level routes (landing page)
Expand Down
55 changes: 5 additions & 50 deletions docs/e2e-testing.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
# End-to-End Testing

The repo ships Playwright-driven smoke tests at
[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Four tests drive a real
Chromium browser through the core flows:

* **`test_login_and_browse_smoke`** — landing → local email+password login →
dashboard → products browse → logout. Minimal regression guard.
* **`test_products_crud_smoke`** — same login + a full create / edit / delete
round-trip against the products module.
* **`test_password_reset_smoke`** — **skipped** (see inline comment in the
test file). `fastapi-users` `reset_password()` validates a password
fingerprint (`password_fgpt`) that is only available server-side. The
full HTTP-layer flow is covered by unit tests in
`modules/users/tests/test_api_auth.py`.
* **`test_admin_invite_smoke`** — admin invites a new user via the UI; the
invitee accepts the invite in a fresh browser context and lands on the
dashboard. Token is minted locally using the dev-default verify secret
(equivalent to what the ConsoleMailer logs).
Playwright-driven smoke tests live in [tests/e2e/](../tests/e2e/) — currently
just [`test_settings_ui.py`](../tests/e2e/test_settings_ui.py), which logs in,
navigates to `/settings/modules`, toggles a module setting, and verifies the
change hot-reloads into `app.state` without a server restart.

End-to-end tests are gated behind the `e2e` pytest marker (declared in
[pyproject.toml](../pyproject.toml)) and are **excluded from the default
Expand Down Expand Up @@ -62,7 +49,7 @@ uv run pytest -m e2e tests/e2e

## Configuration

The tests read these environment variables (all optional):
When you write e2e tests, read these environment variables (all optional):

| Variable | Default | Notes |
| -------------- | ------------------------- | ------------------------------------------------------------ |
Expand All @@ -71,38 +58,6 @@ The tests read these environment variables (all optional):
| `E2E_PASSWORD` | `admin` | Password of the above admin user. |
| `SM_USERS_VERIFICATION_TOKEN_SECRET` | `dev-verify-token-secret-change-me` | Must match the running server's value so locally-minted invite tokens are accepted. |

## What the smoke tests cover

**`test_login_and_browse_smoke`**

1. Landing page renders (`/`) with the "Get Started" CTA.
2. Local email+password login via `/users/login`.
3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware +
Inertia resolver + AuthenticatedLayout.
4. Products browse (`/products/`) renders — proves module pages resolve.
5. Logout returns the user to the public landing page.

**`test_products_crud_smoke`**

1. Login as admin.
2. Create a timestamped product via the Create form.
3. Edit its name and verify the new name appears in the list.
4. Delete it through the confirm dialog and verify the row disappears.

The CRUD test relies on the admin user having the `admin` role (created
automatically by `sm-users create-admin` or the bootstrap env vars).

**`test_admin_invite_smoke`**

1. Admin logs in and submits the invite form at `/users/admin/invite`.
2. The test looks up the new user's UUID via the admin API.
3. A verify token is minted locally (same secret the server uses).
4. A fresh browser context navigates to `/users/invite/accept?token=…`, sets
a password, and verifies a redirect to `/dashboard`.

These are **not** pixel-perfect regression tests — the goal is to catch broad
breakage in the auth + render + CRUD spine.

## Debugging

To see what the browser is doing, run headed with the Playwright trace
Expand Down
2 changes: 0 additions & 2 deletions docs/guide/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ simple_module_python/
│ ├── auth/ # session cookie, CSRF defences
│ ├── background_tasks/ # Celery broker + worker integration
│ ├── dashboard/ # authenticated landing page
│ ├── datasets/ # CSV / dataset uploads
│ ├── feature_flags/ # admin UI for flag toggles
│ ├── file_storage/ # pluggable storage backends (local, S3)
│ ├── permissions/ # role/permission admin UI
│ ├── products/ # reference CRUD module (used in examples)
│ ├── settings/ # DB-backed module settings + admin UI
│ └── users/ # email+password auth, invites, bootstrap
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The API and Vite dev servers start side by side. Visit:

- `http://localhost:8000` — landing page
- `http://localhost:8000/users/login` — sign-in screen
- `http://localhost:8000/products` — a fully-working example module (CRUD on a `products` table)
- `http://localhost:8000/dashboard` — the authenticated home (log in first)
- `http://localhost:8000/settings/modules` — the admin settings UI (log in first)

## 4. Create an admin
Expand Down
4 changes: 0 additions & 4 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,9 @@ simple_module_test
simple_module_auth
simple_module_background_tasks
simple_module_dashboard
simple_module_datasets
simple_module_feature_flags
simple_module_file_storage
simple_module_permissions
simple_module_products
simple_module_settings
simple_module_users
```
Expand Down Expand Up @@ -223,11 +221,9 @@ Trusted Publishing is tied to the GitHub repo, not any personal account — so a
| PyPI | `simple_module_auth` | [modules/auth/](../modules/auth/) |
| PyPI | `simple_module_background_tasks` | [modules/background_tasks/](../modules/background_tasks/) |
| PyPI | `simple_module_dashboard` | [modules/dashboard/](../modules/dashboard/) |
| PyPI | `simple_module_datasets` | [modules/datasets/](../modules/datasets/) |
| PyPI | `simple_module_feature_flags` | [modules/feature_flags/](../modules/feature_flags/) |
| PyPI | `simple_module_file_storage` | [modules/file_storage/](../modules/file_storage/) |
| PyPI | `simple_module_permissions` | [modules/permissions/](../modules/permissions/) |
| PyPI | `simple_module_products` | [modules/products/](../modules/products/) — reference CRUD example |
| PyPI | `simple_module_settings` | [modules/settings/](../modules/settings/) |
| PyPI | `simple_module_users` | [modules/users/](../modules/users/) |
| npm | `@simple-module-py/ui` | [packages/ui/](../packages/ui/) |
Expand Down
18 changes: 2 additions & 16 deletions framework/cli/simple_module_cli/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@ class ModuleEntry:
"Permissions",
requires=("auth", "users"),
),
"products": ModuleEntry("products", "simple_module_products", "Products"),
"dashboard": ModuleEntry(
"dashboard",
"simple_module_dashboard",
"Dashboard",
requires=("users", "products"),
requires=("users",),
),
"settings": ModuleEntry("settings", "simple_module_settings", "Settings"),
"feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"),
Expand All @@ -58,26 +57,13 @@ class ModuleEntry:
requires=("users",),
recipe="background_tasks",
),
"datasets": ModuleEntry(
"datasets",
"simple_module_datasets",
"Datasets",
requires=("file_storage", "background_tasks"),
),
}


# Example modules — `datasets` and `products` are intentionally excluded
# from every default preset because their module names collide with custom
# modules users typically want to register themselves. Pass them via
# `--with datasets,products` (or pick the `examples` preset) to opt in.
_EXAMPLE_MODULES: frozenset[str] = frozenset({"datasets", "products"})

PRESETS: dict[str, tuple[str, ...]] = {
"minimal": ("users",),
"standard": ("users", "dashboard", "permissions"),
"full": tuple(name for name in CATALOG if name not in _EXAMPLE_MODULES),
"examples": tuple(CATALOG),
"full": tuple(CATALOG),
}


Expand Down
2 changes: 1 addition & 1 deletion framework/cli/simple_module_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def create_host(
str,
typer.Option(
"--with",
help="Comma-separated module names to declare as deps (e.g. Auth,Products).",
help="Comma-separated module names to declare as deps (e.g. Auth,Dashboard).",
),
] = "",
) -> None:
Expand Down
1 change: 0 additions & 1 deletion framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ class Preset(StrEnum):
minimal = "minimal"
standard = "standard"
full = "full"
examples = "examples"


def new_project(
Expand Down
13 changes: 3 additions & 10 deletions framework/cli/tests/test_cli_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,10 @@ def test_expand_deps_pulls_in_transitive_dep() -> None:


def test_expand_deps_pulls_in_chain() -> None:
resolved, added = expand_deps(["datasets"])
assert set(resolved) == {
"datasets",
"file_storage",
"settings",
"background_tasks",
"users",
"auth",
}
resolved, added = expand_deps(["permissions"])
assert set(resolved) == {"permissions", "users", "auth"}
added_names = {a for a, _ in added}
assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"}
assert added_names == {"users", "auth"}


def test_expand_deps_idempotent_when_input_already_complete() -> None:
Expand Down
3 changes: 1 addition & 2 deletions framework/cli/tests/test_cli_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,11 @@ def test_wizard_minimal_preset() -> None:
def test_wizard_full_preset_includes_background_tasks() -> None:
_, _, selected, _ = _drive(["", "", "3", ""])
assert "background_tasks" in selected
assert "datasets" not in selected
assert len(selected) >= 7


def test_wizard_custom_picks_only_yes_answers() -> None:
answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""]
answers = ["", "", "4"] + ["n"] * 7 + ["y", ""]
_, _, selected, out = _drive(answers)
assert set(selected) == {"background_tasks", "users", "auth"}
assert "Added users (required by background_tasks)" in out
Expand Down
20 changes: 10 additions & 10 deletions framework/cli/tests/test_scaffolding_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ async def test_compute_returns_existing_page_dirs(self):
modules = discover_modules()
result = compute_module_pages(modules)

# Products + Dashboard ship pages/; Auth is API-only (no frontend pages).
assert {"Products", "Dashboard"}.issubset(result.keys())
# Dashboard ships pages/; Auth is API-only (no frontend pages).
assert "Dashboard" in result
assert "Auth" not in result
for name, path in result.items():
assert path.is_dir(), f"{name} -> {path} should exist"
Expand Down Expand Up @@ -53,12 +53,12 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path):
assert written == {"manifest": manifest, "generated": generated, "css": css}

data = json.loads(manifest.read_text(encoding="utf-8"))
assert "Products" in data
assert data["Products"].endswith("pages") or data["Products"].endswith("pages/")
assert "Dashboard" in data
assert data["Dashboard"].endswith("pages") or data["Dashboard"].endswith("pages/")

ts = generated.read_text(encoding="utf-8")
assert "import.meta.glob" in ts
assert "Products" in ts
assert "Dashboard" in ts
assert "AUTO-GENERATED" in ts or "auto-generated" in ts.lower()
# Glob patterns must be relative to output_dir — Vite treats
# leading-slash paths as project-root-relative and silently matches
Expand All @@ -78,7 +78,7 @@ async def test_creates_expected_backend_files(self, tmp_path):
from simple_module_cli.scaffolding import create_host

dest = tmp_path / "demo"
create_host(dest, name="demo-host", modules=["Products", "Auth"])
create_host(dest, name="demo-host", modules=["Dashboard", "Auth"])

for relpath in [
"pyproject.toml",
Expand Down Expand Up @@ -126,9 +126,9 @@ async def test_declares_selected_module_deps(self, tmp_path):
from simple_module_cli.scaffolding import create_host

dest = tmp_path / "demo"
create_host(dest, name="demo", modules=["Products", "Auth"])
create_host(dest, name="demo", modules=["Dashboard", "Auth"])
pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8")
assert "simple_module_products" in pyproject
assert "simple_module_dashboard" in pyproject
assert "simple_module_auth" in pyproject

async def test_refuses_existing_non_empty_dir(self, tmp_path):
Expand Down Expand Up @@ -161,11 +161,11 @@ async def test_cli_create_host_runs_end_to_end(self, tmp_path):
runner = CliRunner()
result = runner.invoke(
app,
["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Products"],
["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Dashboard"],
)
assert result.exit_code == 0, result.output
assert (tmp_path / "out" / "main.py").is_file()
assert (tmp_path / "out" / "pyproject.toml").is_file()
assert "simple_module_products" in (tmp_path / "out" / "pyproject.toml").read_text(
assert "simple_module_dashboard" in (tmp_path / "out" / "pyproject.toml").read_text(
encoding="utf-8"
)
10 changes: 5 additions & 5 deletions framework/core/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ async def test_discover_finds_installed_modules(self):
"""discover_modules() should find modules registered via entry_points."""
modules = discover_modules()
names = [m.meta.name for m in modules]
assert "Products" in names
assert "Auth" in names
assert "Dashboard" in names
assert "Users" in names


class TestDiscoverModulesAdvanced:
Expand Down Expand Up @@ -221,7 +221,7 @@ async def test_discover_with_none_loads_all(self):
"""Passing enabled=None keeps existing behaviour (load all installed modules)."""
all_mods = discover_modules(enabled=None)
names = {m.meta.name for m in all_mods}
assert {"Auth", "Products", "Dashboard"}.issubset(names)
assert {"Auth", "Users", "Dashboard"}.issubset(names)

async def test_discover_with_allowlist_filters(self):
"""Passing enabled=['Auth'] loads only Auth, even if other modules are installed."""
Expand All @@ -234,9 +234,9 @@ async def test_discover_with_empty_list_loads_none(self):
assert discover_modules(enabled=[]) == []

async def test_discover_allowlist_case_insensitive(self):
"""Allowlist matching ignores case so 'products' and 'Products' both work."""
names = [m.meta.name for m in discover_modules(enabled=["products"])]
assert names == ["Products"]
"""Allowlist matching ignores case so 'dashboard' and 'Dashboard' both work."""
names = [m.meta.name for m in discover_modules(enabled=["dashboard"])]
assert names == ["Dashboard"]

async def test_discover_unknown_name_logged_and_ignored(self, caplog):
"""Names in enabled that don't match any installed module log a warning but don't raise."""
Expand Down
46 changes: 0 additions & 46 deletions framework/db/tests/test_db_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@

import contextlib
import logging
from decimal import Decimal
from unittest.mock import MagicMock

from _models import _TenantBase, _TenantItem
from simple_module_db.deps import get_db
from sqlalchemy.ext.asyncio import AsyncSession


async def _drive_get_db(db_state, populate=None):
Expand Down Expand Up @@ -82,47 +80,3 @@ async def test_read_only_skips_commit(self, db_state, caplog):
read_only = [r for r in records if r.message == "db.session.read_only"]
assert len(read_only) == 1
assert read_only[0].operation == "read_only_rollback" # type: ignore[attr-defined]


class TestEntityListenerLogging:
async def test_create_logs_entity_created(self, db_session: AsyncSession, caplog):
"""Inserting a new entity should log db.entity.created."""
from products.models import Product

with caplog.at_level(logging.INFO, logger="simple_module.db"):
product = Product(name="Widget", price=Decimal("9.99"))
db_session.add(product)
await db_session.flush()

created_msgs = [
r
for r in caplog.records
if r.name == "simple_module.db" and r.message == "db.entity.created"
]
assert len(created_msgs) == 1
assert created_msgs[0].entity == "Product" # type: ignore[attr-defined]
assert created_msgs[0].operation == "create" # type: ignore[attr-defined]

async def test_update_logs_entity_updated(self, db_session: AsyncSession, caplog):
"""Modifying an entity should log db.entity.updated."""
from products.models import Product

product = Product(name="Widget", price=Decimal("9.99"))
db_session.add(product)
await db_session.flush()

caplog.clear()

product.name = "Updated Widget"
with caplog.at_level(logging.INFO, logger="simple_module.db"):
await db_session.flush()

updated_msgs = [
r
for r in caplog.records
if r.name == "simple_module.db" and r.message == "db.entity.updated"
]
assert len(updated_msgs) == 1
assert updated_msgs[0].entity == "Product" # type: ignore[attr-defined]
assert updated_msgs[0].operation == "update" # type: ignore[attr-defined]
assert updated_msgs[0].entity_id is not None # type: ignore[attr-defined]
Loading
Loading