simple-module-test 0.0.35__tar.gz → 0.0.36__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.
Files changed (20) hide show
  1. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/PKG-INFO +5 -4
  2. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/README.md +1 -0
  3. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/pyproject.toml +4 -4
  4. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/__init__.py +5 -0
  5. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/plugin.py +1 -0
  6. simple_module_test-0.0.36/simple_module_test/setup_wizard.py +37 -0
  7. simple_module_test-0.0.36/simple_module_test/tenant_client.py +125 -0
  8. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/.gitignore +0 -0
  9. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/LICENSE +0 -0
  10. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/_schema.py +0 -0
  11. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/app_factory.py +0 -0
  12. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/database.py +0 -0
  13. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/fake_events.py +0 -0
  14. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/fixtures.py +0 -0
  15. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/redis_server.py +0 -0
  16. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/routes.py +0 -0
  17. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/simple_module_test/session_cookie.py +0 -0
  18. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/tests/test_app_factory.py +0 -0
  19. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/tests/test_fake_events.py +0 -0
  20. {simple_module_test-0.0.35 → simple_module_test-0.0.36}/tests/test_plugin_fixtures.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_test
3
- Version: 0.0.35
3
+ Version: 0.0.36
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.35
29
- Requires-Dist: simple-module-db==0.0.35
30
- Requires-Dist: simple-module-hosting==0.0.35
28
+ Requires-Dist: simple-module-core==0.0.36
29
+ Requires-Dist: simple-module-db==0.0.36
30
+ Requires-Dist: simple-module-hosting==0.0.36
31
31
  Requires-Dist: sqlalchemy>=2.0
32
32
  Description-Content-Type: text/markdown
33
33
 
@@ -56,6 +56,7 @@ All fixtures below are auto-registered via the `pytest11` entry point — instal
56
56
  - `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
57
57
  - `client` — an `httpx.AsyncClient` bound to the test app.
58
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.
59
+ - `tenant_client` — factory: `async with tenant_client("member") as (client, tenant_id, user_id)` signs in a fresh user who is an `owner` / `admin` / `member` (default `owner`) of a new tenant — or of `tenant_id=` when given — with that tenant active. **Requires the `users` and `tenants` modules.**
59
60
 
60
61
  ## Usage
61
62
 
@@ -23,6 +23,7 @@ All fixtures below are auto-registered via the `pytest11` entry point — instal
23
23
  - `app` — a `create_app(settings)` instance with `lifespan` started and stopped.
24
24
  - `client` — an `httpx.AsyncClient` bound to the test app.
25
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.
26
+ - `tenant_client` — factory: `async with tenant_client("member") as (client, tenant_id, user_id)` signs in a fresh user who is an `owner` / `admin` / `member` (default `owner`) of a new tenant — or of `tenant_id=` when given — with that tenant active. **Requires the `users` and `tenants` modules.**
26
27
 
27
28
  ## Usage
28
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_test"
3
- version = "0.0.35"
3
+ version = "0.0.36"
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.35",
29
- "simple_module_db==0.0.35",
30
- "simple_module_hosting==0.0.35",
28
+ "simple_module_core==0.0.36",
29
+ "simple_module_db==0.0.36",
30
+ "simple_module_hosting==0.0.36",
31
31
  "sqlalchemy>=2.0",
32
32
  ]
33
33
 
@@ -20,16 +20,21 @@ real broker (cross-worker cache invalidation) and skips when the binary is absen
20
20
  See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for
21
21
  their definitions. ``authenticated_client`` additionally requires the ``users``
22
22
  module to be installed — it seeds an admin via ``users.bootstrap``.
23
+ ``tenant_client`` (``users`` + ``tenants``) is a factory yielding a client signed
24
+ in as an ``owner``/``admin``/``member`` of a real tenant — see
25
+ ``simple_module_test.tenant_client``.
23
26
  """
24
27
 
25
28
  from simple_module_test.app_factory import build_test_app
26
29
  from simple_module_test.fake_events import FakeEventBus, RecordedEvent
27
30
  from simple_module_test.routes import effective_route_paths
28
31
  from simple_module_test.session_cookie import forge_session_cookie
32
+ from simple_module_test.tenant_client import TenantClient
29
33
 
30
34
  __all__ = [
31
35
  "FakeEventBus",
32
36
  "RecordedEvent",
37
+ "TenantClient",
33
38
  "build_test_app",
34
39
  "effective_route_paths",
35
40
  "forge_session_cookie",
@@ -34,6 +34,7 @@ from simple_module_test.fixtures import ( # noqa: F401
34
34
  setup_pending_app,
35
35
  )
36
36
  from simple_module_test.redis_server import redis_server # noqa: F401
37
+ from simple_module_test.tenant_client import tenant_client # noqa: F401
37
38
 
38
39
 
39
40
  def _bootstrap_eager_celery() -> None:
@@ -0,0 +1,37 @@
1
+ """Helpers for driving the ``/setup`` wizard from tests.
2
+
3
+ Every wizard mutation carries the session-bound CSRF token, so a test has to
4
+ do what the page does: load the wizard (which mints the token into the
5
+ session cookie and hands it out as the ``csrfToken`` prop) and echo it back
6
+ as ``X-CSRF-Token``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+ from simple_module_hosting.csrf import CSRF_HEADER
13
+
14
+
15
+ def wizard_client(app) -> httpx.AsyncClient:
16
+ """An anonymous client for *app*; it keeps the session cookie between calls."""
17
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")
18
+
19
+
20
+ async def wizard_headers(client: httpx.AsyncClient) -> dict[str, str]:
21
+ """Load the wizard as Inertia would and return the headers its forms send.
22
+
23
+ Raises ``AssertionError`` when the wizard is closed (404) — a test that
24
+ expected to post into it would otherwise fail with a confusing 403.
25
+ """
26
+ resp = await client.get("/setup", headers={"X-Inertia": "true"})
27
+ assert resp.status_code == 200, f"wizard not open: {resp.status_code}"
28
+ token = resp.json()["props"]["csrfToken"]
29
+ return {CSRF_HEADER: token, "Accept": "application/json"}
30
+
31
+
32
+ async def post_step(
33
+ client: httpx.AsyncClient, step_id: str, data: dict | None = None
34
+ ) -> httpx.Response:
35
+ """Submit *step_id*'s wizard form, CSRF token included."""
36
+ headers = await wizard_headers(client)
37
+ return await client.post(f"/setup/steps/{step_id}", json=data or {}, headers=headers)
@@ -0,0 +1,125 @@
1
+ """``tenant_client``: a signed-in member of a real tenant, with a chosen role.
2
+
3
+ For a module adopting ``MultiTenantMixin``: its tests need requests that act
4
+ for one tenant, as an ``owner``, ``admin`` or ``member`` there — the role that
5
+ reaches the principal as ``tenant:<role>``::
6
+
7
+ async def test_members_can_read(tenant_client):
8
+ async with tenant_client("member") as (client, tenant_id, user_id):
9
+ resp = await client.get("/api/things")
10
+
11
+ async def test_isolation(tenant_client):
12
+ async with tenant_client() as a, tenant_client() as b:
13
+ ... # two tenants, two owners
14
+
15
+ async with tenant_client("owner") as owner, tenant_client(
16
+ "member", tenant_id=owner.tenant_id
17
+ ) as member:
18
+ ... # two users of the same tenant
19
+
20
+ Requires the ``users`` and ``tenants`` modules. Both are imported inside the
21
+ fixture body, never at module scope, so this plugin still imports cleanly in
22
+ an app that has neither — the same rule ``authenticated_client`` follows.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import uuid
28
+ from collections.abc import AsyncGenerator, Callable
29
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
30
+ from typing import Any, NamedTuple
31
+
32
+ import httpx
33
+ import pytest
34
+ from simple_module_core.tenancy import TenantRole
35
+
36
+ from simple_module_test.session_cookie import forge_session_cookie
37
+
38
+
39
+ class TenantClient(NamedTuple):
40
+ client: httpx.AsyncClient
41
+ tenant_id: str
42
+ user_id: str
43
+
44
+
45
+ async def create_user(app: Any, email: str) -> str:
46
+ """A real, active, non-superuser ``users`` row with the ``user`` role; its id."""
47
+ from users.bootstrap import ensure_user_role
48
+ from users.models import User, UserRole
49
+
50
+ async with app.state.sm.db.session_factory() as session:
51
+ user = User(
52
+ id=uuid.uuid4(),
53
+ email=email,
54
+ hashed_password="x",
55
+ is_active=True,
56
+ is_superuser=False,
57
+ is_verified=True,
58
+ )
59
+ session.add(user)
60
+ await session.flush()
61
+ # Seeded lazily: test schemas come from ``create_all`` with no seed rows,
62
+ # and a missing role must not silently leave the user role-less.
63
+ role = await ensure_user_role(session)
64
+ session.add(UserRole(user_id=user.id, role_id=role.id))
65
+ await session.commit()
66
+ return str(user.id)
67
+
68
+
69
+ async def _join(app: Any, user_id: str, email: str, role: TenantRole, tenant_id: str | None) -> str:
70
+ from tenants.models import Membership, Tenant
71
+ from tenants.resolver import forget
72
+
73
+ async with app.state.sm.db.session_factory() as session:
74
+ if tenant_id is None:
75
+ tenant = Tenant(slug=f"t-{uuid.uuid4().hex[:12]}", name="Test Org")
76
+ session.add(tenant)
77
+ await session.flush()
78
+ tenant_id = tenant.id
79
+ session.add(Membership(tenant_id=tenant_id, user_id=user_id, role=role, email=email))
80
+ await session.commit()
81
+ forget(user_id) # the resolver caches memberships per user
82
+ return tenant_id
83
+
84
+
85
+ @asynccontextmanager
86
+ async def session_client(app: Any, data: dict[str, Any]) -> AsyncGenerator[httpx.AsyncClient]:
87
+ """An ``httpx`` client for ``app`` whose signed session cookie holds ``data``."""
88
+ cookie = forge_session_cookie(app.state.sm.settings.secret_key, data)
89
+ async with httpx.AsyncClient(
90
+ transport=httpx.ASGITransport(app=app),
91
+ base_url="http://testserver",
92
+ cookies={"session": cookie},
93
+ ) as client:
94
+ yield client
95
+
96
+
97
+ @pytest.fixture
98
+ def tenant_client(app: Any) -> Callable[..., AbstractAsyncContextManager[TenantClient]]:
99
+ """Factory: ``tenant_client(role="owner", *, tenant_id=None, email=None)``.
100
+
101
+ Creates a user and a membership with ``role`` — in a new tenant, or in
102
+ ``tenant_id`` when given — and yields a :class:`TenantClient` whose session
103
+ has that tenant active.
104
+ """
105
+ from tenants.constants import SESSION_ACTIVE_TENANT
106
+
107
+ @asynccontextmanager
108
+ async def factory(
109
+ role: str = TenantRole.OWNER,
110
+ *,
111
+ tenant_id: str | None = None,
112
+ email: str | None = None,
113
+ ) -> AsyncGenerator[TenantClient]:
114
+ member_role = TenantRole(role)
115
+ email = email or f"{member_role}-{uuid.uuid4().hex[:8]}@example.com"
116
+ user_id = await create_user(app, email)
117
+ tenant_id = await _join(app, user_id, email, member_role, tenant_id)
118
+ data = {"user_id": user_id, SESSION_ACTIVE_TENANT: tenant_id}
119
+ async with session_client(app, data) as client:
120
+ yield TenantClient(client, tenant_id, user_id)
121
+
122
+ return factory
123
+
124
+
125
+ __all__ = ["TenantClient", "create_user", "session_client", "tenant_client"]