simple-module-test 0.0.34__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.34 → simple_module_test-0.0.36}/PKG-INFO +5 -4
  2. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/README.md +1 -0
  3. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/pyproject.toml +4 -4
  4. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/__init__.py +7 -0
  5. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/_schema.py +3 -0
  6. simple_module_test-0.0.36/simple_module_test/database.py +78 -0
  7. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/fixtures.py +14 -2
  8. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/plugin.py +14 -0
  9. simple_module_test-0.0.36/simple_module_test/redis_server.py +145 -0
  10. simple_module_test-0.0.36/simple_module_test/setup_wizard.py +37 -0
  11. simple_module_test-0.0.36/simple_module_test/tenant_client.py +125 -0
  12. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/.gitignore +0 -0
  13. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/LICENSE +0 -0
  14. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/app_factory.py +0 -0
  15. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/fake_events.py +0 -0
  16. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/routes.py +0 -0
  17. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/simple_module_test/session_cookie.py +0 -0
  18. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/tests/test_app_factory.py +0 -0
  19. {simple_module_test-0.0.34 → simple_module_test-0.0.36}/tests/test_fake_events.py +0 -0
  20. {simple_module_test-0.0.34 → 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.34
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.34
29
- Requires-Dist: simple-module-db==0.0.34
30
- Requires-Dist: simple-module-hosting==0.0.34
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.34"
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.34",
29
- "simple_module_db==0.0.34",
30
- "simple_module_hosting==0.0.34",
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
 
@@ -15,19 +15,26 @@ Primary exports:
15
15
  The pytest plugin auto-registers these fixtures (no imports needed):
16
16
  ``fake_event_bus``, ``build_test_app``, ``settings``, ``db_state``,
17
17
  ``engine``, ``db_session``, ``app``, ``client``, and ``authenticated_client``.
18
+ ``redis_server`` starts a throwaway ``redis-server`` for the tests that need a
19
+ real broker (cross-worker cache invalidation) and skips when the binary is absent.
18
20
  See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for
19
21
  their definitions. ``authenticated_client`` additionally requires the ``users``
20
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``.
21
26
  """
22
27
 
23
28
  from simple_module_test.app_factory import build_test_app
24
29
  from simple_module_test.fake_events import FakeEventBus, RecordedEvent
25
30
  from simple_module_test.routes import effective_route_paths
26
31
  from simple_module_test.session_cookie import forge_session_cookie
32
+ from simple_module_test.tenant_client import TenantClient
27
33
 
28
34
  __all__ = [
29
35
  "FakeEventBus",
30
36
  "RecordedEvent",
37
+ "TenantClient",
31
38
  "build_test_app",
32
39
  "effective_route_paths",
33
40
  "forge_session_cookie",
@@ -51,8 +51,11 @@ async def _create_all_tables(engine) -> None:
51
51
  """
52
52
  from sqlalchemy import text
53
53
 
54
+ from simple_module_test.database import reset_schema_once
55
+
54
56
  bases = _ensure_models_imported()
55
57
  heads = _alembic_heads()
58
+ await reset_schema_once(engine) # Postgres: start empty; SQLite memory already is
56
59
 
57
60
  async with engine.begin() as conn:
58
61
 
@@ -0,0 +1,78 @@
1
+ """Which database the test fixtures run on (#343).
2
+
3
+ In-memory SQLite by default. Set ``SM_TEST_DATABASE_URL`` to an async URL
4
+ (``postgresql+asyncpg://postgres@localhost:5432/sm_test``) to run the same
5
+ suites on Postgres — what SQLite cannot show: real concurrent transactions,
6
+ row locks, stricter types, and SQL that SQLite happens to accept.
7
+
8
+ On Postgres every fixture starts from an empty ``public`` schema, so tests
9
+ stay independent; pooled connections are disabled because pytest-asyncio
10
+ gives each test its own event loop.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ from sqlalchemy import text
19
+ from sqlalchemy.pool import NullPool
20
+
21
+ TEST_DATABASE_ENV = "SM_TEST_DATABASE_URL"
22
+ SQLITE_MEMORY = "sqlite+aiosqlite:///:memory:"
23
+
24
+
25
+ def database_url_for_tests() -> str:
26
+ return os.environ.get(TEST_DATABASE_ENV) or SQLITE_MEMORY
27
+
28
+
29
+ def is_sqlite(url: str) -> bool:
30
+ return url.startswith("sqlite")
31
+
32
+
33
+ def init_db_kwargs(url: str) -> dict[str, Any]:
34
+ return {} if is_sqlite(url) else {"poolclass": NullPool}
35
+
36
+
37
+ async def reset_schema(engine: Any) -> None:
38
+ """Empty the database before a test (Postgres); a no-op on SQLite."""
39
+ if engine.dialect.name == "sqlite":
40
+ return
41
+ async with engine.begin() as conn:
42
+ await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
43
+ await conn.execute(text("CREATE SCHEMA public"))
44
+
45
+
46
+ # The test the schema was last emptied for. On SQLite every engine is its own
47
+ # in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
48
+ # one, so a second reset inside the same test would wipe the first fixture's
49
+ # rows (the seeded admin, say) from under it.
50
+ _current_test: object | None = None
51
+ _reset_for: object | None = None
52
+
53
+
54
+ def begin_test(token: object) -> None:
55
+ """Mark the start of a test; the next ``reset_schema_once`` resets."""
56
+ global _current_test
57
+ _current_test = token
58
+
59
+
60
+ async def reset_schema_once(engine: Any) -> None:
61
+ """``reset_schema`` at most once per test (see ``begin_test``)."""
62
+ global _reset_for
63
+ if _current_test is not None and _reset_for is _current_test:
64
+ return
65
+ await reset_schema(engine)
66
+ _reset_for = _current_test
67
+
68
+
69
+ __all__ = [
70
+ "SQLITE_MEMORY",
71
+ "TEST_DATABASE_ENV",
72
+ "begin_test",
73
+ "database_url_for_tests",
74
+ "init_db_kwargs",
75
+ "is_sqlite",
76
+ "reset_schema",
77
+ "reset_schema_once",
78
+ ]
@@ -31,6 +31,7 @@ from simple_module_hosting.settings import Settings
31
31
  from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
32
32
 
33
33
  from simple_module_test._schema import _create_all_tables
34
+ from simple_module_test.database import begin_test, database_url_for_tests, init_db_kwargs
34
35
  from simple_module_test.session_cookie import forge_session_cookie
35
36
 
36
37
  _AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER"
@@ -63,6 +64,16 @@ def pinned_auth_provider() -> Iterator[str]:
63
64
  os.environ[_AUTH_PROVIDER_ENV] = previous
64
65
 
65
66
 
67
+ @pytest.fixture(autouse=True)
68
+ def fresh_test_database(request: pytest.FixtureRequest) -> None:
69
+ """Let the first fixture of each test empty the Postgres schema, and no later one.
70
+
71
+ A no-op on the default in-memory SQLite, where every engine is already
72
+ its own empty database (see ``simple_module_test.database``).
73
+ """
74
+ begin_test(request.node)
75
+
76
+
66
77
  @pytest.fixture
67
78
  def settings() -> Settings:
68
79
  """Settings configured for testing with in-memory SQLite.
@@ -78,7 +89,7 @@ def settings() -> Settings:
78
89
  that expects ``/users/login``.
79
90
  """
80
91
  return Settings(
81
- database_url="sqlite+aiosqlite:///:memory:",
92
+ database_url=database_url_for_tests(),
82
93
  environment="testing",
83
94
  secret_key="test-secret-key",
84
95
  multi_tenant=True,
@@ -92,7 +103,8 @@ async def db_state() -> AsyncGenerator[DatabaseState, None]:
92
103
  """Create a fresh in-memory DatabaseState with listeners registered."""
93
104
  from simple_module_db.listeners import register_listeners
94
105
 
95
- state = init_db("sqlite+aiosqlite:///:memory:")
106
+ url = database_url_for_tests()
107
+ state = init_db(url, **init_db_kwargs(url))
96
108
  register_listeners(state)
97
109
  yield state
98
110
  await state.engine.dispose()
@@ -9,6 +9,7 @@ test suites — treat the fixture surface like a public API.
9
9
  from __future__ import annotations
10
10
 
11
11
  import contextlib
12
+ import os
12
13
 
13
14
  import pytest
14
15
 
@@ -27,10 +28,13 @@ from simple_module_test.fixtures import ( # noqa: F401
27
28
  db_session,
28
29
  db_state,
29
30
  engine,
31
+ fresh_test_database,
30
32
  pinned_auth_provider,
31
33
  settings,
32
34
  setup_pending_app,
33
35
  )
36
+ from simple_module_test.redis_server import redis_server # noqa: F401
37
+ from simple_module_test.tenant_client import tenant_client # noqa: F401
34
38
 
35
39
 
36
40
  def _bootstrap_eager_celery() -> None:
@@ -40,7 +44,17 @@ def _bootstrap_eager_celery() -> None:
40
44
  so the host's ``build_celery`` call never runs and ``task.delay()``
41
45
  falls through to the broker. Skipped silently when ``background_tasks``
42
46
  isn't installed.
47
+
48
+ Also switches off the cross-worker invalidation transport. ``on_startup``
49
+ would otherwise open a Redis pub/sub listener per app fixture — against a
50
+ broker no test environment runs — leaving a reconnect loop in the
51
+ background of every suite. ``setdefault``, so a test that wants the transport
52
+ can set the variable and get it — though the tests that exercise the transport
53
+ build ``RedisInvalidationTransport`` directly instead, against either a fake
54
+ client (``test_bg_invalidation.py``) or a throwaway ``redis-server`` (the
55
+ ``redis_server`` fixture, used by ``test_bg_invalidation_redis.py``).
43
56
  """
57
+ os.environ.setdefault("SM_BG_TASKS_BROADCAST_INVALIDATIONS", "false")
44
58
  with contextlib.suppress(ImportError):
45
59
  from background_tasks.celery_app import build_celery
46
60
  from background_tasks.settings import BackgroundTasksSettings
@@ -0,0 +1,145 @@
1
+ """A throwaway ``redis-server`` for tests that need a real broker.
2
+
3
+ The cross-worker invalidation transport is the one piece of this framework whose
4
+ whole job happens *between* processes, and a fake client cannot show that it
5
+ works — it can only show that this repo's own retry and teardown logic behaves.
6
+ So the tests that matter start a real server.
7
+
8
+ Deliberately a subprocess rather than a Docker service or the shared
9
+ ``dev-services`` stack: a unit-test job that runs ``make install-py`` and nothing
10
+ else must be able to run these, and a developer must not have to bring anything
11
+ up first. The server is ephemeral — a free port, no persistence
12
+ (``--save '' --appendonly no``), killed on teardown — so it cannot collide with
13
+ the real ``dev-services`` Redis on 6379 or leave state behind.
14
+
15
+ ``SM_TEST_REDIS_URL`` overrides all of this and is used as-is, which is how CI
16
+ supplies a Redis **service container** instead of a binary: GitHub's
17
+ ``ubuntu-latest`` image does not ship ``redis-server``, contrary to what this
18
+ docstring first claimed — the ``CI`` guard below caught that on the first run,
19
+ which is the whole reason it exists.
20
+
21
+ Absent both the variable and the binary the fixture skips — except under ``CI``,
22
+ where it **fails**.
23
+ A skip is right on a developer's machine and wrong on a build: the whole point of
24
+ these tests is to cover what the fake client cannot, and a runner image that
25
+ stopped shipping ``redis-server`` would silently shed every one of them while the
26
+ build stayed green. That is the same "passes while testing nothing" failure the
27
+ tests themselves exist to rule out, so it gets an explicit guard rather than a
28
+ sentence in a plan.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import os
35
+ import shutil
36
+ import socket
37
+ import subprocess
38
+ import time
39
+ from collections.abc import Iterator
40
+
41
+ import pytest
42
+
43
+ __all__ = ["redis_server"]
44
+
45
+ _STARTUP_TIMEOUT_SECONDS = 10.0
46
+ _POLL_INTERVAL_SECONDS = 0.05
47
+
48
+
49
+ def _free_port() -> int:
50
+ """A port nothing is listening on, as of a moment ago.
51
+
52
+ Inherently racy — the port could be taken between the probe and the bind —
53
+ but the alternative is a fixed port, which collides with a developer's own
54
+ Redis and with a parallel test run. ``redis-server`` fails loudly on a taken
55
+ port, and :func:`_wait_until_ready` turns that into a clear timeout rather
56
+ than a hang.
57
+ """
58
+ with socket.socket() as sock:
59
+ sock.bind(("127.0.0.1", 0))
60
+ return int(sock.getsockname()[1])
61
+
62
+
63
+ def _wait_until_ready(process: subprocess.Popen, port: int) -> None:
64
+ """Block until the server accepts a connection, or fail with its output.
65
+
66
+ Polls a TCP connect rather than shelling out to ``redis-cli``: one less
67
+ binary to require, and the thing under test is whether a socket opens.
68
+ """
69
+ deadline = time.monotonic() + _STARTUP_TIMEOUT_SECONDS
70
+ while time.monotonic() < deadline:
71
+ if process.poll() is not None:
72
+ output = (process.stdout.read() if process.stdout else "") or "(no output)"
73
+ raise RuntimeError(f"redis-server exited during startup: {output}")
74
+ with contextlib.suppress(OSError), socket.create_connection(("127.0.0.1", port), 0.2):
75
+ return
76
+ time.sleep(_POLL_INTERVAL_SECONDS)
77
+ process.kill()
78
+ raise RuntimeError(f"redis-server did not accept connections on {port} in time")
79
+
80
+
81
+ @pytest.fixture(scope="session")
82
+ def redis_server() -> Iterator[str]:
83
+ """A URL for a usable Redis. Session-scoped — a local start costs ~50ms.
84
+
85
+ Session scope means tests share one server, so anything that would leave a
86
+ subscription or a key behind must clean up after itself. Pub/sub carries no
87
+ state between messages, which is why sharing is safe for the invalidation
88
+ tests; a test that wrote keys would want its own database index.
89
+
90
+ With ``SM_TEST_REDIS_URL`` set the server is somebody else's — a CI service
91
+ container — so it is neither private nor guaranteed empty, and this fixture
92
+ neither starts nor stops it.
93
+ """
94
+ external = os.environ.get("SM_TEST_REDIS_URL")
95
+ if external:
96
+ yield external
97
+ return
98
+
99
+ binary = shutil.which("redis-server")
100
+ if binary is None:
101
+ message = (
102
+ "no redis-server binary and no SM_TEST_REDIS_URL, so the real-transport "
103
+ "and cross-process tests cannot run"
104
+ )
105
+ if os.environ.get("CI"):
106
+ pytest.fail(
107
+ f"{message}. On CI this is a failure, not a skip: silently dropping "
108
+ "these tests would leave the cross-process claim untested while the "
109
+ "build stayed green. Give the job a redis service and point "
110
+ "SM_TEST_REDIS_URL at it, or install redis-server on the runner."
111
+ )
112
+ pytest.skip(message)
113
+
114
+ port = _free_port()
115
+ process = subprocess.Popen(
116
+ [
117
+ binary,
118
+ "--port",
119
+ str(port),
120
+ "--bind",
121
+ "127.0.0.1",
122
+ # No persistence: nothing to write, nothing to load, no dump.rdb
123
+ # dropped into whatever directory pytest happened to run from.
124
+ "--save",
125
+ "",
126
+ "--appendonly",
127
+ "no",
128
+ "--databases",
129
+ "1",
130
+ ],
131
+ stdout=subprocess.PIPE,
132
+ stderr=subprocess.STDOUT,
133
+ text=True,
134
+ )
135
+ try:
136
+ _wait_until_ready(process, port)
137
+ yield f"redis://127.0.0.1:{port}/0"
138
+ finally:
139
+ process.terminate()
140
+ with contextlib.suppress(subprocess.TimeoutExpired):
141
+ process.wait(timeout=5)
142
+ if process.poll() is None:
143
+ process.kill()
144
+ if process.stdout is not None:
145
+ process.stdout.close()
@@ -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"]