simple-module-test 0.0.37__tar.gz → 0.0.39__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.37 → simple_module_test-0.0.39}/.gitignore +1 -2
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/PKG-INFO +9 -9
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/pyproject.toml +9 -9
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/__init__.py +3 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/_schema.py +23 -13
- simple_module_test-0.0.39/simple_module_test/database.py +199 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/fake_events.py +1 -1
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/fixtures.py +3 -0
- simple_module_test-0.0.39/simple_module_test/mail.py +40 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/plugin.py +15 -9
- simple_module_test-0.0.39/simple_module_test/process_state.py +24 -0
- simple_module_test-0.0.39/simple_module_test/waiting.py +83 -0
- simple_module_test-0.0.39/tests/test_schema_reuse_pg.py +75 -0
- simple_module_test-0.0.39/tests/test_testing_process_state.py +94 -0
- simple_module_test-0.0.39/tests/test_waiting_helpers.py +88 -0
- simple_module_test-0.0.39/tests/test_worker_database.py +51 -0
- simple_module_test-0.0.37/simple_module_test/database.py +0 -92
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/LICENSE +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/README.md +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/app_factory.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/redis_server.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/routes.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/session_cookie.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/setup_wizard.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/tenant_client.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/tests/test_app_factory.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/tests/test_fake_events.py +0 -0
- {simple_module_test-0.0.37 → simple_module_test-0.0.39}/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.
|
|
3
|
+
Version: 0.0.39
|
|
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
|
|
@@ -21,14 +21,14 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: fastapi
|
|
25
|
-
Requires-Dist: httpx
|
|
26
|
-
Requires-Dist: pytest-asyncio
|
|
27
|
-
Requires-Dist: pytest
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: sqlalchemy
|
|
24
|
+
Requires-Dist: fastapi<0.157,>=0.140
|
|
25
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
26
|
+
Requires-Dist: pytest-asyncio<2,>=0.24
|
|
27
|
+
Requires-Dist: pytest<10,>=8.0
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.39
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.39
|
|
31
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
33
33
|
|
|
34
34
|
# simple_module_test
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_test"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
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"
|
|
@@ -21,14 +21,14 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"fastapi>=0.
|
|
25
|
-
"httpx>=0.27",
|
|
26
|
-
"pytest>=8.0",
|
|
27
|
-
"pytest-asyncio>=0.24",
|
|
28
|
-
"simple_module_core==0.0.
|
|
29
|
-
"simple_module_db==0.0.
|
|
30
|
-
"simple_module_hosting==0.0.
|
|
31
|
-
"sqlalchemy>=2.0",
|
|
24
|
+
"fastapi>=0.140,<0.157",
|
|
25
|
+
"httpx>=0.27,<0.29",
|
|
26
|
+
"pytest>=8.0,<10",
|
|
27
|
+
"pytest-asyncio>=0.24,<2",
|
|
28
|
+
"simple_module_core==0.0.39",
|
|
29
|
+
"simple_module_db==0.0.39",
|
|
30
|
+
"simple_module_hosting==0.0.39",
|
|
31
|
+
"sqlalchemy>=2.0,<3",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.entry-points.pytest11]
|
|
@@ -30,6 +30,7 @@ from simple_module_test.fake_events import FakeEventBus, RecordedEvent
|
|
|
30
30
|
from simple_module_test.routes import effective_route_paths
|
|
31
31
|
from simple_module_test.session_cookie import forge_session_cookie
|
|
32
32
|
from simple_module_test.tenant_client import TenantClient
|
|
33
|
+
from simple_module_test.waiting import wait_for, wait_for_sync
|
|
33
34
|
|
|
34
35
|
__all__ = [
|
|
35
36
|
"FakeEventBus",
|
|
@@ -38,4 +39,6 @@ __all__ = [
|
|
|
38
39
|
"build_test_app",
|
|
39
40
|
"effective_route_paths",
|
|
40
41
|
"forge_session_cookie",
|
|
42
|
+
"wait_for",
|
|
43
|
+
"wait_for_sync",
|
|
41
44
|
]
|
|
@@ -19,6 +19,9 @@ from simple_module_db.base import all_module_bases
|
|
|
19
19
|
@lru_cache(maxsize=1)
|
|
20
20
|
def _ensure_models_imported() -> list:
|
|
21
21
|
"""Import all module models so all_module_bases is populated (cached)."""
|
|
22
|
+
import simple_module_db.leases # framework-owned table (#539)
|
|
23
|
+
import simple_module_db.stored_settings # noqa: F401 framework-owned table (#500)
|
|
24
|
+
|
|
22
25
|
for mod in discover_modules():
|
|
23
26
|
pkg = type(mod).__module__.split(".")[0]
|
|
24
27
|
with contextlib.suppress(ModuleNotFoundError):
|
|
@@ -51,27 +54,34 @@ async def _create_all_tables(engine) -> None:
|
|
|
51
54
|
"""
|
|
52
55
|
from sqlalchemy import text
|
|
53
56
|
|
|
54
|
-
from simple_module_test.database import
|
|
57
|
+
from simple_module_test.database import prepare_schema_once
|
|
55
58
|
|
|
56
59
|
bases = _ensure_models_imported()
|
|
57
60
|
heads = _alembic_heads()
|
|
58
|
-
await reset_schema_once(engine) # Postgres: start empty; SQLite memory already is
|
|
59
61
|
|
|
60
|
-
async
|
|
62
|
+
async def _build(engine) -> None:
|
|
63
|
+
async with engine.begin() as conn:
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
def _sync_create_all(sync_conn):
|
|
66
|
+
for base in bases:
|
|
67
|
+
base.metadata.create_all(sync_conn)
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
await conn.run_sync(_sync_create_all)
|
|
70
|
+
# Part of the build, so a reused Postgres schema's fingerprint covers it.
|
|
71
|
+
if heads:
|
|
72
|
+
await conn.execute(
|
|
73
|
+
text(
|
|
74
|
+
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
75
|
+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
76
|
+
)
|
|
77
|
+
)
|
|
67
78
|
|
|
79
|
+
tables = [table for base in bases for table in base.metadata.tables.values()]
|
|
80
|
+
# Postgres reuses the schema built for an earlier test, emptied of rows.
|
|
81
|
+
await prepare_schema_once(engine, tables, _build)
|
|
82
|
+
|
|
83
|
+
async with engine.begin() as conn:
|
|
68
84
|
if heads:
|
|
69
|
-
await conn.execute(
|
|
70
|
-
text(
|
|
71
|
-
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
72
|
-
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
85
|
await conn.execute(text("DELETE FROM alembic_version"))
|
|
76
86
|
for head in heads:
|
|
77
87
|
await conn.execute(
|
|
@@ -0,0 +1,199 @@
|
|
|
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 test starts with empty tables, so tests stay independent:
|
|
9
|
+
the schema is built once per process and emptied of rows between tests, and
|
|
10
|
+
dropped and rebuilt only when it changed (``prepare_schema_once``). Pooled
|
|
11
|
+
connections are disabled because pytest-asyncio
|
|
12
|
+
gives each test its own event loop. Under pytest-xdist each worker gets its
|
|
13
|
+
own database (``<name>_gw0``, ...), so workers never drop each other's schema.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import text
|
|
23
|
+
from sqlalchemy.engine import make_url
|
|
24
|
+
from sqlalchemy.pool import NullPool
|
|
25
|
+
|
|
26
|
+
TEST_DATABASE_ENV = "SM_TEST_DATABASE_URL"
|
|
27
|
+
SQLITE_MEMORY = "sqlite+aiosqlite:///:memory:"
|
|
28
|
+
XDIST_WORKER_ENV = "PYTEST_XDIST_WORKER"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def database_url_for_tests() -> str:
|
|
32
|
+
return os.environ.get(TEST_DATABASE_ENV) or SQLITE_MEMORY
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def database_is_postgres() -> bool:
|
|
36
|
+
return database_url_for_tests().startswith("postgresql")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_sqlite(url: str) -> bool:
|
|
40
|
+
return url.startswith("sqlite")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def init_db_kwargs(url: str) -> dict[str, Any]:
|
|
44
|
+
return {} if is_sqlite(url) else {"poolclass": NullPool}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def isolate_worker_database() -> None:
|
|
48
|
+
"""Point this xdist worker at its own Postgres database, creating it if needed.
|
|
49
|
+
|
|
50
|
+
Rewrites ``SM_TEST_DATABASE_URL`` rather than wrapping it, so tests that read
|
|
51
|
+
the variable directly and subprocesses that inherit it land on the same
|
|
52
|
+
database. A no-op on SQLite and outside xdist workers. The role needs
|
|
53
|
+
``CREATEDB`` the first time.
|
|
54
|
+
"""
|
|
55
|
+
worker = os.environ.get(XDIST_WORKER_ENV)
|
|
56
|
+
if not worker or not database_is_postgres():
|
|
57
|
+
return
|
|
58
|
+
base = make_url(database_url_for_tests())
|
|
59
|
+
url = base.set(database=f"{base.database}_{worker}")
|
|
60
|
+
asyncio.run(_create_database_if_missing(base, url.database or ""))
|
|
61
|
+
os.environ[TEST_DATABASE_ENV] = url.render_as_string(hide_password=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _create_database_if_missing(server_url: Any, name: str) -> None:
|
|
65
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
66
|
+
|
|
67
|
+
engine = create_async_engine(server_url, poolclass=NullPool, isolation_level="AUTOCOMMIT")
|
|
68
|
+
try:
|
|
69
|
+
async with engine.connect() as conn:
|
|
70
|
+
exists = await conn.scalar(
|
|
71
|
+
text("SELECT 1 FROM pg_database WHERE datname = :n"), {"n": name}
|
|
72
|
+
)
|
|
73
|
+
if not exists:
|
|
74
|
+
quoted = conn.dialect.identifier_preparer.quote_identifier(name)
|
|
75
|
+
await conn.execute(text(f"CREATE DATABASE {quoted}"))
|
|
76
|
+
finally:
|
|
77
|
+
await engine.dispose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def reset_schema(engine: Any) -> None:
|
|
81
|
+
"""Empty the database before a test (Postgres); a no-op on SQLite."""
|
|
82
|
+
global _built_schema
|
|
83
|
+
if engine.dialect.name == "sqlite":
|
|
84
|
+
return
|
|
85
|
+
_built_schema = None # whatever was built before is gone
|
|
86
|
+
async with engine.begin() as conn:
|
|
87
|
+
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
|
88
|
+
await conn.execute(text("CREATE SCHEMA public"))
|
|
89
|
+
# The drop took the installed extensions with it; modules' models need them.
|
|
90
|
+
await conn.run_sync(_create_declared_extensions)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _create_declared_extensions(sync_conn: Any) -> None:
|
|
94
|
+
from simple_module_core.discovery import discover_modules
|
|
95
|
+
from simple_module_db.extensions import ensure_db_extensions
|
|
96
|
+
|
|
97
|
+
ensure_db_extensions(sync_conn, discover_modules())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# The test the schema was last emptied for. On SQLite every engine is its own
|
|
101
|
+
# in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
|
|
102
|
+
# one, so a second reset inside the same test would wipe the first fixture's
|
|
103
|
+
# rows (the seeded admin, say) from under it.
|
|
104
|
+
_current_test: object | None = None
|
|
105
|
+
_reset_for: object | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def begin_test(token: object) -> None:
|
|
109
|
+
"""Mark the start of a test; the next ``reset_schema_once`` resets."""
|
|
110
|
+
global _current_test
|
|
111
|
+
_current_test = token
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def reset_schema_once(engine: Any) -> None:
|
|
115
|
+
"""``reset_schema`` at most once per test (see ``begin_test``)."""
|
|
116
|
+
global _reset_for
|
|
117
|
+
if _current_test is not None and _reset_for is _current_test:
|
|
118
|
+
return
|
|
119
|
+
await reset_schema(engine)
|
|
120
|
+
_reset_for = _current_test
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# The tables this process last built and the catalog fingerprint after the
|
|
124
|
+
# build (Postgres only); reused only while both still match.
|
|
125
|
+
_built_schema: tuple[frozenset[str], str] | None = None
|
|
126
|
+
|
|
127
|
+
_FINGERPRINT = text(
|
|
128
|
+
"SELECT md5(coalesce(string_agg(item, ',' ORDER BY item), '')) FROM ("
|
|
129
|
+
" SELECT c.relname || ':' || c.relkind::text || ':' || coalesce(a.attname || ':'"
|
|
130
|
+
" || a.atttypid::text || ':' || a.attnotnull::text, '') AS item"
|
|
131
|
+
" FROM pg_class c LEFT JOIN pg_attribute a"
|
|
132
|
+
" ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped"
|
|
133
|
+
" WHERE c.relnamespace = 'public'::regnamespace"
|
|
134
|
+
" UNION ALL SELECT 'con:' || conname FROM pg_constraint"
|
|
135
|
+
" WHERE connamespace = 'public'::regnamespace"
|
|
136
|
+
" UNION ALL SELECT 'type:' || typname FROM pg_type"
|
|
137
|
+
" WHERE typnamespace = 'public'::regnamespace"
|
|
138
|
+
" UNION ALL SELECT 'ext:' || extname FROM pg_extension) AS s"
|
|
139
|
+
)
|
|
140
|
+
_RESTART_SEQUENCES = text(
|
|
141
|
+
"SELECT setval(c.oid::regclass, 1, false) FROM pg_class c"
|
|
142
|
+
" WHERE c.relkind = 'S' AND c.relnamespace = 'public'::regnamespace"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def prepare_schema_once(engine: Any, tables: list[Any], build: Any) -> None:
|
|
147
|
+
"""Give this test empty ``tables``, at most once per test (see ``begin_test``).
|
|
148
|
+
|
|
149
|
+
SQLite: ``build(engine)``, since every engine is a fresh in-memory database.
|
|
150
|
+
Postgres: when this process already built the schema and the catalog still
|
|
151
|
+
matches it, delete every row and restart the sequences (~0.03 s) instead of
|
|
152
|
+
dropping and rebuilding it (~0.25 s). Anything else (the first test, a
|
|
153
|
+
direct ``reset_schema`` call, a test's own DDL) resets and rebuilds.
|
|
154
|
+
"""
|
|
155
|
+
global _reset_for, _built_schema
|
|
156
|
+
if engine.dialect.name == "sqlite":
|
|
157
|
+
await build(engine)
|
|
158
|
+
return
|
|
159
|
+
if _current_test is not None and _reset_for is _current_test:
|
|
160
|
+
return
|
|
161
|
+
_reset_for = _current_test
|
|
162
|
+
names = frozenset(table.fullname for table in tables)
|
|
163
|
+
if _built_schema is not None and _built_schema == (names, await _fingerprint(engine)):
|
|
164
|
+
await _empty_tables(engine, tables)
|
|
165
|
+
return
|
|
166
|
+
await reset_schema(engine)
|
|
167
|
+
await build(engine)
|
|
168
|
+
_built_schema = (names, await _fingerprint(engine))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _fingerprint(engine: Any) -> str:
|
|
172
|
+
async with engine.connect() as conn:
|
|
173
|
+
return await conn.scalar(_FINGERPRINT)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _empty_tables(engine: Any, tables: list[Any]) -> None:
|
|
177
|
+
from sqlalchemy.schema import sort_tables
|
|
178
|
+
|
|
179
|
+
async with engine.begin() as conn:
|
|
180
|
+
quote = conn.dialect.identifier_preparer.format_table
|
|
181
|
+
# Children first, so no foreign key refuses a parent's delete.
|
|
182
|
+
for table in reversed(sort_tables(tables)):
|
|
183
|
+
await conn.execute(text(f"DELETE FROM {quote(table)}"))
|
|
184
|
+
await conn.execute(_RESTART_SEQUENCES)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"SQLITE_MEMORY",
|
|
189
|
+
"TEST_DATABASE_ENV",
|
|
190
|
+
"begin_test",
|
|
191
|
+
"database_is_postgres",
|
|
192
|
+
"database_url_for_tests",
|
|
193
|
+
"init_db_kwargs",
|
|
194
|
+
"is_sqlite",
|
|
195
|
+
"isolate_worker_database",
|
|
196
|
+
"prepare_schema_once",
|
|
197
|
+
"reset_schema",
|
|
198
|
+
"reset_schema_once",
|
|
199
|
+
]
|
|
@@ -32,7 +32,7 @@ class FakeEventBus(EventBus):
|
|
|
32
32
|
super().__init__()
|
|
33
33
|
self.events: list[RecordedEvent] = []
|
|
34
34
|
|
|
35
|
-
async def publish(self, event: Event) -> None:
|
|
35
|
+
async def publish(self, event: Event) -> None:
|
|
36
36
|
self.events.append(RecordedEvent(event=event))
|
|
37
37
|
return await super().publish(event)
|
|
38
38
|
|
|
@@ -161,6 +161,9 @@ async def _seed_setup_admin(application) -> None:
|
|
|
161
161
|
async def _build_app(settings: Settings, *, seed_admin: bool):
|
|
162
162
|
from simple_module_hosting.app_builder import create_app
|
|
163
163
|
|
|
164
|
+
# Process-wide state is reset per test by the autouse fixture in
|
|
165
|
+
# ``process_state``, not here: a second app built inside one test must not
|
|
166
|
+
# reset the live first app's mail sender or lease backend.
|
|
164
167
|
application = create_app(settings)
|
|
165
168
|
|
|
166
169
|
await _create_all_tables(application.state.sm.db.engine)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Mail fixtures: capture what the app sends, and never leak a sender between tests.
|
|
2
|
+
|
|
3
|
+
The process mail sender (``simple_module_core.mail``) is module-level state that
|
|
4
|
+
an app's ``mail`` module installs on startup, so a test that boots an app would
|
|
5
|
+
otherwise hand its SMTP sender to the next test in the same worker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from simple_module_core.mail import MailMessage, install_mail_sender
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CapturedMail:
|
|
17
|
+
"""A sender that records every message instead of sending it."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, delivers_email: bool = True) -> None:
|
|
20
|
+
self.delivers_email = delivers_email
|
|
21
|
+
self.messages: list[MailMessage] = []
|
|
22
|
+
|
|
23
|
+
async def send(self, message: MailMessage) -> None:
|
|
24
|
+
self.messages.append(message)
|
|
25
|
+
|
|
26
|
+
def to(self, address: str) -> list[MailMessage]:
|
|
27
|
+
return [m for m in self.messages if m.to == address]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def captured_mail() -> Iterator[CapturedMail]:
|
|
32
|
+
"""Install a recording sender that claims to deliver; yields it.
|
|
33
|
+
|
|
34
|
+
Installed after any app fixture requested before it has started, so it wins
|
|
35
|
+
over the ``mail`` module's sender. A ``settings_reloaded`` in the test
|
|
36
|
+
reinstalls the real one; reinstall with ``install_mail_sender(captured)``.
|
|
37
|
+
"""
|
|
38
|
+
captured = CapturedMail()
|
|
39
|
+
install_mail_sender(captured)
|
|
40
|
+
yield captured
|
|
@@ -33,6 +33,8 @@ from simple_module_test.fixtures import ( # noqa: F401
|
|
|
33
33
|
settings,
|
|
34
34
|
setup_pending_app,
|
|
35
35
|
)
|
|
36
|
+
from simple_module_test.mail import captured_mail # noqa: F401
|
|
37
|
+
from simple_module_test.process_state import _reset_process_state # noqa: F401
|
|
36
38
|
from simple_module_test.redis_server import redis_server # noqa: F401
|
|
37
39
|
from simple_module_test.tenant_client import tenant_client # noqa: F401
|
|
38
40
|
|
|
@@ -42,15 +44,19 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
42
44
|
"markers",
|
|
43
45
|
"postgres: needs a PostgreSQL test database (SM_TEST_DATABASE_URL=postgresql+...)",
|
|
44
46
|
)
|
|
45
|
-
from simple_module_test.database import database_is_postgres
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
|
|
49
|
+
@pytest.hookimpl(tryfirst=True)
|
|
50
|
+
def pytest_load_initial_conftests(early_config, parser, args) -> None:
|
|
51
|
+
"""Give each xdist worker its own Postgres database.
|
|
52
|
+
|
|
53
|
+
Every Postgres test drops and recreates ``public``. This runs before any
|
|
54
|
+
conftest is imported (``pytest_configure`` runs after), so a conftest or
|
|
55
|
+
test helper that reads the URL at import time already sees the worker's.
|
|
56
|
+
"""
|
|
57
|
+
from simple_module_test.database import isolate_worker_database
|
|
58
|
+
|
|
59
|
+
isolate_worker_database()
|
|
54
60
|
|
|
55
61
|
|
|
56
62
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
|
@@ -132,7 +138,7 @@ def _cheap_password_hashing() -> None:
|
|
|
132
138
|
)
|
|
133
139
|
original_init(self, password_hash)
|
|
134
140
|
|
|
135
|
-
PasswordHelper.__init__ = _init
|
|
141
|
+
PasswordHelper.__init__ = _init
|
|
136
142
|
|
|
137
143
|
|
|
138
144
|
_cheap_password_hashing()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Reset process services between tests and apps built by the test plugin."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_core.leases import reset_lease_backend
|
|
7
|
+
from simple_module_core.mail import reset_mail_sender
|
|
8
|
+
from simple_module_hosting.sentry import reset_sentry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reset_process_state() -> None:
|
|
12
|
+
"""Restore unconfigured services without retaining another app's resources."""
|
|
13
|
+
reset_mail_sender()
|
|
14
|
+
reset_lease_backend()
|
|
15
|
+
reset_sentry()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _reset_process_state() -> Iterator[None]:
|
|
20
|
+
reset_process_state()
|
|
21
|
+
try:
|
|
22
|
+
yield
|
|
23
|
+
finally:
|
|
24
|
+
reset_process_state()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Polling helpers that replace fixed ``sleep()`` waits in tests.
|
|
2
|
+
|
|
3
|
+
A fixed sleep is a bet on timing: too short and the test flakes on a loaded
|
|
4
|
+
runner, too long and the suite is slow on a fast one. ``wait_for`` polls a
|
|
5
|
+
predicate until it is truthy and fails with the last value it saw.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import inspect
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _timeout_error(
|
|
16
|
+
predicate: Callable[..., Any], timeout: float, last: Any, error: BaseException | None
|
|
17
|
+
) -> AssertionError:
|
|
18
|
+
name = getattr(predicate, "__name__", repr(predicate))
|
|
19
|
+
detail = f"last error: {error!r}" if error is not None else f"last value: {last!r}"
|
|
20
|
+
return AssertionError(f"wait_for({name}) not satisfied within {timeout}s; {detail}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _poll_once(predicate: Callable[[], Any]) -> tuple[Any, BaseException | None]:
|
|
24
|
+
"""Call ``predicate`` once; an exception it raises is returned, not raised."""
|
|
25
|
+
try:
|
|
26
|
+
return predicate(), None
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
return None, exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def wait_for(
|
|
32
|
+
predicate: Callable[[], Any] | Callable[[], Awaitable[Any]],
|
|
33
|
+
timeout: float = 2.0,
|
|
34
|
+
interval: float = 0.01,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Poll ``predicate`` (sync or async) until truthy; return its value.
|
|
37
|
+
|
|
38
|
+
Raises ``AssertionError`` with the last value seen on timeout. An exception
|
|
39
|
+
raised by the predicate counts as "not yet" and is reported if it is the
|
|
40
|
+
last thing seen.
|
|
41
|
+
"""
|
|
42
|
+
deadline = time.monotonic() + timeout
|
|
43
|
+
while True:
|
|
44
|
+
value, error = _poll_once(predicate)
|
|
45
|
+
if inspect.isawaitable(value):
|
|
46
|
+
value, error = await _settle(value, max(deadline - time.monotonic(), 0.001))
|
|
47
|
+
if value:
|
|
48
|
+
return value
|
|
49
|
+
if time.monotonic() >= deadline:
|
|
50
|
+
raise _timeout_error(predicate, timeout, value, error)
|
|
51
|
+
await asyncio.sleep(interval)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _settle(awaitable: Awaitable[Any], budget: float) -> tuple[Any, BaseException | None]:
|
|
55
|
+
"""Await an async predicate's result, with :func:`_poll_once`'s contract.
|
|
56
|
+
|
|
57
|
+
``budget`` bounds the await, so a predicate that hangs fails the wait at its
|
|
58
|
+
timeout instead of blocking the test forever.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return await asyncio.wait_for(awaitable, budget), None
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
return None, exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def wait_for_sync(
|
|
67
|
+
predicate: Callable[[], Any],
|
|
68
|
+
timeout: float = 2.0,
|
|
69
|
+
interval: float = 0.01,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Blocking twin of :func:`wait_for` for sync tests (sync predicates only)."""
|
|
72
|
+
deadline = time.monotonic() + timeout
|
|
73
|
+
while True:
|
|
74
|
+
value, error = _poll_once(predicate)
|
|
75
|
+
if inspect.isawaitable(value):
|
|
76
|
+
if inspect.iscoroutine(value):
|
|
77
|
+
value.close() # never awaited: avoid the RuntimeWarning
|
|
78
|
+
raise TypeError("wait_for_sync takes a sync predicate; use wait_for for async ones")
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
if time.monotonic() >= deadline:
|
|
82
|
+
raise _timeout_error(predicate, timeout, value, error)
|
|
83
|
+
time.sleep(interval) # intentional-sleep: the polling interval itself
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Postgres reuses the schema between tests, emptied of rows (``prepare_schema_once``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_test import database
|
|
7
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, func, select, text
|
|
8
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.postgres
|
|
11
|
+
|
|
12
|
+
_metadata = MetaData()
|
|
13
|
+
_item = Table(
|
|
14
|
+
"reuse_probe_item",
|
|
15
|
+
_metadata,
|
|
16
|
+
Column("id", Integer, primary_key=True),
|
|
17
|
+
Column("name", String(20)),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
async def engine(monkeypatch):
|
|
23
|
+
# The probe drives the per-test state itself; put it back afterwards.
|
|
24
|
+
for attr in ("_current_test", "_reset_for", "_built_schema"):
|
|
25
|
+
monkeypatch.setattr(database, attr, getattr(database, attr))
|
|
26
|
+
url = database.database_url_for_tests()
|
|
27
|
+
engine = create_async_engine(url, **database.init_db_kwargs(url))
|
|
28
|
+
try:
|
|
29
|
+
yield engine
|
|
30
|
+
finally:
|
|
31
|
+
await database.reset_schema(engine) # the next test rebuilds the real schema
|
|
32
|
+
await engine.dispose()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _next_test(engine, builds: list[int]) -> None:
|
|
36
|
+
async def build(engine) -> None:
|
|
37
|
+
builds.append(1)
|
|
38
|
+
async with engine.begin() as conn:
|
|
39
|
+
await conn.run_sync(_metadata.create_all)
|
|
40
|
+
|
|
41
|
+
database.begin_test(object())
|
|
42
|
+
await database.prepare_schema_once(engine, [_item], build)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _insert(engine) -> int:
|
|
46
|
+
async with engine.begin() as conn:
|
|
47
|
+
return (
|
|
48
|
+
await conn.execute(_item.insert().values(name="x").returning(_item.c.id))
|
|
49
|
+
).scalar_one()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_a_later_test_gets_the_same_schema_without_rows(engine):
|
|
53
|
+
builds: list[int] = []
|
|
54
|
+
await _next_test(engine, builds)
|
|
55
|
+
assert [await _insert(engine), await _insert(engine)] == [1, 2]
|
|
56
|
+
|
|
57
|
+
await _next_test(engine, builds)
|
|
58
|
+
|
|
59
|
+
assert builds == [1]
|
|
60
|
+
async with engine.connect() as conn:
|
|
61
|
+
assert await conn.scalar(select(func.count()).select_from(_item)) == 0
|
|
62
|
+
assert await _insert(engine) == 1 # sequences restart too
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_a_test_that_changes_the_schema_forces_a_rebuild(engine):
|
|
66
|
+
builds: list[int] = []
|
|
67
|
+
await _next_test(engine, builds)
|
|
68
|
+
async with engine.begin() as conn:
|
|
69
|
+
await conn.execute(text("CREATE TABLE reuse_probe_extra (x int)"))
|
|
70
|
+
|
|
71
|
+
await _next_test(engine, builds)
|
|
72
|
+
|
|
73
|
+
assert builds == [1, 1]
|
|
74
|
+
async with engine.connect() as conn:
|
|
75
|
+
assert await conn.scalar(text("SELECT to_regclass('reuse_probe_extra')")) is None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Process services are isolated between plugin-built apps and tests."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import Mock
|
|
4
|
+
|
|
5
|
+
from simple_module_core.leases import (
|
|
6
|
+
InProcessLeaseBackend,
|
|
7
|
+
configure_leases,
|
|
8
|
+
get_leases,
|
|
9
|
+
reset_lease_backend,
|
|
10
|
+
)
|
|
11
|
+
from simple_module_core.mail import (
|
|
12
|
+
ConsoleMailSender,
|
|
13
|
+
current_mail_sender,
|
|
14
|
+
install_mail_resolver,
|
|
15
|
+
install_mail_sender,
|
|
16
|
+
mail_transport_installed,
|
|
17
|
+
reset_mail_sender,
|
|
18
|
+
resolve_mail_sender,
|
|
19
|
+
)
|
|
20
|
+
from simple_module_hosting import sentry
|
|
21
|
+
from simple_module_test.mail import CapturedMail
|
|
22
|
+
from simple_module_test.process_state import reset_process_state
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def test_reset_mail_clears_sender_and_worker_resolver() -> None:
|
|
26
|
+
sender = CapturedMail()
|
|
27
|
+
|
|
28
|
+
async def resolver(db):
|
|
29
|
+
return sender
|
|
30
|
+
|
|
31
|
+
install_mail_sender(sender)
|
|
32
|
+
install_mail_resolver(resolver)
|
|
33
|
+
reset_mail_sender()
|
|
34
|
+
assert not mail_transport_installed()
|
|
35
|
+
assert isinstance(current_mail_sender(), ConsoleMailSender)
|
|
36
|
+
assert isinstance(await resolve_mail_sender(object()), ConsoleMailSender)
|
|
37
|
+
assert current_mail_sender().include_body is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def test_reset_leases_discards_configuration_and_held_names() -> None:
|
|
41
|
+
original = InProcessLeaseBackend()
|
|
42
|
+
configure_leases(original)
|
|
43
|
+
assert await original.acquire("held", "previous-app", 60) == 1
|
|
44
|
+
reset_lease_backend()
|
|
45
|
+
replacement = get_leases()
|
|
46
|
+
assert replacement is not original
|
|
47
|
+
assert isinstance(replacement, InProcessLeaseBackend)
|
|
48
|
+
assert await replacement.acquire("held", "next-app", 60) == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_reset_sentry_closes_and_unbinds_client(monkeypatch) -> None:
|
|
52
|
+
import pytest
|
|
53
|
+
|
|
54
|
+
sdk = pytest.importorskip("sentry_sdk")
|
|
55
|
+
scope = Mock()
|
|
56
|
+
monkeypatch.setattr(sdk, "get_global_scope", lambda: scope)
|
|
57
|
+
monkeypatch.setattr(sentry, "_enabled", True)
|
|
58
|
+
sentry._request_scope.set({"type": "http"})
|
|
59
|
+
sentry.reset_sentry()
|
|
60
|
+
scope.client.close.assert_called_once_with()
|
|
61
|
+
scope.set_client.assert_called_once_with(None)
|
|
62
|
+
assert not sentry.is_enabled()
|
|
63
|
+
assert sentry._request_scope.get() is None
|
|
64
|
+
sentry.reset_sentry()
|
|
65
|
+
scope.client.close.assert_called_once_with()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_plugin_second_app_keeps_first_apps_services(settings, monkeypatch) -> None:
|
|
69
|
+
"""Resets are per test (autouse fixture), so a second app in one test must not
|
|
70
|
+
wipe the first app's installed mail sender."""
|
|
71
|
+
from fastapi import FastAPI
|
|
72
|
+
from simple_module_hosting import app_builder
|
|
73
|
+
from simple_module_test import fixtures
|
|
74
|
+
|
|
75
|
+
def create_app(settings):
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
app.state.sm = Mock()
|
|
78
|
+
return app
|
|
79
|
+
|
|
80
|
+
async def create_tables(engine):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(app_builder, "create_app", create_app)
|
|
84
|
+
monkeypatch.setattr(fixtures, "_create_all_tables", create_tables)
|
|
85
|
+
install_mail_sender(CapturedMail())
|
|
86
|
+
async for _app in fixtures._build_app(settings, seed_admin=False):
|
|
87
|
+
assert mail_transport_installed()
|
|
88
|
+
reset_process_state()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_autouse_reset_clears_state_left_by_a_previous_test() -> None:
|
|
92
|
+
# The autouse ``_reset_process_state`` fixture ran before this test.
|
|
93
|
+
assert not mail_transport_installed()
|
|
94
|
+
assert not sentry.is_enabled()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from simple_module_test import wait_for, wait_for_sync
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_returns_value_when_already_true():
|
|
8
|
+
assert await wait_for(lambda: "ok") == "ok"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_polls_sync_predicate_until_true():
|
|
12
|
+
calls = {"n": 0}
|
|
13
|
+
|
|
14
|
+
def pred():
|
|
15
|
+
calls["n"] += 1
|
|
16
|
+
return calls["n"] >= 3
|
|
17
|
+
|
|
18
|
+
assert await wait_for(pred, interval=0.001) is True
|
|
19
|
+
assert calls["n"] == 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_async_predicate():
|
|
23
|
+
state = {"done": False}
|
|
24
|
+
|
|
25
|
+
async def pred():
|
|
26
|
+
return state["done"]
|
|
27
|
+
|
|
28
|
+
async def flip():
|
|
29
|
+
await asyncio.sleep(0.02) # intentional-sleep: simulate work finishing later
|
|
30
|
+
state["done"] = True
|
|
31
|
+
|
|
32
|
+
task = asyncio.create_task(flip())
|
|
33
|
+
assert await wait_for(pred, interval=0.005) is True
|
|
34
|
+
await task
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_timeout_reports_last_value():
|
|
38
|
+
with pytest.raises(AssertionError, match=r"last value: 0"):
|
|
39
|
+
await wait_for(lambda: 0, timeout=0.05, interval=0.005)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_predicate_exception_is_retried_then_reported():
|
|
43
|
+
calls = {"n": 0}
|
|
44
|
+
|
|
45
|
+
def pred():
|
|
46
|
+
calls["n"] += 1
|
|
47
|
+
if calls["n"] < 3:
|
|
48
|
+
raise KeyError("missing")
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
assert await wait_for(pred, interval=0.001)
|
|
52
|
+
|
|
53
|
+
def boom():
|
|
54
|
+
raise ValueError("x")
|
|
55
|
+
|
|
56
|
+
with pytest.raises(AssertionError, match="last error: ValueError"):
|
|
57
|
+
await wait_for(boom, timeout=0.05, interval=0.005)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_sync_returns_and_polls():
|
|
61
|
+
calls = {"n": 0}
|
|
62
|
+
|
|
63
|
+
def pred():
|
|
64
|
+
calls["n"] += 1
|
|
65
|
+
return calls["n"] >= 2
|
|
66
|
+
|
|
67
|
+
assert wait_for_sync(pred, interval=0.001) is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_sync_timeout():
|
|
71
|
+
with pytest.raises(AssertionError, match=r"last value: \[\]"):
|
|
72
|
+
wait_for_sync(list, timeout=0.05, interval=0.005)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_hung_async_predicate_times_out():
|
|
76
|
+
async def hangs():
|
|
77
|
+
await asyncio.sleep(30) # intentional-sleep: never returns on its own
|
|
78
|
+
|
|
79
|
+
with pytest.raises(AssertionError, match="not satisfied"):
|
|
80
|
+
await wait_for(hangs, timeout=0.05, interval=0.005)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_sync_rejects_an_async_predicate():
|
|
84
|
+
async def pred():
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
with pytest.raises(TypeError, match="sync predicate"):
|
|
88
|
+
wait_for_sync(pred)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Each xdist worker gets its own Postgres database (``isolate_worker_database``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_test import database
|
|
7
|
+
|
|
8
|
+
_PG = "postgresql+asyncpg://postgres:secret@db.local:5432/sm_test"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def created(monkeypatch) -> list[tuple[str, str]]:
|
|
13
|
+
calls: list[tuple[str, str]] = []
|
|
14
|
+
|
|
15
|
+
async def fake_create(server_url, name):
|
|
16
|
+
calls.append((server_url.database, name))
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr(database, "_create_database_if_missing", fake_create)
|
|
19
|
+
return calls
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_a_worker_is_pointed_at_its_own_database(monkeypatch, created):
|
|
23
|
+
monkeypatch.setenv(database.TEST_DATABASE_ENV, _PG)
|
|
24
|
+
monkeypatch.setenv(database.XDIST_WORKER_ENV, "gw3")
|
|
25
|
+
|
|
26
|
+
database.isolate_worker_database()
|
|
27
|
+
|
|
28
|
+
assert created == [("sm_test", "sm_test_gw3")]
|
|
29
|
+
assert database.database_url_for_tests() == (
|
|
30
|
+
"postgresql+asyncpg://postgres:secret@db.local:5432/sm_test_gw3"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_outside_xdist_the_named_database_is_used(monkeypatch, created):
|
|
35
|
+
monkeypatch.setenv(database.TEST_DATABASE_ENV, _PG)
|
|
36
|
+
monkeypatch.delenv(database.XDIST_WORKER_ENV, raising=False)
|
|
37
|
+
|
|
38
|
+
database.isolate_worker_database()
|
|
39
|
+
|
|
40
|
+
assert created == []
|
|
41
|
+
assert database.database_url_for_tests() == _PG
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_sqlite_workers_are_left_alone(monkeypatch, created):
|
|
45
|
+
monkeypatch.delenv(database.TEST_DATABASE_ENV, raising=False)
|
|
46
|
+
monkeypatch.setenv(database.XDIST_WORKER_ENV, "gw0")
|
|
47
|
+
|
|
48
|
+
database.isolate_worker_database()
|
|
49
|
+
|
|
50
|
+
assert created == []
|
|
51
|
+
assert database.database_url_for_tests() == database.SQLITE_MEMORY
|
|
@@ -1,92 +0,0 @@
|
|
|
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 database_is_postgres() -> bool:
|
|
30
|
-
return database_url_for_tests().startswith("postgresql")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def is_sqlite(url: str) -> bool:
|
|
34
|
-
return url.startswith("sqlite")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def init_db_kwargs(url: str) -> dict[str, Any]:
|
|
38
|
-
return {} if is_sqlite(url) else {"poolclass": NullPool}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
async def reset_schema(engine: Any) -> None:
|
|
42
|
-
"""Empty the database before a test (Postgres); a no-op on SQLite."""
|
|
43
|
-
if engine.dialect.name == "sqlite":
|
|
44
|
-
return
|
|
45
|
-
async with engine.begin() as conn:
|
|
46
|
-
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
|
47
|
-
await conn.execute(text("CREATE SCHEMA public"))
|
|
48
|
-
# The drop took the installed extensions with it; modules' models need them.
|
|
49
|
-
await conn.run_sync(_create_declared_extensions)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _create_declared_extensions(sync_conn: Any) -> None:
|
|
53
|
-
from simple_module_core.discovery import discover_modules
|
|
54
|
-
from simple_module_db.extensions import ensure_db_extensions
|
|
55
|
-
|
|
56
|
-
ensure_db_extensions(sync_conn, discover_modules())
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# The test the schema was last emptied for. On SQLite every engine is its own
|
|
60
|
-
# in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
|
|
61
|
-
# one, so a second reset inside the same test would wipe the first fixture's
|
|
62
|
-
# rows (the seeded admin, say) from under it.
|
|
63
|
-
_current_test: object | None = None
|
|
64
|
-
_reset_for: object | None = None
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def begin_test(token: object) -> None:
|
|
68
|
-
"""Mark the start of a test; the next ``reset_schema_once`` resets."""
|
|
69
|
-
global _current_test
|
|
70
|
-
_current_test = token
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
async def reset_schema_once(engine: Any) -> None:
|
|
74
|
-
"""``reset_schema`` at most once per test (see ``begin_test``)."""
|
|
75
|
-
global _reset_for
|
|
76
|
-
if _current_test is not None and _reset_for is _current_test:
|
|
77
|
-
return
|
|
78
|
-
await reset_schema(engine)
|
|
79
|
-
_reset_for = _current_test
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
__all__ = [
|
|
83
|
-
"SQLITE_MEMORY",
|
|
84
|
-
"TEST_DATABASE_ENV",
|
|
85
|
-
"begin_test",
|
|
86
|
-
"database_is_postgres",
|
|
87
|
-
"database_url_for_tests",
|
|
88
|
-
"init_db_kwargs",
|
|
89
|
-
"is_sqlite",
|
|
90
|
-
"reset_schema",
|
|
91
|
-
"reset_schema_once",
|
|
92
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/session_cookie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|