spltz-viur-testing 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,308 @@
1
+ Metadata-Version: 2.4
2
+ Name: spltz-viur-testing
3
+ Version: 0.1.0
4
+ Summary: Safe test-mode for ViUR core projects — Playwright e2e ready.
5
+ Author: Andreas H. Kelch
6
+ License: MIT License
7
+
8
+ Copyright © 2026 Andreas H. Kelch
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/sprengplatz/viur-testing
29
+ Project-URL: Documentation, https://sprengplatz.github.io/viur-testing/
30
+ Project-URL: Repository, https://github.com/sprengplatz/viur-testing.git
31
+ Project-URL: Bug Tracker, https://github.com/sprengplatz/viur-testing/issues
32
+ Project-URL: Changelog, https://github.com/sprengplatz/viur-testing/blob/main/CHANGELOG.md
33
+ Keywords: viur,e2e,testing,playwright
34
+ Classifier: Development Status :: 4 - Beta
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
41
+ Requires-Python: >=3.12
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: viur-core<4,>=3.7
45
+ Provides-Extra: test
46
+ Requires-Dist: pytest~=8.0; extra == "test"
47
+ Requires-Dist: pytest-cov~=5.0; extra == "test"
48
+ Requires-Dist: coverage[toml]~=7.0; extra == "test"
49
+ Requires-Dist: spltz-viur-light-mock>=0.1; extra == "test"
50
+ Provides-Extra: docs
51
+ Requires-Dist: mkdocs-material~=9.5; extra == "docs"
52
+ Requires-Dist: mkdocstrings[python]~=0.26; extra == "docs"
53
+ Provides-Extra: dev
54
+ Requires-Dist: spltz-viur-testing[docs,test]; extra == "dev"
55
+ Requires-Dist: build~=1.2; extra == "dev"
56
+ Dynamic: license-file
57
+
58
+ # viur-testing
59
+
60
+ [![Tests](https://github.com/sprengplatz/viur-testing/actions/workflows/test.yml/badge.svg)](https://github.com/sprengplatz/viur-testing/actions/workflows/test.yml)
61
+ [![Docs](https://github.com/sprengplatz/viur-testing/actions/workflows/docs.yml/badge.svg)](https://sprengplatz.github.io/viur-testing/)
62
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
63
+
64
+ Safe test-mode for ViUR core projects — primarily for Playwright
65
+ end-to-end tests. Swaps the default datastore database out for a
66
+ dedicated named database (default: `viur-tests`), with a bilateral
67
+ handshake that refuses to let any test or endpoint run unless both the
68
+ server **and** the runner agree they are talking to the test instance.
69
+
70
+ ## Bilateral guarantee in six lines
71
+
72
+ 1. **`activate()` refuses outside `conf.instance.is_dev_server`** —
73
+ read from viur-core's canonical flag.
74
+ 2. **Synchronous datastore probe** in the target database has to
75
+ succeed before the client swap is applied.
76
+ 3. **`TestModule` *and* `ConfigModule` both refuse to instantiate**
77
+ outside the dev server *or* without a prior `activate()` call —
78
+ both `__init__`s raise in either case. Even a host that bypasses
79
+ the `TestModule` container and mounts `ConfigModule` directly is
80
+ subject to the same checks, so a forgotten activate or a stray
81
+ production mount fails loudly at boot.
82
+ 4. **Per-request token validator** (`X-Viur-Test-Token`) blocks every
83
+ request that does not carry the session token, except the two
84
+ bootstrap endpoints.
85
+ 5. **`protect()` installs a production-side header guard** that 403s
86
+ any request carrying the test token header outside dev, regardless
87
+ of its value. Installed in every environment.
88
+ 6. **Runner preflight** calls `/json/_test/config/status` and refuses to run
89
+ any test if the server's reply (database, project_id, token hash)
90
+ does not match.
91
+
92
+ The session token is stored only in the test database itself
93
+ (kind `viur-tests`, entity `auth-token`) — never on disk. App Engine
94
+ file-system writes are not needed.
95
+
96
+ ## Module layout
97
+
98
+ ```
99
+ /_test/ TestModule (container, refuses outside dev mode)
100
+ /_test/config/status ConfigModule.status — issues/returns token
101
+ /_test/config/finish ConfigModule.finish — deletes token entity
102
+ ```
103
+
104
+ Future test flavours (load test, integration helpers, …) go in as
105
+ sibling submodules under the same `_test` container.
106
+
107
+ ## Requirements
108
+
109
+ - Python ≥ 3.12
110
+ - viur-core ≥ 3.7, < 4
111
+ - A named Datastore database (default name: `viur-tests`) created in
112
+ your GCP project alongside `(default)`.
113
+
114
+ ## Install
115
+
116
+ The PyPI distribution name is `spltz-viur-testing` (experimental
117
+ prefix); the Python import path stays `viur.testing`:
118
+
119
+ ```sh
120
+ pip install spltz-viur-testing
121
+ ```
122
+
123
+ ```python
124
+ import viur.testing
125
+ viur.testing.setup()
126
+ ```
127
+
128
+ ## Server-side wiring
129
+
130
+ Two one-liners. `main.py` — **as the first lines, before any
131
+ `viur.core` import**:
132
+
133
+ ```python
134
+ import viur.testing
135
+ viur.testing.setup()
136
+
137
+ # Only now may viur.core be imported by your own code.
138
+ from viur.core import setup as core_setup
139
+ import modules
140
+ import render
141
+
142
+ core_setup(modules, render)
143
+ ```
144
+
145
+ `viur.testing.setup()` checks the `VIUR_TESTING_ENABLE` env var; if
146
+ truthy it calls `activate()` (datastore client swap + key-factory
147
+ patch + closed-system whitelist + state priming + validator install)
148
+ and always installs the production header guard via `protect()`.
149
+
150
+ In `modules/__init__.py` register the test endpoints — idempotent and
151
+ safe to leave in place for production deployments (no-op when test
152
+ mode isn't armed):
153
+
154
+ ```python
155
+ # modules/__init__.py
156
+ import viur.testing
157
+ viur.testing.register_modules(globals())
158
+ ```
159
+
160
+ That exposes `POST /json/_test/config/status` and `POST /json/_test/config/finish`.
161
+ Both endpoints re-verify `conf.instance.is_dev_server` and the active
162
+ datastore database before performing any work — defence in depth on
163
+ top of the `TestModule.__init__` guard.
164
+
165
+ If you need more control, the two functions wrap underlying primitives
166
+ you can call yourself: `viur.testing.activate(database=...)`,
167
+ `viur.testing.protect()`, plus direct mounting via
168
+ `from viur.testing._test import TestModule`.
169
+
170
+ ## Running the dev server with test mode
171
+
172
+ Toggle test mode at boot by setting the env var that `setup()` reads:
173
+
174
+ ```sh
175
+ VIUR_TESTING_ENABLE=1 viur run
176
+ ```
177
+
178
+ Without the env var, `setup()` skips `activate()` and the process boots
179
+ against the default database as if the package were not installed.
180
+
181
+ When test mode is active, the dev-server boot banner gains two extra
182
+ lines — `database = …` and `namespace = …` — so the running slice is
183
+ visible at a glance. The namespace line is rendered unconditionally;
184
+ without `VIUR_TESTING_NAMESPACE` it falls back to `(default)`, making
185
+ it obvious that test mode is armed but namespace isolation is **not**
186
+ in effect:
187
+
188
+ ```
189
+ # With VIUR_TESTING_NAMESPACE=alice
190
+ ################## LOCAL DEVELOPMENT SERVER IS UP AND RUNNING ##################
191
+ # project = my-viur-project #
192
+ # python = 3.13.0 #
193
+ # viur = 3.8.25 #
194
+ # database = viur-tests #
195
+ # namespace = alice #
196
+ ################################################################################
197
+
198
+ # Without VIUR_TESTING_NAMESPACE (or with empty value)
199
+ ################## LOCAL DEVELOPMENT SERVER IS UP AND RUNNING ##################
200
+ # project = my-viur-project #
201
+ # python = 3.13.0 #
202
+ # viur = 3.8.25 #
203
+ # database = viur-tests #
204
+ # namespace = (default) #
205
+ ################################################################################
206
+ ```
207
+
208
+ ## Concurrency: sharing one test database across multiple testers
209
+
210
+ The `viur-tests` database is a shared GCP resource. If two engineers
211
+ both boot a dev server with the same database and run tests at the
212
+ same time, their entities will collide — Person A's seed wipes
213
+ Person B's user, Person B's test queries find leftovers from Person A.
214
+
215
+ The fix is the optional `namespace` argument. ViUR-testing passes it
216
+ to `google.cloud.datastore.Client(database=…, namespace=…)` and rewires
217
+ `viur.core.db.Key` so every read and write in the process is scoped
218
+ to that namespace. Different namespaces in the same database are
219
+ fully isolated — no separate DB provisioning needed.
220
+
221
+ Boot each dev server with its own namespace:
222
+
223
+ ```sh
224
+ # Alice's machine
225
+ VIUR_TESTING_ENABLE=1 VIUR_TESTING_NAMESPACE=alice viur run
226
+
227
+ # Bob's machine
228
+ VIUR_TESTING_ENABLE=1 VIUR_TESTING_NAMESPACE=bob viur run
229
+
230
+ # CI for PR #42
231
+ VIUR_TESTING_ENABLE=1 VIUR_TESTING_NAMESPACE=ci-pr-42 viur run
232
+ ```
233
+
234
+ The runner-side `require_test_mode` can assert the expected namespace
235
+ to fail fast when somebody points at the wrong slice::
236
+
237
+ from viur.testing import require_test_mode
238
+
239
+ status = require_test_mode(
240
+ "http://localhost:8080",
241
+ expected_namespace="alice", # omit to skip; pass None for default
242
+ )
243
+
244
+ `VIUR_TESTING_NAMESPACE` may be empty or unset — both mean "no
245
+ namespace, use the Datastore default". This is the existing behaviour
246
+ when no namespaces are needed (e.g. single-developer setup).
247
+
248
+ ## Runner-side wiring (pytest + Playwright)
249
+
250
+ ```python
251
+ # tests/conftest.py
252
+ import pytest
253
+ from viur.testing import require_test_mode, finish
254
+
255
+ _BASE_URL = "http://localhost:8080"
256
+
257
+
258
+ @pytest.fixture(scope="session")
259
+ def test_session():
260
+ status = require_test_mode(_BASE_URL)
261
+ yield status
262
+ finish(_BASE_URL, status.token)
263
+ ```
264
+
265
+ If the server is not in test mode, points at the wrong database, or
266
+ the token hash does not match the returned token, `require_test_mode`
267
+ raises `TestModePreflightError` and no test ever runs.
268
+
269
+ For Playwright, inject the token as a default header on the browser
270
+ context:
271
+
272
+ ```python
273
+ @pytest.fixture
274
+ def context(browser, test_session):
275
+ return browser.new_context(
276
+ extra_http_headers={"X-Viur-Test-Token": test_session.token},
277
+ )
278
+ ```
279
+
280
+ ## Naming
281
+
282
+ The Python package keeps its repository name `viur-testing` for stability,
283
+ but everything inside it speaks the generic *test* vocabulary so future
284
+ test flavours can be added under the same `TestModule` umbrella
285
+ without churn. The `_test` URL prefix's leading underscore signals
286
+ "system-internal, not for production callers".
287
+
288
+ ## Development
289
+
290
+ ```bash
291
+ git clone git@github.com:sprengplatz/viur-testing.git
292
+ cd viur-testing
293
+ pip install -e ".[dev]"
294
+ pytest # 100% coverage required
295
+ mkdocs serve # docs at http://localhost:8000
296
+ ```
297
+
298
+ ## Documentation
299
+
300
+ See [sprengplatz.github.io/viur-testing](https://sprengplatz.github.io/viur-testing/).
301
+
302
+ ## Changelog
303
+
304
+ See [CHANGELOG.md](CHANGELOG.md).
305
+
306
+ ## License
307
+
308
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ spltz_viur_testing-0.1.0.dist-info/licenses/LICENSE,sha256=xbdB0-3osMyjq6qSugjP0wbJx20IIMB2w_qj-HhvnXE,1072
2
+ viur/testing/__init__.py,sha256=0olAfnZdCkYJD5fUzKeB6kpdm_bu_YcM5_l2FD2ogQQ,12639
3
+ viur/testing/activation.py,sha256=x7Uo15BjSfbOuzEoYO6Gtj-QHKzH4oLinzUhk2wK0KY,14408
4
+ viur/testing/banner.py,sha256=LDzRLZ2ZLBE4x3Cn3VDcBR7rH7FABgVJDxxnO8ZP7xY,5934
5
+ viur/testing/constants.py,sha256=8n9vqS9k6LYbh7LbRgi4UGZrqmoc9djmd5idpbfNCB8,1561
6
+ viur/testing/protection.py,sha256=zfljUGeM-OGBoiJHSsEi5flIHDAajopNll9_EQa74OQ,1664
7
+ viur/testing/runner.py,sha256=Cd-vzM2z5MD-HeiOppVwFTNp92CGoxHPGLVrBWnouFM,7383
8
+ viur/testing/validator.py,sha256=bkUvBKB0ZmPlpznuRCd8MHZdPM9-oauxUxIaMQjaP5w,4810
9
+ viur/testing/_test/__init__.py,sha256=HFEiBlWmuY4akpf55kqGXv7L7Qo4uvHc0wDg-ermvtc,5990
10
+ viur/testing/_test/config.py,sha256=vG2nRIt174Y3w9W6zTZ55lc6m2FhGnhB3YU7WR5BOkQ,13126
11
+ spltz_viur_testing-0.1.0.dist-info/METADATA,sha256=lAcjL57ksq4reeFJb338nb_9Q2VKzEODX0pT_neI2_k,11905
12
+ spltz_viur_testing-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ spltz_viur_testing-0.1.0.dist-info/top_level.txt,sha256=UpXSoS_clIJYncMZKAppy3WNRScHSjarTabKBOa8OeI,5
14
+ spltz_viur_testing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright © 2026 Andreas H. Kelch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ viur
@@ -0,0 +1,321 @@
1
+ """
2
+ viur-testing — safe test-mode for viur-core projects.
3
+
4
+ The package implements a bilateral guarantee for end-to-end tests
5
+ (typically Playwright): the server refuses to boot unless it is wired
6
+ to a dedicated test database, and the runner refuses to start tests
7
+ unless the server confirms that state back. The session token lives
8
+ only in the test database itself — no on-disk handoff.
9
+
10
+ Server side
11
+ ~~~~~~~~~~~
12
+
13
+ - :func:`activate` swaps the datastore client to the target test
14
+ database, runs a synchronous probe roundtrip, primes the in-process
15
+ state on :class:`~viur.testing._test.config.ConfigModule`, and installs
16
+ the request validator. Must be called before any ``viur.core``
17
+ import.
18
+ - :class:`~viur.testing._test.TestModule` is the host-mountable container
19
+ under ``/_test``. It refuses to instantiate outside a local dev
20
+ server — the structural last line of defence against an accidental
21
+ production mount. It carries
22
+ :class:`~viur.testing._test.config.ConfigModule` as a submodule under
23
+ ``config``, exposing ``POST /json/_test/config/status`` and
24
+ ``POST /json/_test/config/finish``.
25
+ - :class:`~viur.testing.validator.TokenValidator` rejects every
26
+ non-bootstrap request that lacks the matching ``X-Viur-Test-Token``
27
+ header.
28
+
29
+ Runner side
30
+ ~~~~~~~~~~~
31
+
32
+ - :func:`require_test_mode` is the preflight check.
33
+ - :func:`finish` tells the server to drop the token entity.
34
+
35
+ Importing the heavy classes
36
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
37
+
38
+ :class:`TestModule`, :class:`ConfigModule`, :class:`TokenValidator` and
39
+ :class:`ProductionGuardValidator` all inherit from viur-core base
40
+ classes at class-definition time — importing any of them triggers the
41
+ full ``viur.core/__init__.py`` chain, which loads
42
+ ``viur.core.db.transport`` and instantiates the default
43
+ ``datastore.Client``. That is exactly what :func:`activate` is trying
44
+ to swap *before* it ever runs.
45
+
46
+ This top-level package therefore deliberately does **not** re-export
47
+ those classes. Import them from their concrete submodules, and only
48
+ *after* :func:`activate` has finished:
49
+
50
+ - :class:`~viur.testing._test.TestModule` →
51
+ ``from viur.testing._test import TestModule``
52
+ - :class:`~viur.testing.validator.TokenValidator`,
53
+ :class:`~viur.testing.validator.ProductionGuardValidator` →
54
+ used internally by :func:`activate` / :func:`protect`; the host
55
+ rarely needs to touch them.
56
+ """
57
+
58
+ import os as _os
59
+
60
+ from .activation import activate
61
+ from .constants import DEFAULT_DATABASE, TOKEN_HEADER
62
+ from .protection import protect
63
+ from .runner import ServerStatus, TestModePreflightError, finish, require_test_mode
64
+
65
+ __version__ = "0.1.0"
66
+
67
+ __all__ = [
68
+ "DEFAULT_DATABASE",
69
+ "ServerStatus",
70
+ "TOKEN_HEADER",
71
+ "TestModePreflightError",
72
+ "activate",
73
+ "finish",
74
+ "protect",
75
+ "register_finish_hook",
76
+ "register_modules",
77
+ "register_status_hook",
78
+ "register_test_submodule",
79
+ "require_test_mode",
80
+ "setup",
81
+ ]
82
+
83
+
84
+ def register_status_hook(hook) -> None:
85
+ """Register a project callback that runs inside ``/_test/config/status``.
86
+
87
+ The hook is invoked after the session token has been issued and
88
+ in-process state primed; if it returns a dict, the entries are
89
+ merged into the response payload. Use this to inject
90
+ project-specific configuration that the e2e runner needs to know
91
+ about (feature flags, generated IDs, seed data references, …).
92
+
93
+ Thin wrapper around
94
+ :meth:`viur.testing._test.config.ConfigModule.register_status_hook`.
95
+ Typical wiring lives in ``deploy/test/__init__.py``.
96
+
97
+ :param hook: ``() -> dict | None`` callable.
98
+ """
99
+ from ._test.config import ConfigModule # noqa: PLC0415
100
+
101
+ ConfigModule.register_status_hook(hook)
102
+
103
+
104
+ def register_finish_hook(hook) -> None:
105
+ """Register a project callback that runs inside ``/_test/config/finish``.
106
+
107
+ Same shape as :func:`register_status_hook`: optional dict return
108
+ is merged into the finish response. Useful for cleanup
109
+ confirmation, summary info, or project-specific shutdown hooks.
110
+
111
+ :param hook: ``() -> dict | None`` callable.
112
+ """
113
+ from ._test.config import ConfigModule # noqa: PLC0415
114
+
115
+ ConfigModule.register_finish_hook(hook)
116
+
117
+
118
+ def register_test_submodule(name: str, module_cls: type) -> None:
119
+ """Mount a host-provided submodule under ``/_test/<name>/...``.
120
+
121
+ Use this to attach project-specific test fixtures (setup,
122
+ teardown, seed data, …) to the same ``/_test`` container that
123
+ carries the built-in :class:`~viur.testing._test.config.ConfigModule`.
124
+
125
+ Recommended convention: one submodule per e2e spec file, named
126
+ after the spec — ``tests/auth/userLogin.spec.ts`` ↔ ``/_test/userLogin/``
127
+ with methods ``setup`` and ``teardown``. This keeps test fixtures
128
+ co-located with the tests that need them.
129
+
130
+ The registration is stored on :class:`~viur.testing._test.TestModule`
131
+ and consumed at mount time. Call this **before** ``viur.core.setup()``
132
+ runs — typically right after ``register_modules`` in your host's
133
+ ``modules/__init__.py``.
134
+
135
+ Production-safe: if test mode is not armed (``VIUR_TESTING_ENABLE``
136
+ unset), ``TestModule`` is never mounted and the registration has
137
+ no observable effect.
138
+
139
+ :param name: URL segment under ``/_test/``. Must not collide with
140
+ viur-testing's reserved names (currently ``config``).
141
+ :param module_cls: A ``viur.core.Module`` subclass with the
142
+ endpoints the test needs.
143
+ :raises ValueError: when ``name`` is reserved or empty.
144
+ """
145
+ from ._test import TestModule # noqa: PLC0415
146
+
147
+ TestModule.register_submodule(name, module_cls)
148
+
149
+
150
+ def setup(
151
+ *,
152
+ enable_env_var: str = "VIUR_TESTING_ENABLE",
153
+ database: str = DEFAULT_DATABASE,
154
+ namespace: str | None = None,
155
+ namespace_env_var: str = "VIUR_TESTING_NAMESPACE",
156
+ api_dir: str | None = "testing",
157
+ ) -> None:
158
+ """One-call host-side wiring for ``main.py``.
159
+
160
+ Must be the **first** line of code in ``main.py`` — before any
161
+ ``from viur.core ...`` import. Internally:
162
+
163
+ 1. Reads ``os.environ[enable_env_var]`` (default
164
+ ``VIUR_TESTING_ENABLE``). If truthy, calls :func:`activate`
165
+ which swaps the datastore client to ``database`` (default
166
+ ``viur-tests``) and the optional ``namespace``, runs the probe
167
+ and installs the request validator.
168
+ 2. Calls :func:`protect` unconditionally to install the
169
+ production header guard.
170
+
171
+ Namespace resolution: if ``namespace`` is not given to this call,
172
+ ``os.environ[namespace_env_var]`` is consulted. An empty string is
173
+ treated as "no namespace" — the host can clear an inherited env
174
+ var by exporting ``VIUR_TESTING_NAMESPACE=``. This makes it easy
175
+ to give different testers their own slice of the same
176
+ ``viur-tests`` database without changing ``main.py``::
177
+
178
+ $ VIUR_TESTING_ENABLE=1 VIUR_TESTING_NAMESPACE=ak viur run
179
+
180
+ Use the env var, **not** ``conf.instance.is_dev_server``, as the
181
+ gate — reading ``conf.instance`` triggers the full ``viur.core``
182
+ import chain (including ``viur.core.db.transport``), which would
183
+ leave :func:`activate` with no chance to patch the singleton.
184
+
185
+ Typical host wiring::
186
+
187
+ # main.py
188
+ import viur.testing
189
+ viur.testing.setup()
190
+
191
+ from viur.core import setup as core_setup
192
+ import modules, render
193
+ app = core_setup(modules, render)
194
+
195
+ :param enable_env_var: Name of the env var that gates
196
+ :func:`activate`. Default ``VIUR_TESTING_ENABLE``.
197
+ :param database: Name of the test database to swap to. Default
198
+ ``viur-tests``.
199
+ :param namespace: Optional Datastore namespace. When ``None``, the
200
+ env var named in ``namespace_env_var`` is consulted as
201
+ fallback.
202
+ :param namespace_env_var: Name of the env var to read when
203
+ ``namespace`` is not given. Default ``VIUR_TESTING_NAMESPACE``.
204
+ :param api_dir: Name of the wrapper directory (relative to the
205
+ caller's parent dir) that contains an ``api/`` subfolder
206
+ with the project test API package. ``setup()`` loads
207
+ ``<api_dir>/api/__init__.py`` and registers it as the
208
+ top-level Python package ``api`` via ``importlib`` — no
209
+ ``sys.path`` manipulation, no sibling-directory exposure.
210
+
211
+ Default: ``"testing"`` — resolves to
212
+ ``<dirname(main.py)>/../testing/api/`` and matches the
213
+ convention ``testing/api/`` (backend fixtures) +
214
+ ``testing/e2e/`` (Playwright). Pass any other string to
215
+ relocate the wrapper, or ``None`` to skip the project
216
+ test API entirely.
217
+
218
+ If the resolved ``__init__.py`` does not exist, a one-line
219
+ info message is printed and setup continues — that helps
220
+ spot misconfigurations early (you'd otherwise see mysterious
221
+ ``404 /_test/<spec>/setup`` errors from the runner side).
222
+ """
223
+ if _os.environ.get(enable_env_var):
224
+ if namespace is None:
225
+ namespace = _os.environ.get(namespace_env_var) or None
226
+ activate(database=database, namespace=namespace)
227
+ if api_dir is not None:
228
+ _load_project_api(api_dir)
229
+ protect()
230
+
231
+
232
+ def _load_project_api(api_dir: str, caller_file: str | None = None) -> None:
233
+ """Resolve ``<caller_parent>/<api_dir>/api/__init__.py`` and
234
+ register it as top-level Python package ``api``.
235
+
236
+ The relative path is anchored at the host's ``main.py``; when
237
+ invoked from :func:`setup` we walk the stack via
238
+ ``inspect.stack`` to find that file. Tests can pass
239
+ ``caller_file`` directly to bypass the stack walk.
240
+
241
+ :param api_dir: Wrapper directory name (e.g. ``"testing"``).
242
+ :param caller_file: Override for the host file used to anchor the
243
+ relative path. When ``None``, walks the call stack.
244
+ """
245
+ if caller_file is None:
246
+ import inspect # noqa: PLC0415
247
+ # stack[0]=this fn, stack[1]=setup, stack[2]=caller of setup
248
+ caller_file = inspect.stack()[2].filename
249
+ api_init = _os.path.abspath(_os.path.join(
250
+ _os.path.dirname(caller_file), "..", api_dir, "api", "__init__.py",
251
+ ))
252
+ _load_api_package(api_init)
253
+
254
+
255
+ def _load_api_package(api_init: str) -> None:
256
+ """Register the package at ``api_init`` as the top-level Python
257
+ package ``api``.
258
+
259
+ ``api_init`` is the absolute path to an ``__init__.py``. Uses
260
+ ``importlib.util.spec_from_file_location`` so only the one
261
+ package is exposed — ``sys.path`` is left untouched. If the file
262
+ is missing, prints a clear info line and returns; the rest of
263
+ test-mode setup keeps running.
264
+ """
265
+ import importlib.util # noqa: PLC0415
266
+ import sys # noqa: PLC0415
267
+
268
+ if not _os.path.isfile(api_init):
269
+ print(
270
+ f"[viur-testing] no api package found at {api_init!r} — "
271
+ "project-specific test fixtures will not be loaded. "
272
+ "Pass `api_dir=<wrapper>` to viur.testing.setup() pointing "
273
+ "at a wrapper directory that contains an api/ subfolder.",
274
+ )
275
+ return
276
+
277
+ spec = importlib.util.spec_from_file_location(
278
+ "api", api_init,
279
+ submodule_search_locations=[_os.path.dirname(api_init)],
280
+ )
281
+ if spec is None or spec.loader is None: # pragma: no cover — only happens on a corrupted file system
282
+ print(f"[viur-testing] could not build import spec for {api_init!r}")
283
+ return
284
+
285
+ module = importlib.util.module_from_spec(spec)
286
+ sys.modules["api"] = module
287
+ spec.loader.exec_module(module)
288
+ print(f"[viur-testing] loaded project api package from {api_init!r}")
289
+
290
+
291
+ def register_modules(target: dict) -> None:
292
+ """Inject :class:`~viur.testing._test.TestModule` into the host's
293
+ ``modules/`` namespace if test mode is active.
294
+
295
+ Call from ``modules/__init__.py`` after the auto-discovery loop —
296
+ typically::
297
+
298
+ # modules/__init__.py
299
+ from viur.testing import register_modules
300
+ register_modules(globals())
301
+
302
+ Idempotent: if :func:`activate` has not run (test mode not armed)
303
+ this is a no-op, so the same line stays in place for both dev and
304
+ production deployments.
305
+
306
+ ``TestModule`` is registered as a **class**, not an instance, so
307
+ viur-core's ``__build_app`` (which scans ``vars(modules)`` for
308
+ Module subclasses) picks it up and routes ``/_test/config/*``
309
+ through it.
310
+
311
+ :param target: The ``modules/__init__.py`` global namespace dict,
312
+ typically ``globals()``.
313
+ """
314
+ from ._test.config import ConfigModule # noqa: PLC0415
315
+
316
+ if not ConfigModule.is_active():
317
+ return # test mode not armed — nothing to mount
318
+
319
+ from ._test import TestModule # noqa: PLC0415
320
+
321
+ target["_test"] = TestModule