Skip to content

Commit 3f174ad

Browse files
committed
python: Add unit tests for Svix API client (WIP)
1 parent 7416137 commit 3f174ad

File tree

5 files changed

+325
-6
lines changed

5 files changed

+325
-6
lines changed

.github/workflows/python-tests.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Python Tests
2+
on:
3+
push:
4+
paths:
5+
- "python/**"
6+
- "openapi.json"
7+
pull_request:
8+
paths:
9+
- "python/**"
10+
- "openapi.json"
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Build svix server image
18+
run: docker compose build
19+
working-directory: ./server
20+
21+
- uses: actions/setup-python@v2
22+
name: Install Python
23+
with:
24+
python-version: "3.11"
25+
26+
- name: Install deps
27+
run: |
28+
python -m pip install --upgrade pip
29+
python -m pip install -r requirements.txt .
30+
python -m pip install -r requirements-dev.txt .
31+
working-directory: ./python
32+
33+
- name: Regen openapi libs
34+
run: ./scripts/generate_openapi.sh
35+
working-directory: ./python
36+
37+
- name: Check typing on client tests
38+
run: mypy tests/test_client.py
39+
working-directory: ./python
40+
41+
- name: Run Python tests
42+
run: pytest -sv
43+
working-directory: ./python

python/requirements-dev.txt

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.11
2+
# This file is autogenerated by pip-compile with Python 3.12
33
# by the following command:
44
#
55
# pip-compile --output-file=requirements-dev.txt requirements.in/development.txt
66
#
7-
anyio==4.4.0
8-
# via httpcore
7+
anyio==4.6.0
8+
# via
9+
# httpcore
10+
# httpx
911
attrs==24.2.0
10-
# via openapi-python-client
12+
# via
13+
# openapi-python-client
14+
# pytest-docker
1115
autoflake==2.3.1
1216
# via openapi-python-client
1317
black==24.8.0
@@ -18,6 +22,9 @@ certifi==2024.8.30
1822
# via
1923
# httpcore
2024
# httpx
25+
# requests
26+
charset-normalizer==3.3.2
27+
# via requests
2128
click==8.1.7
2229
# via
2330
# black
@@ -35,6 +42,7 @@ idna==3.10
3542
# via
3643
# anyio
3744
# httpx
45+
# requests
3846
iniconfig==2.0.0
3947
# via pytest
4048
isort==5.13.2
@@ -62,7 +70,7 @@ pathspec==0.12.1
6270
# via black
6371
pip-tools==7.4.1
6472
# via -r requirements.in/development.txt
65-
platformdirs==4.3.3
73+
platformdirs==4.3.6
6674
# via black
6775
pluggy==1.5.0
6876
# via pytest
@@ -75,12 +83,18 @@ pyproject-hooks==1.1.0
7583
# build
7684
# pip-tools
7785
pytest==8.3.3
86+
# via
87+
# -r requirements.in/development.txt
88+
# pytest-docker
89+
pytest-docker==3.1.1
7890
# via -r requirements.in/development.txt
7991
python-dateutil==2.9.0.post0
8092
# via openapi-python-client
8193
pyyaml==6.0.2
8294
# via openapi-python-client
83-
ruff==0.6.5
95+
requests==2.32.3
96+
# via -r requirements.in/development.txt
97+
ruff==0.6.8
8498
# via -r requirements.in/development.txt
8599
shellingham==1.5.4
86100
# via openapi-python-client
@@ -98,6 +112,8 @@ typing-extensions==4.12.2
98112
# mypy
99113
# pydantic
100114
# typer
115+
urllib3==2.2.3
116+
# via requests
101117
wheel==0.44.0
102118
# via pip-tools
103119

python/requirements.in/development.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ pytest
55
httpx>=0.23.0
66
openapi-python-client>=0.14.1,<0.15 # I think version 0.15 is now dangerous for us? https://github.com/openapi-generators/openapi-python-client/pull/775#issuecomment-1646977834
77
jinja2>=3.1.3
8+
pytest-docker
9+
requests

python/tests/conftest.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import os
2+
import shutil
3+
from subprocess import CalledProcessError, check_output
4+
5+
import pytest
6+
import requests
7+
from requests.adapters import HTTPAdapter
8+
from svix.api import Svix, SvixOptions
9+
from urllib3.util.retry import Retry
10+
11+
SVIX_ORG_ID = "org_svix_python_tests"
12+
13+
14+
def pytest_collection_modifyitems(config, items):
15+
"""Client tests require docker compose (v2 or v1) so skip them if it is not
16+
installed on host."""
17+
skipper = None
18+
if shutil.which("docker") is None:
19+
skipper = pytest.mark.skip(reason="skipping test as docker command is missing")
20+
else:
21+
docker_compose_available = False
22+
try:
23+
# check if docker compose v2 if available
24+
check_output(["docker", "compose", "version"])
25+
docker_compose_available = True
26+
except CalledProcessError:
27+
# check if docker compose v1 if available
28+
docker_compose_available = shutil.which("docker-compose") is not None
29+
finally:
30+
if not docker_compose_available:
31+
skipper = pytest.mark.skip(
32+
reason="skipping test as docker compose is missing"
33+
)
34+
if skipper is not None:
35+
for item in items:
36+
if item.module.__name__ == "tests.test_client":
37+
item.add_marker(skipper)
38+
39+
40+
@pytest.fixture(scope="session")
41+
def docker_compose_command():
42+
try:
43+
# use docker compose v2 if available
44+
check_output(["docker", "compose", "version"])
45+
return "docker compose"
46+
except Exception:
47+
# fallback on v1 otherwise
48+
return "docker-compose"
49+
50+
51+
@pytest.fixture(scope="session")
52+
def docker_compose_file():
53+
return [
54+
os.path.join(os.path.dirname(__file__), "../../server/docker-compose.yml"),
55+
os.path.join(
56+
os.path.dirname(__file__), "../../server/docker-compose.override.yml"
57+
),
58+
]
59+
60+
61+
@pytest.fixture(scope="session")
62+
def docker_compose(docker_services):
63+
return docker_services._docker_compose
64+
65+
66+
@pytest.fixture(scope="session")
67+
def svix_server_url(docker_services):
68+
# svix server container exposes a free port to the docker host,
69+
# we use the docker network gateway IP in case the tests are also
70+
# executed in a container
71+
svix_server_port = docker_services.port_for("backend", 8071)
72+
return f"http://172.17.0.1:{svix_server_port}"
73+
74+
75+
@pytest.fixture(autouse=True, scope="session")
76+
def svix_server(svix_server_url):
77+
"""Spawn a Svix server for the tests session using docker compose"""
78+
# wait for the svix backend service to be up and responding
79+
request_session = requests.Session()
80+
retries = Retry(total=10, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504])
81+
request_session.mount("http://", HTTPAdapter(max_retries=retries))
82+
api_url = f"{svix_server_url}/api/v1/health/"
83+
response = request_session.get(api_url)
84+
assert response
85+
86+
87+
@pytest.fixture(autouse=True)
88+
def svix_wiper(docker_compose):
89+
"""Ensure stateless tests"""
90+
yield
91+
# wipe svix database after each test to ensure stateless tests
92+
docker_compose.execute(
93+
f"exec -T backend svix-server wipe --yes-i-know-what-im-doing {SVIX_ORG_ID}"
94+
)
95+
96+
97+
@pytest.fixture(scope="session")
98+
def svix_api(svix_server_url, docker_compose):
99+
# generate bearer token to authorize communication with the svix server
100+
exec_output = docker_compose.execute(
101+
f"exec -T backend svix-server jwt generate {SVIX_ORG_ID}"
102+
)
103+
svix_auth_token = (
104+
exec_output.decode()
105+
.replace("Token (Bearer): ", "")
106+
.replace("\r", "")
107+
.replace("\n", "")
108+
)
109+
return Svix(
110+
svix_auth_token,
111+
SvixOptions(server_url=svix_server_url),
112+
)

python/tests/test_client.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import itertools
2+
import uuid
3+
from typing import Any, Dict, Optional
4+
5+
import pytest
6+
from svix.api import (
7+
ApplicationIn,
8+
ApplicationOut,
9+
EndpointIn,
10+
EndpointOut,
11+
EventTypeIn,
12+
EventTypeOut,
13+
Svix,
14+
)
15+
16+
17+
def _gen_uuid(name: str) -> str:
18+
return str(uuid.uuid5(uuid.NAMESPACE_DNS, name))
19+
20+
21+
@pytest.fixture
22+
def svix_app_name() -> str:
23+
return "svix_python_tests"
24+
25+
26+
@pytest.fixture
27+
def event_type_schema() -> Dict[str, Any]:
28+
return {
29+
"type": "object",
30+
"title": "event.test",
31+
"description": "A dummy event type",
32+
"properties": {
33+
"value": {
34+
"type": "string",
35+
"description": "A simple string value",
36+
}
37+
},
38+
"required": ["value"],
39+
}
40+
41+
42+
@pytest.fixture
43+
def endpoint_url() -> str:
44+
return "http://localhost/webhook/receiver"
45+
46+
47+
def create_svix_app(
48+
svix_api: Svix, svix_app_name: str, svix_app_uid: str
49+
) -> ApplicationOut:
50+
return svix_api.application.get_or_create(
51+
ApplicationIn(name=svix_app_name, uid=svix_app_uid)
52+
)
53+
54+
55+
def create_svix_event_type(
56+
svix_api: Svix, event_type_schema: Dict[str, Any]
57+
) -> EventTypeOut:
58+
return svix_api.event_type.create(
59+
EventTypeIn(
60+
name=event_type_schema["title"],
61+
description=event_type_schema["description"],
62+
schemas={"1": event_type_schema},
63+
)
64+
)
65+
66+
67+
def create_svix_endpoint(
68+
svix_api: Svix,
69+
app_uid: str,
70+
event_type_name: str,
71+
endpoint_url: str,
72+
endpoint_uid: str,
73+
channel: Optional[str] = None,
74+
metadata: Optional[Dict[str, Any]] = None,
75+
secret: Optional[str] = None,
76+
) -> EndpointOut:
77+
return svix_api.endpoint.create(
78+
app_uid,
79+
EndpointIn(
80+
url=endpoint_url,
81+
uid=endpoint_uid,
82+
version=1,
83+
filter_types=[event_type_name],
84+
channels=[channel] if channel else None,
85+
metadata=metadata, # type: ignore[arg-type]
86+
secret=secret,
87+
),
88+
)
89+
90+
91+
def test_svix_application_create(svix_api: Svix, svix_app_name: str) -> None:
92+
svix_app_uid = _gen_uuid(svix_app_name)
93+
app = create_svix_app(svix_api, svix_app_name, svix_app_uid)
94+
assert app.name == svix_app_name
95+
assert app.uid == svix_app_uid
96+
97+
98+
def test_svix_event_type_create(
99+
svix_api: Svix, event_type_schema: Dict[str, Any]
100+
) -> None:
101+
event_type = create_svix_event_type(svix_api, event_type_schema)
102+
assert event_type.name == event_type_schema["title"]
103+
assert event_type.description == event_type_schema["description"]
104+
assert event_type.schemas == {"1": event_type_schema}
105+
106+
107+
@pytest.mark.parametrize(
108+
"with_channel,with_metadata,with_secret",
109+
list(itertools.product([False, True], repeat=3)),
110+
)
111+
def test_svix_endpoint_create(
112+
svix_api: Svix,
113+
svix_app_name: str,
114+
event_type_schema: Dict[str, Any],
115+
endpoint_url: str,
116+
with_channel: bool,
117+
with_metadata: bool,
118+
with_secret: bool,
119+
) -> None:
120+
svix_app_uid = _gen_uuid(svix_app_name)
121+
app = create_svix_app(svix_api, svix_app_name, svix_app_uid)
122+
event_type = create_svix_event_type(svix_api, event_type_schema)
123+
endpoint_uid = _gen_uuid(endpoint_url)
124+
channel = "test" if with_channel else None
125+
metadata = {"test": "test"} if with_metadata else None
126+
secret = "whsec_" + "e" * 32 if with_secret else None
127+
assert app.uid
128+
endpoint = create_svix_endpoint(
129+
svix_api,
130+
app.uid,
131+
event_type.name,
132+
endpoint_url,
133+
endpoint_uid,
134+
channel,
135+
metadata,
136+
secret,
137+
)
138+
assert endpoint.url == endpoint_url
139+
assert endpoint.uid == endpoint_uid
140+
assert endpoint.filter_types == [event_type.name]
141+
if with_channel:
142+
assert endpoint.channels == [channel]
143+
if with_metadata:
144+
assert endpoint.metadata == metadata
145+
if with_secret:
146+
assert svix_api.endpoint.get_secret(app.uid, endpoint_uid).key == secret

0 commit comments

Comments
 (0)