kernia-test-utils 0.1.0__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.
- kernia_test_utils-0.1.0/.gitignore +38 -0
- kernia_test_utils-0.1.0/LICENSE +21 -0
- kernia_test_utils-0.1.0/PKG-INFO +72 -0
- kernia_test_utils-0.1.0/README.md +36 -0
- kernia_test_utils-0.1.0/pyproject.toml +63 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/__init__.py +56 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/adapter_fixtures.py +143 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/asgi_driver.py +107 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/containers.py +124 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/mock_idp.py +191 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/mock_saml_idp.py +268 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/mock_sms.py +41 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/mock_smtp.py +60 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/mock_stripe.py +637 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/py.typed +0 -0
- kernia_test_utils-0.1.0/src/kernia_test_utils/soft_webauthn.py +210 -0
- kernia_test_utils-0.1.0/tests/test_mock_idp.py +89 -0
- kernia_test_utils-0.1.0/tests/test_mock_saml_idp.py +133 -0
- kernia_test_utils-0.1.0/tests/test_mock_sms.py +45 -0
- kernia_test_utils-0.1.0/tests/test_mock_smtp.py +55 -0
- kernia_test_utils-0.1.0/tests/test_mock_stripe.py +89 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
.uv/
|
|
7
|
+
.mypy_cache/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
|
|
15
|
+
# Editors
|
|
16
|
+
.idea/
|
|
17
|
+
.vscode/
|
|
18
|
+
*.swp
|
|
19
|
+
.DS_Store
|
|
20
|
+
|
|
21
|
+
# Docs build output
|
|
22
|
+
/site/
|
|
23
|
+
docs/site/
|
|
24
|
+
|
|
25
|
+
# Internal tooling (not part of the public repo)
|
|
26
|
+
scripts/audit_layout.py
|
|
27
|
+
scripts/setup_kernia_dns.sh
|
|
28
|
+
spec/
|
|
29
|
+
|
|
30
|
+
# Local
|
|
31
|
+
|
|
32
|
+
.projects/cache
|
|
33
|
+
.projects/vault
|
|
34
|
+
.projects/state.test.json
|
|
35
|
+
.projects/state.local.test.json
|
|
36
|
+
.env
|
|
37
|
+
.env.local
|
|
38
|
+
.env.test
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Advantch
|
|
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,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kernia-test-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared test utilities for Kernia
|
|
5
|
+
Project-URL: Homepage, https://kernia.dev
|
|
6
|
+
Project-URL: Documentation, https://kernia.dev/docs
|
|
7
|
+
Project-URL: Source, https://github.com/advantch/kernia
|
|
8
|
+
Project-URL: Issues, https://github.com/advantch/kernia/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/advantch/kernia/releases
|
|
10
|
+
Author: Advantch
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: asgi,authentication,authorization,django,fastapi,oauth,passkeys,security,sessions,sso,starlette
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: cbor2>=5
|
|
26
|
+
Requires-Dist: cryptography>=42
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: kernia>=0.1.0
|
|
29
|
+
Requires-Dist: lxml>=5
|
|
30
|
+
Requires-Dist: webauthn>=2.7
|
|
31
|
+
Provides-Extra: pytest
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'pytest'
|
|
33
|
+
Provides-Extra: testcontainers
|
|
34
|
+
Requires-Dist: testcontainers>=4; extra == 'testcontainers'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# kernia-test-utils
|
|
38
|
+
|
|
39
|
+
Shared test utilities for Kernia: an ASGI driver, mock OIDC/SAML identity providers, SMTP/SMS capture, a Stripe REST mock, a software WebAuthn authenticator, and lazy testcontainers fixtures.
|
|
40
|
+
|
|
41
|
+
Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
pip install kernia-test-utils
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from kernia_test_utils import ASGIDriver, MockSMTP
|
|
51
|
+
|
|
52
|
+
# Drive an ASGI app like an HTTP client, no server needed.
|
|
53
|
+
driver = ASGIDriver(app=auth.router.mount())
|
|
54
|
+
response = await driver.post("/api/auth/sign-up/email", json={
|
|
55
|
+
"email": "user@example.com",
|
|
56
|
+
"password": "correct-horse",
|
|
57
|
+
"name": "User",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
# Capture outgoing email in tests.
|
|
61
|
+
smtp = MockSMTP()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Also provides `MockIdP`, `MockSAMLIdP`, `MockStripe`, `MockSMS`, `SoftAuthenticator`, and container fixtures behind `requires_docker`.
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# kernia-test-utils
|
|
2
|
+
|
|
3
|
+
Shared test utilities for Kernia: an ASGI driver, mock OIDC/SAML identity providers, SMTP/SMS capture, a Stripe REST mock, a software WebAuthn authenticator, and lazy testcontainers fixtures.
|
|
4
|
+
|
|
5
|
+
Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
pip install kernia-test-utils
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from kernia_test_utils import ASGIDriver, MockSMTP
|
|
15
|
+
|
|
16
|
+
# Drive an ASGI app like an HTTP client, no server needed.
|
|
17
|
+
driver = ASGIDriver(app=auth.router.mount())
|
|
18
|
+
response = await driver.post("/api/auth/sign-up/email", json={
|
|
19
|
+
"email": "user@example.com",
|
|
20
|
+
"password": "correct-horse",
|
|
21
|
+
"name": "User",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Capture outgoing email in tests.
|
|
25
|
+
smtp = MockSMTP()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Also provides `MockIdP`, `MockSAMLIdP`, `MockStripe`, `MockSMS`, `SoftAuthenticator`, and container fixtures behind `requires_docker`.
|
|
29
|
+
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kernia-test-utils"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared test utilities for Kernia"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"kernia>=0.1.0",
|
|
8
|
+
"cryptography>=42",
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"cbor2>=5",
|
|
11
|
+
"webauthn>=2.7",
|
|
12
|
+
"lxml>=5",
|
|
13
|
+
]
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
license = "MIT"
|
|
16
|
+
license-files = [
|
|
17
|
+
"LICENSE",
|
|
18
|
+
]
|
|
19
|
+
authors = [
|
|
20
|
+
{name = "Advantch"},
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
"authentication",
|
|
24
|
+
"authorization",
|
|
25
|
+
"sessions",
|
|
26
|
+
"oauth",
|
|
27
|
+
"passkeys",
|
|
28
|
+
"sso",
|
|
29
|
+
"asgi",
|
|
30
|
+
"fastapi",
|
|
31
|
+
"starlette",
|
|
32
|
+
"django",
|
|
33
|
+
"security",
|
|
34
|
+
]
|
|
35
|
+
classifiers = [
|
|
36
|
+
"Development Status :: 4 - Beta",
|
|
37
|
+
"Intended Audience :: Developers",
|
|
38
|
+
"Operating System :: OS Independent",
|
|
39
|
+
"Programming Language :: Python :: 3",
|
|
40
|
+
"Programming Language :: Python :: 3.11",
|
|
41
|
+
"Programming Language :: Python :: 3.12",
|
|
42
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
43
|
+
"Topic :: Internet :: WWW/HTTP :: Session",
|
|
44
|
+
"Topic :: Security",
|
|
45
|
+
"Typing :: Typed",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.optional-dependencies]
|
|
49
|
+
testcontainers = ["testcontainers>=4"]
|
|
50
|
+
pytest = ["pytest>=8"]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://kernia.dev"
|
|
54
|
+
Documentation = "https://kernia.dev/docs"
|
|
55
|
+
Source = "https://github.com/advantch/kernia"
|
|
56
|
+
Issues = "https://github.com/advantch/kernia/issues"
|
|
57
|
+
Changelog = "https://github.com/advantch/kernia/releases"
|
|
58
|
+
[build-system]
|
|
59
|
+
requires = ["hatchling"]
|
|
60
|
+
build-backend = "hatchling.build"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/kernia_test_utils"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared test fixtures.
|
|
2
|
+
|
|
3
|
+
Mirrors `reference/packages/test-utils/`. Exposes:
|
|
4
|
+
|
|
5
|
+
* `ASGIDriver` / `ASGIResponse` — call an ASGI app like a client.
|
|
6
|
+
* `MockIdP` — in-process OIDC IdP with signed id_tokens.
|
|
7
|
+
* `MockSMTP` / `SentEmail` — capture outgoing emails.
|
|
8
|
+
* `MockSMS` / `SentSMS` — capture outgoing SMS.
|
|
9
|
+
* `MockStripe` — Stripe REST mock + signed-webhook helper.
|
|
10
|
+
* `MockSAMLIdP` — minimal signed SAML 2.0 IdP fixture.
|
|
11
|
+
* Container helpers — lazy testcontainers fixtures behind `requires_docker`.
|
|
12
|
+
* `all_adapters_param` — pytest parametrize value covering every backend.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from kernia_test_utils.adapter_fixtures import (
|
|
16
|
+
AdapterFactory,
|
|
17
|
+
adapter_cleanup,
|
|
18
|
+
all_adapters_param,
|
|
19
|
+
)
|
|
20
|
+
from kernia_test_utils.asgi_driver import ASGIDriver, ASGIResponse
|
|
21
|
+
from kernia_test_utils.containers import (
|
|
22
|
+
docker_available,
|
|
23
|
+
mongodb_container,
|
|
24
|
+
mysql_container,
|
|
25
|
+
postgres_container,
|
|
26
|
+
redis_container,
|
|
27
|
+
requires_docker,
|
|
28
|
+
)
|
|
29
|
+
from kernia_test_utils.mock_idp import MockIdP
|
|
30
|
+
from kernia_test_utils.mock_saml_idp import MockSAMLIdP
|
|
31
|
+
from kernia_test_utils.mock_sms import MockSMS, SentSMS
|
|
32
|
+
from kernia_test_utils.mock_smtp import MockSMTP, SentEmail
|
|
33
|
+
from kernia_test_utils.mock_stripe import MockStripe
|
|
34
|
+
from kernia_test_utils.soft_webauthn import SoftAuthenticator
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ASGIDriver",
|
|
38
|
+
"ASGIResponse",
|
|
39
|
+
"AdapterFactory",
|
|
40
|
+
"MockIdP",
|
|
41
|
+
"MockSAMLIdP",
|
|
42
|
+
"MockSMS",
|
|
43
|
+
"MockSMTP",
|
|
44
|
+
"MockStripe",
|
|
45
|
+
"SentEmail",
|
|
46
|
+
"SentSMS",
|
|
47
|
+
"SoftAuthenticator",
|
|
48
|
+
"adapter_cleanup",
|
|
49
|
+
"all_adapters_param",
|
|
50
|
+
"docker_available",
|
|
51
|
+
"mongodb_container",
|
|
52
|
+
"mysql_container",
|
|
53
|
+
"postgres_container",
|
|
54
|
+
"redis_container",
|
|
55
|
+
"requires_docker",
|
|
56
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Cross-adapter parametrization helper.
|
|
2
|
+
|
|
3
|
+
Plugin integration tests do:
|
|
4
|
+
|
|
5
|
+
@pytest.mark.parametrize(*all_adapters_param())
|
|
6
|
+
async def test_thing(adapter_factory):
|
|
7
|
+
adapter = await adapter_factory()
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
The factory returns a fresh adapter with a fresh DB. Schema is created
|
|
11
|
+
per-test; for SQLAlchemy the engine is disposed after the test runs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from kernia_test_utils.containers import docker_available, postgres_container
|
|
22
|
+
|
|
23
|
+
AdapterFactory = Callable[[], Awaitable[Any]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _memory_factory() -> Any:
|
|
27
|
+
from kernia_memory_adapter import memory_adapter
|
|
28
|
+
|
|
29
|
+
return memory_adapter()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _sqlite_factory() -> Any:
|
|
33
|
+
# Each call gets its own in-memory database — they're isolated even when
|
|
34
|
+
# several tests run concurrently because the URL contains a fresh secret.
|
|
35
|
+
import secrets
|
|
36
|
+
|
|
37
|
+
from kernia_sqlalchemy import sqlalchemy_adapter
|
|
38
|
+
|
|
39
|
+
url = f"sqlite+aiosqlite:///file:{secrets.token_hex(8)}?mode=memory&cache=shared&uri=true"
|
|
40
|
+
return await sqlalchemy_adapter(url=url)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _postgres_url_factory() -> Callable[[], Awaitable[Any]]:
|
|
44
|
+
"""Return an adapter-factory bound to a single Postgres container.
|
|
45
|
+
|
|
46
|
+
The container is started lazily on first use and stopped via an atexit
|
|
47
|
+
handler. Each adapter call gets a fresh database name on that container.
|
|
48
|
+
"""
|
|
49
|
+
state: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
async def factory() -> Any:
|
|
52
|
+
from kernia_sqlalchemy import sqlalchemy_adapter
|
|
53
|
+
|
|
54
|
+
if "url" not in state:
|
|
55
|
+
import atexit
|
|
56
|
+
|
|
57
|
+
ctx = postgres_container()
|
|
58
|
+
url = ctx.__enter__()
|
|
59
|
+
state["ctx"] = ctx
|
|
60
|
+
state["url"] = url
|
|
61
|
+
atexit.register(lambda: ctx.__exit__(None, None, None))
|
|
62
|
+
# NOTE: tests share the same database; rely on per-test transactional
|
|
63
|
+
# rollback at the adapter layer. For now we just hand out adapters
|
|
64
|
+
# against the shared URL — schema is idempotent (CREATE IF NOT EXISTS).
|
|
65
|
+
return await sqlalchemy_adapter(url=state["url"])
|
|
66
|
+
|
|
67
|
+
return factory
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def all_adapters_param() -> tuple[str, list[Any]]:
|
|
71
|
+
"""Return `("adapter_factory", [...])` for pytest.mark.parametrize.
|
|
72
|
+
|
|
73
|
+
Each entry is a `pytest.param(factory, id=..., marks=...)`. Containers
|
|
74
|
+
that need Docker are wrapped in `pytest.mark.skipif` when Docker is
|
|
75
|
+
unavailable.
|
|
76
|
+
"""
|
|
77
|
+
has_docker = docker_available()
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
"adapter_factory",
|
|
81
|
+
[
|
|
82
|
+
pytest.param(_memory_factory, id="memory"),
|
|
83
|
+
pytest.param(_sqlite_factory, id="sqlalchemy-sqlite"),
|
|
84
|
+
pytest.param(
|
|
85
|
+
_postgres_url_factory(),
|
|
86
|
+
id="sqlalchemy-postgres",
|
|
87
|
+
marks=pytest.mark.skipif(not has_docker, reason="Docker required"),
|
|
88
|
+
),
|
|
89
|
+
pytest.param(
|
|
90
|
+
_mongo_url_factory(),
|
|
91
|
+
id="mongo",
|
|
92
|
+
marks=pytest.mark.skipif(not has_docker, reason="Docker required for mongo"),
|
|
93
|
+
),
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mongo_url_factory() -> Callable[[], Awaitable[Any]]:
|
|
99
|
+
"""Return an adapter-factory bound to a single MongoDB container.
|
|
100
|
+
|
|
101
|
+
Mirrors `_postgres_url_factory`: the container starts lazily on first use
|
|
102
|
+
and is stopped via an atexit handler, so it outlives each test body (a
|
|
103
|
+
`with` block here would tear the container down before the test runs).
|
|
104
|
+
Each call gets a fresh database name on the shared container, keeping
|
|
105
|
+
parametrized tests isolated.
|
|
106
|
+
"""
|
|
107
|
+
state: dict[str, Any] = {}
|
|
108
|
+
|
|
109
|
+
async def factory() -> Any:
|
|
110
|
+
try:
|
|
111
|
+
from kernia_mongo import mongo_adapter
|
|
112
|
+
except ImportError:
|
|
113
|
+
pytest.skip("kernia_mongo is not installed")
|
|
114
|
+
|
|
115
|
+
if "url" not in state:
|
|
116
|
+
import atexit
|
|
117
|
+
|
|
118
|
+
from kernia_test_utils.containers import mongodb_container
|
|
119
|
+
|
|
120
|
+
ctx = mongodb_container()
|
|
121
|
+
state["ctx"] = ctx
|
|
122
|
+
state["url"] = ctx.__enter__()
|
|
123
|
+
atexit.register(lambda: ctx.__exit__(None, None, None))
|
|
124
|
+
|
|
125
|
+
import secrets
|
|
126
|
+
|
|
127
|
+
return await mongo_adapter(url=state["url"], db_name=f"kernia_test_{secrets.token_hex(4)}")
|
|
128
|
+
|
|
129
|
+
return factory
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.fixture(autouse=False)
|
|
133
|
+
async def adapter_cleanup() -> Any:
|
|
134
|
+
"""Per-test cleanup hook.
|
|
135
|
+
|
|
136
|
+
Tests that use the parametrized factory can opt in by adding this fixture.
|
|
137
|
+
It currently just yields — adapter teardown is the factory's
|
|
138
|
+
responsibility — but it's the seam where future global cleanup will live.
|
|
139
|
+
"""
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ["AdapterFactory", "adapter_cleanup", "all_adapters_param"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ASGIDriver — calls an ASGI app like a client without a real server.
|
|
2
|
+
|
|
3
|
+
Maintains a cookie jar between calls so tests can chain sign-up → get-session.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ASGIResponse:
|
|
16
|
+
status: int
|
|
17
|
+
headers: tuple[tuple[str, str], ...]
|
|
18
|
+
body: bytes
|
|
19
|
+
|
|
20
|
+
def json(self) -> Any:
|
|
21
|
+
if not self.body:
|
|
22
|
+
return None
|
|
23
|
+
return json.loads(self.body.decode("utf-8"))
|
|
24
|
+
|
|
25
|
+
def set_cookies(self) -> dict[str, str]:
|
|
26
|
+
out: dict[str, str] = {}
|
|
27
|
+
for k, v in self.headers:
|
|
28
|
+
if k.lower() != "set-cookie":
|
|
29
|
+
continue
|
|
30
|
+
name, _, rest = v.partition("=")
|
|
31
|
+
value, _, _attrs = rest.partition(";")
|
|
32
|
+
out[name.strip()] = value
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ASGIDriver:
|
|
38
|
+
app: Callable[..., Awaitable[None]]
|
|
39
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
async def request(
|
|
42
|
+
self,
|
|
43
|
+
method: str,
|
|
44
|
+
path: str,
|
|
45
|
+
*,
|
|
46
|
+
json_body: Any = None,
|
|
47
|
+
headers: Mapping[str, str] | None = None,
|
|
48
|
+
query: str = "",
|
|
49
|
+
) -> ASGIResponse:
|
|
50
|
+
body_bytes = b""
|
|
51
|
+
req_headers: list[tuple[bytes, bytes]] = []
|
|
52
|
+
for k, v in (headers or {}).items():
|
|
53
|
+
req_headers.append((k.lower().encode("latin-1"), v.encode("latin-1")))
|
|
54
|
+
if json_body is not None:
|
|
55
|
+
body_bytes = json.dumps(json_body).encode("utf-8")
|
|
56
|
+
if not any(k == b"content-type" for k, _ in req_headers):
|
|
57
|
+
req_headers.append((b"content-type", b"application/json"))
|
|
58
|
+
if self.cookies:
|
|
59
|
+
cookie_header = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
|
|
60
|
+
req_headers.append((b"cookie", cookie_header.encode("latin-1")))
|
|
61
|
+
|
|
62
|
+
scope = {
|
|
63
|
+
"type": "http",
|
|
64
|
+
"method": method,
|
|
65
|
+
"path": path,
|
|
66
|
+
"query_string": query.encode("utf-8"),
|
|
67
|
+
"headers": req_headers,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sent_body = b""
|
|
71
|
+
more = True
|
|
72
|
+
|
|
73
|
+
async def receive() -> dict:
|
|
74
|
+
nonlocal more
|
|
75
|
+
if more:
|
|
76
|
+
more = False
|
|
77
|
+
return {"type": "http.request", "body": body_bytes, "more_body": False}
|
|
78
|
+
return {"type": "http.disconnect"}
|
|
79
|
+
|
|
80
|
+
captured: dict[str, Any] = {"status": None, "headers": [], "body": b""}
|
|
81
|
+
|
|
82
|
+
async def send(msg: dict) -> None:
|
|
83
|
+
nonlocal sent_body
|
|
84
|
+
if msg["type"] == "http.response.start":
|
|
85
|
+
captured["status"] = msg["status"]
|
|
86
|
+
captured["headers"] = msg.get("headers", [])
|
|
87
|
+
elif msg["type"] == "http.response.body":
|
|
88
|
+
sent_body += msg.get("body") or b""
|
|
89
|
+
|
|
90
|
+
await self.app(scope, receive, send)
|
|
91
|
+
|
|
92
|
+
captured["body"] = sent_body
|
|
93
|
+
decoded_headers = tuple(
|
|
94
|
+
(k.decode("latin-1"), v.decode("latin-1")) for k, v in captured["headers"]
|
|
95
|
+
)
|
|
96
|
+
response = ASGIResponse(
|
|
97
|
+
status=captured["status"],
|
|
98
|
+
headers=decoded_headers,
|
|
99
|
+
body=sent_body,
|
|
100
|
+
)
|
|
101
|
+
# Update cookie jar
|
|
102
|
+
for name, value in response.set_cookies().items():
|
|
103
|
+
if value:
|
|
104
|
+
self.cookies[name] = value
|
|
105
|
+
elif name in self.cookies:
|
|
106
|
+
del self.cookies[name]
|
|
107
|
+
return response
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Lazy testcontainers fixtures.
|
|
2
|
+
|
|
3
|
+
Each helper imports `testcontainers` at call-time so the dep stays optional.
|
|
4
|
+
If Docker is not reachable, the call raises an ImportError / RuntimeError; use
|
|
5
|
+
`requires_docker()` to skip cleanly at the test layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _testcontainers_installed() -> bool:
|
|
19
|
+
"""True if the optional `testcontainers` package is importable."""
|
|
20
|
+
import importlib.util
|
|
21
|
+
|
|
22
|
+
return importlib.util.find_spec("testcontainers") is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def docker_available() -> bool:
|
|
26
|
+
"""Best-effort check that container-backed tests can actually run.
|
|
27
|
+
|
|
28
|
+
Requires BOTH a reachable Docker daemon AND the `testcontainers` package.
|
|
29
|
+
If either is missing, the gated suites skip cleanly instead of erroring —
|
|
30
|
+
a Docker daemon with no `testcontainers` install is a common local setup.
|
|
31
|
+
"""
|
|
32
|
+
if not _testcontainers_installed():
|
|
33
|
+
return False
|
|
34
|
+
if shutil.which("docker") is None:
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["docker", "info"],
|
|
39
|
+
check=False,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
timeout=5,
|
|
42
|
+
)
|
|
43
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
44
|
+
return False
|
|
45
|
+
return result.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def requires_docker() -> pytest.MarkDecorator:
|
|
49
|
+
"""Return a pytest mark that skips when Docker isn't reachable."""
|
|
50
|
+
return pytest.mark.skipif(not docker_available(), reason="Docker is not available on this host")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def postgres_container(image: str = "postgres:16-alpine") -> Iterator[str]:
|
|
55
|
+
"""Yield a Postgres connection URL (asyncpg-compatible)."""
|
|
56
|
+
try:
|
|
57
|
+
from testcontainers.postgres import PostgresContainer # type: ignore[import-not-found]
|
|
58
|
+
except ImportError as e: # pragma: no cover
|
|
59
|
+
raise RuntimeError("testcontainers extra is not installed") from e
|
|
60
|
+
container = PostgresContainer(image)
|
|
61
|
+
container.start()
|
|
62
|
+
try:
|
|
63
|
+
# Convert default psycopg2 URL to asyncpg-friendly form.
|
|
64
|
+
url = container.get_connection_url().replace(
|
|
65
|
+
"postgresql+psycopg2://", "postgresql+asyncpg://"
|
|
66
|
+
)
|
|
67
|
+
yield url
|
|
68
|
+
finally:
|
|
69
|
+
container.stop()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextlib.contextmanager
|
|
73
|
+
def mysql_container(image: str = "mysql:8") -> Iterator[str]:
|
|
74
|
+
try:
|
|
75
|
+
from testcontainers.mysql import MySqlContainer # type: ignore[import-not-found]
|
|
76
|
+
except ImportError as e: # pragma: no cover
|
|
77
|
+
raise RuntimeError("testcontainers extra is not installed") from e
|
|
78
|
+
container = MySqlContainer(image)
|
|
79
|
+
container.start()
|
|
80
|
+
try:
|
|
81
|
+
url = container.get_connection_url().replace("mysql+pymysql://", "mysql+aiomysql://")
|
|
82
|
+
yield url
|
|
83
|
+
finally:
|
|
84
|
+
container.stop()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@contextlib.contextmanager
|
|
88
|
+
def mongodb_container(image: str = "mongo:7") -> Iterator[str]:
|
|
89
|
+
try:
|
|
90
|
+
from testcontainers.mongodb import MongoDbContainer # type: ignore[import-not-found]
|
|
91
|
+
except ImportError as e: # pragma: no cover
|
|
92
|
+
raise RuntimeError("testcontainers extra is not installed") from e
|
|
93
|
+
container = MongoDbContainer(image)
|
|
94
|
+
container.start()
|
|
95
|
+
try:
|
|
96
|
+
yield container.get_connection_url()
|
|
97
|
+
finally:
|
|
98
|
+
container.stop()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextlib.contextmanager
|
|
102
|
+
def redis_container(image: str = "redis:7-alpine") -> Iterator[str]:
|
|
103
|
+
try:
|
|
104
|
+
from testcontainers.redis import RedisContainer # type: ignore[import-not-found]
|
|
105
|
+
except ImportError as e: # pragma: no cover
|
|
106
|
+
raise RuntimeError("testcontainers extra is not installed") from e
|
|
107
|
+
container = RedisContainer(image)
|
|
108
|
+
container.start()
|
|
109
|
+
try:
|
|
110
|
+
host = container.get_container_host_ip()
|
|
111
|
+
port = container.get_exposed_port(6379)
|
|
112
|
+
yield f"redis://{host}:{port}/0"
|
|
113
|
+
finally:
|
|
114
|
+
container.stop()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = [
|
|
118
|
+
"docker_available",
|
|
119
|
+
"mongodb_container",
|
|
120
|
+
"mysql_container",
|
|
121
|
+
"postgres_container",
|
|
122
|
+
"redis_container",
|
|
123
|
+
"requires_docker",
|
|
124
|
+
]
|