mockly-testcontainers 0.11.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.
- mockly_testcontainers-0.11.0/.gitignore +72 -0
- mockly_testcontainers-0.11.0/PKG-INFO +105 -0
- mockly_testcontainers-0.11.0/README.md +93 -0
- mockly_testcontainers-0.11.0/pyproject.toml +27 -0
- mockly_testcontainers-0.11.0/src/mockly_testcontainers/__init__.py +4 -0
- mockly_testcontainers-0.11.0/src/mockly_testcontainers/_types.py +32 -0
- mockly_testcontainers-0.11.0/src/mockly_testcontainers/container.py +173 -0
- mockly_testcontainers-0.11.0/tests/test_container.py +113 -0
- mockly_testcontainers-0.11.0/tests/test_integration.py +66 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Binaries
|
|
2
|
+
/mockly
|
|
3
|
+
/mockly.exe
|
|
4
|
+
dist/
|
|
5
|
+
|
|
6
|
+
# UI build
|
|
7
|
+
ui/dist/
|
|
8
|
+
ui/node_modules/
|
|
9
|
+
|
|
10
|
+
# Assets (built by make)
|
|
11
|
+
assets/dist/
|
|
12
|
+
|
|
13
|
+
# Go
|
|
14
|
+
*.test
|
|
15
|
+
*.out
|
|
16
|
+
vendor/
|
|
17
|
+
coverage.txt
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
# Config (user-specific, not example)
|
|
29
|
+
mockly.yaml
|
|
30
|
+
|
|
31
|
+
# Node client
|
|
32
|
+
clients/node/node_modules/
|
|
33
|
+
clients/node/dist/
|
|
34
|
+
clients/node/bin/
|
|
35
|
+
|
|
36
|
+
# Node testcontainers
|
|
37
|
+
clients/node-testcontainers/node_modules/
|
|
38
|
+
clients/node-testcontainers/dist/
|
|
39
|
+
|
|
40
|
+
# Python client
|
|
41
|
+
clients/python/.pytest_cache/
|
|
42
|
+
clients/python/__pycache__/
|
|
43
|
+
clients/python/src/**/__pycache__/
|
|
44
|
+
clients/python/*.egg-info/
|
|
45
|
+
clients/python/dist/
|
|
46
|
+
clients/python/.venv/
|
|
47
|
+
|
|
48
|
+
# Python testcontainers
|
|
49
|
+
clients/python-testcontainers/.venv/
|
|
50
|
+
clients/python-testcontainers/dist/
|
|
51
|
+
clients/python-testcontainers/src/**/__pycache__/
|
|
52
|
+
clients/python-testcontainers/*.egg-info/
|
|
53
|
+
|
|
54
|
+
# Python cache (all locations)
|
|
55
|
+
**/__pycache__/
|
|
56
|
+
**/*.pyc
|
|
57
|
+
|
|
58
|
+
# Java client
|
|
59
|
+
clients/java/target/
|
|
60
|
+
|
|
61
|
+
# Java testcontainers
|
|
62
|
+
clients/java/testcontainers/target/
|
|
63
|
+
|
|
64
|
+
# .NET client
|
|
65
|
+
clients/dotnet/**/bin/
|
|
66
|
+
clients/dotnet/**/obj/
|
|
67
|
+
|
|
68
|
+
# Rust client
|
|
69
|
+
clients/rust/target/
|
|
70
|
+
|
|
71
|
+
# Rust testcontainers
|
|
72
|
+
clients/rust-testcontainers/target/
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mockly-testcontainers
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Testcontainers module for Mockly — run Mockly as a Docker container in tests
|
|
5
|
+
Project-URL: Homepage, https://github.com/dever-labs/mockly/tree/main/clients/python-testcontainers
|
|
6
|
+
Project-URL: Repository, https://github.com/dever-labs/mockly
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: docker,http,integration-test,mock,mockly,testcontainers,testing
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: testcontainers>=4.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# mockly-testcontainers
|
|
14
|
+
|
|
15
|
+
Run Mockly in Docker-backed Python tests with `testcontainers-python`.
|
|
16
|
+
|
|
17
|
+
The package starts `ghcr.io/dever-labs/mockly:latest`, waits for the management API to be ready, and provides helpers for mocks, scenarios, faults, and logs.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Python 3.10+
|
|
22
|
+
- Docker
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install mockly-testcontainers
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
from mockly_testcontainers import Mock, MockRequest, MockResponse, MocklyContainer
|
|
36
|
+
|
|
37
|
+
with MocklyContainer() as container:
|
|
38
|
+
container.add_mock(
|
|
39
|
+
Mock(
|
|
40
|
+
id="get-user",
|
|
41
|
+
request=MockRequest(method="GET", path="/users/1"),
|
|
42
|
+
response=MockResponse(status=200, body='{"id":1}'),
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
with urllib.request.urlopen(f"{container.get_http_base()}/users/1") as response:
|
|
47
|
+
assert response.status == 200
|
|
48
|
+
assert response.read().decode() == '{"id":1}'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## When to use the testcontainers module
|
|
52
|
+
|
|
53
|
+
Use `mockly-testcontainers` when you want Docker-managed lifecycle, no native binary download, or the same Mockly image in local tests and CI.
|
|
54
|
+
|
|
55
|
+
Use `mockly-driver` when you want to run the native Mockly binary directly from Python.
|
|
56
|
+
|
|
57
|
+
## Construction and configuration
|
|
58
|
+
|
|
59
|
+
`MocklyContainer` extends `testcontainers.core.container.DockerContainer`.
|
|
60
|
+
|
|
61
|
+
| API | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `MocklyContainer(image=DEFAULT_IMAGE)` | Create a container using the default Mockly image, or override it with another image name. |
|
|
64
|
+
| `with_inline_config(yaml)` | Replace `/config/mockly.yaml` with inline YAML before startup. |
|
|
65
|
+
| `start()` | Start the container and wait for the management API. |
|
|
66
|
+
| `stop()` | Stop the container and clean up the generated config file. |
|
|
67
|
+
|
|
68
|
+
### Custom YAML config
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
with MocklyContainer().with_inline_config("""mockly:
|
|
72
|
+
api:
|
|
73
|
+
port: 9091
|
|
74
|
+
protocols:
|
|
75
|
+
http:
|
|
76
|
+
enabled: true
|
|
77
|
+
port: 8090
|
|
78
|
+
""") as container:
|
|
79
|
+
...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Management methods
|
|
83
|
+
|
|
84
|
+
| Method | Description |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `get_http_base()` | Base URL of the mock HTTP server. |
|
|
87
|
+
| `get_api_base()` | Base URL of the management API. |
|
|
88
|
+
| `add_mock(mock)` | Register a dynamic HTTP mock. |
|
|
89
|
+
| `delete_mock(mock_id)` | Delete a mock by ID. |
|
|
90
|
+
| `reset()` | Remove dynamic mocks, deactivate scenarios, and clear faults. |
|
|
91
|
+
| `activate_scenario(scenario_id)` | Activate a configured scenario. |
|
|
92
|
+
| `deactivate_scenario(scenario_id)` | Deactivate a configured scenario. |
|
|
93
|
+
| `set_fault(config)` | Apply a global HTTP fault. |
|
|
94
|
+
| `clear_fault()` | Remove the active fault. |
|
|
95
|
+
| `get_logs()` | Read request logs as JSON. |
|
|
96
|
+
| `clear_logs()` | Clear stored request logs. |
|
|
97
|
+
|
|
98
|
+
## Exported types
|
|
99
|
+
|
|
100
|
+
The package exports these dataclasses:
|
|
101
|
+
|
|
102
|
+
- `Mock`
|
|
103
|
+
- `MockRequest`
|
|
104
|
+
- `MockResponse`
|
|
105
|
+
- `FaultConfig`
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# mockly-testcontainers
|
|
2
|
+
|
|
3
|
+
Run Mockly in Docker-backed Python tests with `testcontainers-python`.
|
|
4
|
+
|
|
5
|
+
The package starts `ghcr.io/dever-labs/mockly:latest`, waits for the management API to be ready, and provides helpers for mocks, scenarios, faults, and logs.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.10+
|
|
10
|
+
- Docker
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install mockly-testcontainers
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from mockly_testcontainers import Mock, MockRequest, MockResponse, MocklyContainer
|
|
24
|
+
|
|
25
|
+
with MocklyContainer() as container:
|
|
26
|
+
container.add_mock(
|
|
27
|
+
Mock(
|
|
28
|
+
id="get-user",
|
|
29
|
+
request=MockRequest(method="GET", path="/users/1"),
|
|
30
|
+
response=MockResponse(status=200, body='{"id":1}'),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
with urllib.request.urlopen(f"{container.get_http_base()}/users/1") as response:
|
|
35
|
+
assert response.status == 200
|
|
36
|
+
assert response.read().decode() == '{"id":1}'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## When to use the testcontainers module
|
|
40
|
+
|
|
41
|
+
Use `mockly-testcontainers` when you want Docker-managed lifecycle, no native binary download, or the same Mockly image in local tests and CI.
|
|
42
|
+
|
|
43
|
+
Use `mockly-driver` when you want to run the native Mockly binary directly from Python.
|
|
44
|
+
|
|
45
|
+
## Construction and configuration
|
|
46
|
+
|
|
47
|
+
`MocklyContainer` extends `testcontainers.core.container.DockerContainer`.
|
|
48
|
+
|
|
49
|
+
| API | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `MocklyContainer(image=DEFAULT_IMAGE)` | Create a container using the default Mockly image, or override it with another image name. |
|
|
52
|
+
| `with_inline_config(yaml)` | Replace `/config/mockly.yaml` with inline YAML before startup. |
|
|
53
|
+
| `start()` | Start the container and wait for the management API. |
|
|
54
|
+
| `stop()` | Stop the container and clean up the generated config file. |
|
|
55
|
+
|
|
56
|
+
### Custom YAML config
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
with MocklyContainer().with_inline_config("""mockly:
|
|
60
|
+
api:
|
|
61
|
+
port: 9091
|
|
62
|
+
protocols:
|
|
63
|
+
http:
|
|
64
|
+
enabled: true
|
|
65
|
+
port: 8090
|
|
66
|
+
""") as container:
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Management methods
|
|
71
|
+
|
|
72
|
+
| Method | Description |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `get_http_base()` | Base URL of the mock HTTP server. |
|
|
75
|
+
| `get_api_base()` | Base URL of the management API. |
|
|
76
|
+
| `add_mock(mock)` | Register a dynamic HTTP mock. |
|
|
77
|
+
| `delete_mock(mock_id)` | Delete a mock by ID. |
|
|
78
|
+
| `reset()` | Remove dynamic mocks, deactivate scenarios, and clear faults. |
|
|
79
|
+
| `activate_scenario(scenario_id)` | Activate a configured scenario. |
|
|
80
|
+
| `deactivate_scenario(scenario_id)` | Deactivate a configured scenario. |
|
|
81
|
+
| `set_fault(config)` | Apply a global HTTP fault. |
|
|
82
|
+
| `clear_fault()` | Remove the active fault. |
|
|
83
|
+
| `get_logs()` | Read request logs as JSON. |
|
|
84
|
+
| `clear_logs()` | Clear stored request logs. |
|
|
85
|
+
|
|
86
|
+
## Exported types
|
|
87
|
+
|
|
88
|
+
The package exports these dataclasses:
|
|
89
|
+
|
|
90
|
+
- `Mock`
|
|
91
|
+
- `MockRequest`
|
|
92
|
+
- `MockResponse`
|
|
93
|
+
- `FaultConfig`
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mockly-testcontainers"
|
|
7
|
+
version = "0.11.0"
|
|
8
|
+
description = "Testcontainers module for Mockly — run Mockly as a Docker container in tests"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["mock", "testing", "http", "integration-test", "mockly", "testcontainers", "docker"]
|
|
13
|
+
dependencies = ["testcontainers>=4.0.0"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/dever-labs/mockly/tree/main/clients/python-testcontainers"
|
|
17
|
+
Repository = "https://github.com/dever-labs/mockly"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/mockly_testcontainers"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
addopts = "-m 'not integration'"
|
|
25
|
+
markers = [
|
|
26
|
+
"integration: marks tests that require a running Docker daemon (deselect with '-m not integration')",
|
|
27
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class MockRequest:
|
|
7
|
+
method: str
|
|
8
|
+
path: str
|
|
9
|
+
headers: Optional[dict] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MockResponse:
|
|
14
|
+
status: int
|
|
15
|
+
body: Optional[str] = None
|
|
16
|
+
headers: Optional[dict] = None
|
|
17
|
+
delay: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Mock:
|
|
22
|
+
id: str
|
|
23
|
+
request: MockRequest
|
|
24
|
+
response: MockResponse
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FaultConfig:
|
|
29
|
+
enabled: bool
|
|
30
|
+
delay: Optional[str] = None
|
|
31
|
+
status_override: Optional[int] = None
|
|
32
|
+
error_rate: Optional[float] = None
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from testcontainers.core.container import DockerContainer
|
|
11
|
+
|
|
12
|
+
from ._types import FaultConfig, Mock
|
|
13
|
+
|
|
14
|
+
DEFAULT_IMAGE = "ghcr.io/dever-labs/mockly:latest"
|
|
15
|
+
HTTP_PORT = 8090
|
|
16
|
+
API_PORT = 9091
|
|
17
|
+
CONTAINER_CONFIG_PATH = "/config/mockly.yaml"
|
|
18
|
+
DEFAULT_CONFIG = """mockly:
|
|
19
|
+
api:
|
|
20
|
+
port: 9091
|
|
21
|
+
protocols:
|
|
22
|
+
http:
|
|
23
|
+
enabled: true
|
|
24
|
+
port: 8090
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _without_none(value: Any) -> Any:
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return {
|
|
31
|
+
key: _without_none(item)
|
|
32
|
+
for key, item in value.items()
|
|
33
|
+
if item is not None
|
|
34
|
+
}
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
return [_without_none(item) for item in value]
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MocklyContainer(DockerContainer):
|
|
41
|
+
def __init__(self, image: str = DEFAULT_IMAGE) -> None:
|
|
42
|
+
super().__init__(image)
|
|
43
|
+
self._config_yaml = DEFAULT_CONFIG
|
|
44
|
+
self._config_host_path: str | None = None
|
|
45
|
+
self.with_exposed_ports(HTTP_PORT, API_PORT)
|
|
46
|
+
|
|
47
|
+
def with_inline_config(self, yaml: str) -> "MocklyContainer":
|
|
48
|
+
self._config_yaml = yaml
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def _configure(self) -> None:
|
|
52
|
+
self._cleanup_config_file()
|
|
53
|
+
with tempfile.NamedTemporaryFile(
|
|
54
|
+
mode="w",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
prefix=".mockly-tc-",
|
|
57
|
+
suffix=".yaml",
|
|
58
|
+
dir=os.getcwd(),
|
|
59
|
+
delete=False,
|
|
60
|
+
) as config_file:
|
|
61
|
+
config_file.write(self._config_yaml)
|
|
62
|
+
self._config_host_path = config_file.name
|
|
63
|
+
self.with_command(f"start -c {CONTAINER_CONFIG_PATH}")
|
|
64
|
+
self.with_volume_mapping(self._config_host_path, CONTAINER_CONFIG_PATH, "ro")
|
|
65
|
+
|
|
66
|
+
def _cleanup_config_file(self) -> None:
|
|
67
|
+
if self._config_host_path is None:
|
|
68
|
+
return
|
|
69
|
+
self.volumes.pop(self._config_host_path, None)
|
|
70
|
+
try:
|
|
71
|
+
os.unlink(self._config_host_path)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
pass
|
|
74
|
+
self._config_host_path = None
|
|
75
|
+
|
|
76
|
+
def get_http_base(self) -> str:
|
|
77
|
+
host = self.get_container_host_ip()
|
|
78
|
+
port = self.get_exposed_port(HTTP_PORT)
|
|
79
|
+
return f"http://{host}:{port}"
|
|
80
|
+
|
|
81
|
+
def get_api_base(self) -> str:
|
|
82
|
+
host = self.get_container_host_ip()
|
|
83
|
+
port = self.get_exposed_port(API_PORT)
|
|
84
|
+
return f"http://{host}:{port}"
|
|
85
|
+
|
|
86
|
+
def _wait_ready(self, max_ms: int = 60000) -> None:
|
|
87
|
+
url = f"{self.get_api_base()}/api/protocols"
|
|
88
|
+
deadline = time.monotonic() + max_ms / 1000
|
|
89
|
+
while time.monotonic() < deadline:
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(url, timeout=2):
|
|
92
|
+
return
|
|
93
|
+
except Exception:
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
raise TimeoutError(f"Mockly container did not become ready within {max_ms}ms")
|
|
96
|
+
|
|
97
|
+
def start(self) -> "MocklyContainer":
|
|
98
|
+
try:
|
|
99
|
+
self._configure()
|
|
100
|
+
super().start()
|
|
101
|
+
self._wait_ready()
|
|
102
|
+
return self
|
|
103
|
+
except Exception:
|
|
104
|
+
self.stop()
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
def stop(self, force: bool = True, delete_volume: bool = True) -> None:
|
|
108
|
+
try:
|
|
109
|
+
super().stop(force=force, delete_volume=delete_volume)
|
|
110
|
+
finally:
|
|
111
|
+
self._cleanup_config_file()
|
|
112
|
+
|
|
113
|
+
def add_mock(self, mock: Mock) -> None:
|
|
114
|
+
self._request(
|
|
115
|
+
"POST",
|
|
116
|
+
"/api/mocks/http",
|
|
117
|
+
_without_none(asdict(mock)),
|
|
118
|
+
expected=(200, 201),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def delete_mock(self, mock_id: str) -> None:
|
|
122
|
+
self._request("DELETE", f"/api/mocks/http/{mock_id}", expected=(204,))
|
|
123
|
+
|
|
124
|
+
def reset(self) -> None:
|
|
125
|
+
self._request("POST", "/api/reset", expected=(200,))
|
|
126
|
+
|
|
127
|
+
def activate_scenario(self, scenario_id: str) -> None:
|
|
128
|
+
self._request("POST", f"/api/scenarios/{scenario_id}/activate", expected=(200,))
|
|
129
|
+
|
|
130
|
+
def deactivate_scenario(self, scenario_id: str) -> None:
|
|
131
|
+
self._request("POST", f"/api/scenarios/{scenario_id}/deactivate", expected=(200,))
|
|
132
|
+
|
|
133
|
+
def set_fault(self, config: FaultConfig) -> None:
|
|
134
|
+
self._request(
|
|
135
|
+
"POST",
|
|
136
|
+
"/api/fault",
|
|
137
|
+
_without_none(asdict(config)),
|
|
138
|
+
expected=(200,),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def clear_fault(self) -> None:
|
|
142
|
+
self._request("DELETE", "/api/fault", expected=(200,))
|
|
143
|
+
|
|
144
|
+
def get_logs(self) -> str:
|
|
145
|
+
return self._request("GET", "/api/logs", expected=(200,)).decode()
|
|
146
|
+
|
|
147
|
+
def clear_logs(self) -> None:
|
|
148
|
+
self._request("DELETE", "/api/logs", expected=(200,))
|
|
149
|
+
|
|
150
|
+
def _request(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
path: str,
|
|
154
|
+
body: dict | None = None,
|
|
155
|
+
expected: tuple[int, ...] = (200,),
|
|
156
|
+
) -> bytes:
|
|
157
|
+
url = f"{self.get_api_base()}{path}"
|
|
158
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
159
|
+
headers = {"Content-Type": "application/json"} if data is not None else {}
|
|
160
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(req) as resp:
|
|
163
|
+
if resp.status not in expected:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"Unexpected status {resp.status} for {method} {path}"
|
|
166
|
+
)
|
|
167
|
+
return resp.read()
|
|
168
|
+
except urllib.error.HTTPError as exc:
|
|
169
|
+
if exc.code in expected:
|
|
170
|
+
return exc.read()
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
f"HTTP {exc.code} for {method} {path}: {exc.read().decode(errors='replace')}"
|
|
173
|
+
) from exc
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from dataclasses import asdict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
from mockly_testcontainers import MocklyContainer
|
|
6
|
+
from mockly_testcontainers._types import FaultConfig, Mock, MockRequest, MockResponse
|
|
7
|
+
from mockly_testcontainers.container import (
|
|
8
|
+
API_PORT,
|
|
9
|
+
CONTAINER_CONFIG_PATH,
|
|
10
|
+
DEFAULT_CONFIG,
|
|
11
|
+
DEFAULT_IMAGE,
|
|
12
|
+
HTTP_PORT,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_default_image():
|
|
17
|
+
assert DEFAULT_IMAGE == "ghcr.io/dever-labs/mockly:latest"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_default_ports():
|
|
21
|
+
assert HTTP_PORT == 8090
|
|
22
|
+
assert API_PORT == 9091
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_with_inline_config():
|
|
26
|
+
container = MocklyContainer()
|
|
27
|
+
yaml = "protocols:\n http:\n enabled: true"
|
|
28
|
+
result = container.with_inline_config(yaml)
|
|
29
|
+
assert result is container
|
|
30
|
+
assert container._config_yaml == yaml
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_default_config_contains_api_port():
|
|
34
|
+
container = MocklyContainer()
|
|
35
|
+
assert container._config_yaml == DEFAULT_CONFIG
|
|
36
|
+
assert "api:\n port: 9091" in container._config_yaml
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_configure_sets_command_and_mounts_config():
|
|
40
|
+
container = MocklyContainer().with_inline_config("mockly:\n api:\n port: 9091\n")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
container._configure()
|
|
44
|
+
|
|
45
|
+
assert container._command == f"start -c {CONTAINER_CONFIG_PATH}"
|
|
46
|
+
assert container._config_host_path is not None
|
|
47
|
+
assert Path(container._config_host_path).read_text() == container._config_yaml
|
|
48
|
+
assert container.volumes[container._config_host_path] == {
|
|
49
|
+
"bind": CONTAINER_CONFIG_PATH,
|
|
50
|
+
"mode": "ro",
|
|
51
|
+
}
|
|
52
|
+
finally:
|
|
53
|
+
container._cleanup_config_file()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_mock_json_serialization():
|
|
57
|
+
mock = Mock(
|
|
58
|
+
id="test",
|
|
59
|
+
request=MockRequest(method="GET", path="/hello"),
|
|
60
|
+
response=MockResponse(status=200, body="world"),
|
|
61
|
+
)
|
|
62
|
+
data = asdict(mock)
|
|
63
|
+
assert data["id"] == "test"
|
|
64
|
+
assert data["request"]["method"] == "GET"
|
|
65
|
+
assert data["response"]["status"] == 200
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_add_mock_posts_serialized_body():
|
|
69
|
+
container = MocklyContainer()
|
|
70
|
+
container._request = MagicMock()
|
|
71
|
+
|
|
72
|
+
mock = Mock(
|
|
73
|
+
id="test",
|
|
74
|
+
request=MockRequest(method="GET", path="/hello"),
|
|
75
|
+
response=MockResponse(status=200, body="world"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
container.add_mock(mock)
|
|
79
|
+
|
|
80
|
+
container._request.assert_called_once_with(
|
|
81
|
+
"POST",
|
|
82
|
+
"/api/mocks/http",
|
|
83
|
+
{
|
|
84
|
+
"id": "test",
|
|
85
|
+
"request": {"method": "GET", "path": "/hello"},
|
|
86
|
+
"response": {"status": 200, "body": "world"},
|
|
87
|
+
},
|
|
88
|
+
expected=(200, 201),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_set_fault_posts_config():
|
|
93
|
+
container = MocklyContainer()
|
|
94
|
+
container._request = MagicMock()
|
|
95
|
+
|
|
96
|
+
container.set_fault(FaultConfig(enabled=True, delay="50ms", error_rate=0.1))
|
|
97
|
+
|
|
98
|
+
container._request.assert_called_once_with(
|
|
99
|
+
"POST",
|
|
100
|
+
"/api/fault",
|
|
101
|
+
{"enabled": True, "delay": "50ms", "error_rate": 0.1},
|
|
102
|
+
expected=(200,),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_get_logs_returns_decoded_body():
|
|
107
|
+
container = MocklyContainer()
|
|
108
|
+
container._request = MagicMock(return_value=b'[{"matched_id":"users"}]')
|
|
109
|
+
|
|
110
|
+
logs = container.get_logs()
|
|
111
|
+
|
|
112
|
+
assert logs == '[{"matched_id":"users"}]'
|
|
113
|
+
container._request.assert_called_once_with("GET", "/api/logs", expected=(200,))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Integration tests — require a running Docker daemon.
|
|
2
|
+
|
|
3
|
+
Run with:
|
|
4
|
+
pytest -m integration tests/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from mockly_testcontainers import Mock, MocklyContainer, MockRequest, MockResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
pytestmark = pytest.mark.integration
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="module")
|
|
20
|
+
def container():
|
|
21
|
+
c = MocklyContainer()
|
|
22
|
+
c.start()
|
|
23
|
+
yield c
|
|
24
|
+
c.stop()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_smoke(container):
|
|
28
|
+
assert container.get_http_base().startswith("http://")
|
|
29
|
+
assert container.get_api_base().startswith("http://")
|
|
30
|
+
|
|
31
|
+
with urllib.request.urlopen(f"{container.get_api_base()}/api/protocols") as response:
|
|
32
|
+
assert response.status == 200
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_add_mock_and_reset(container):
|
|
36
|
+
container.add_mock(
|
|
37
|
+
Mock(
|
|
38
|
+
id="hello-mock",
|
|
39
|
+
request=MockRequest(method="GET", path="/hello"),
|
|
40
|
+
response=MockResponse(status=200, body="world"),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
with urllib.request.urlopen(f"{container.get_http_base()}/hello") as response:
|
|
45
|
+
assert response.status == 200
|
|
46
|
+
assert response.read() == b"world"
|
|
47
|
+
|
|
48
|
+
container.reset()
|
|
49
|
+
|
|
50
|
+
with pytest.raises(urllib.error.HTTPError) as exc_info:
|
|
51
|
+
urllib.request.urlopen(f"{container.get_http_base()}/hello")
|
|
52
|
+
|
|
53
|
+
assert exc_info.value.code != 200
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_get_logs(container):
|
|
57
|
+
container.clear_logs()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
urllib.request.urlopen(f"{container.get_http_base()}/logging-path")
|
|
61
|
+
except urllib.error.HTTPError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
logs = container.get_logs()
|
|
65
|
+
assert len(logs) > 0
|
|
66
|
+
json.loads(logs)
|