diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..5394bcd63 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,49 @@ +name: Python Tests +on: + push: + branches: + - main + paths: + - "python/**" + - "openapi.json" + - "server/**" + - ".github/workflows/python-tests.yml" + pull_request: + paths: + - "python/**" + - "openapi.json" + - "server/**" + - ".github/workflows/python-tests.yml" +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build svix server image + run: docker compose build + working-directory: ./server + + - uses: actions/setup-python@v5 + name: Install Python + with: + python-version: "3.11" + + - name: Install deps + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt . + python -m pip install -r requirements-dev.txt . + working-directory: ./python + + - name: Regen openapi libs + run: ./scripts/generate_openapi.sh + working-directory: ./python + + - name: Check typing on client tests + run: mypy tests/test_client.py + working-directory: ./python + + - name: Run Python tests + run: pytest -sv + working-directory: ./python diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt index 6f9675b2c..bc11a4388 100644 --- a/python/requirements-dev.txt +++ b/python/requirements-dev.txt @@ -4,50 +4,56 @@ # # pip-compile --output-file=requirements-dev.txt requirements.in/development.txt # -anyio==3.5.0 +anyio==4.6.0 # via httpcore -attrs==21.4.0 +attrs==24.2.0 # via # openapi-python-client - # pytest -autoflake==1.4 + # pytest-docker +autoflake==2.3.1 # via openapi-python-client -black==23.3.0 +black==24.8.0 # via openapi-python-client -build==0.10.0 +build==1.2.2 # via pip-tools -certifi==2024.7.4 +certifi==2024.8.30 # via # httpcore # httpx -click==8.0.1 + # requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 # via # black # pip-tools # typer -h11==0.12.0 +h11==0.14.0 # via httpcore -httpcore==0.15.0 +httpcore==0.17.3 # via httpx -httpx==0.23.0 +httpx==0.24.1 # via # -r requirements.in/development.txt # openapi-python-client -idna==3.3 +idna==3.10 # via # anyio - # rfc3986 -iniconfig==1.1.1 + # httpx + # requests +iniconfig==2.0.0 # via pytest -isort==5.8.0 +isort==5.13.2 # via openapi-python-client -jinja2==3.1.3 +jinja2==3.1.4 # via # -r requirements.in/development.txt # openapi-python-client -markupsafe==2.1.0 - # via jinja2 -mypy==1.4.0 +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +mypy==1.11.2 # via -r requirements.in/development.txt mypy-extensions==1.0.0 # via @@ -55,55 +61,64 @@ mypy-extensions==1.0.0 # mypy openapi-python-client==0.14.1 # via -r requirements.in/development.txt -packaging==23.1 +packaging==24.1 # via # black # build # pytest -pathspec==0.11.1 +pathspec==0.12.1 # via black -pip-tools==6.13.0 +pip-tools==7.4.1 # via -r requirements.in/development.txt -platformdirs==3.5.1 +platformdirs==4.3.6 # via black -pluggy==0.13.1 - # via pytest -py==1.10.0 +pluggy==1.5.0 # via pytest -pydantic==1.10.13 +pydantic==1.10.18 # via openapi-python-client -pyflakes==2.3.1 +pyflakes==3.2.0 # via autoflake -pyproject-hooks==1.0.0 - # via build -pytest==6.2.4 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +pytest==8.3.3 + # via + # -r requirements.in/development.txt + # pytest-docker +pytest-docker==3.1.1 # via -r requirements.in/development.txt -python-dateutil==2.8.2 +pytest-httpserver==1.1.0 + # via -r requirements.in/development.txt +python-dateutil==2.9.0.post0 # via openapi-python-client -pyyaml==6.0.1 +pyyaml==6.0.2 # via openapi-python-client -rfc3986[idna2008]==1.5.0 - # via httpx -ruff==0.4.8 +requests==2.32.3 # via -r requirements.in/development.txt -shellingham==1.4.0 +ruff==0.6.8 + # via -r requirements.in/development.txt +shellingham==1.5.4 # via openapi-python-client six==1.16.0 # via python-dateutil -sniffio==1.2.0 +sniffio==1.3.1 # via # anyio # httpcore # httpx -toml==0.10.2 - # via pytest -typer==0.7.0 +typer==0.9.4 # via openapi-python-client -typing-extensions==4.6.3 +typing-extensions==4.12.2 # via # mypy # pydantic -wheel==0.40.0 + # typer +urllib3==2.2.3 + # via requests +werkzeug==3.0.4 + # via pytest-httpserver +wheel==0.44.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/python/requirements.in/development.txt b/python/requirements.in/development.txt index 7b885d039..7179f6146 100644 --- a/python/requirements.in/development.txt +++ b/python/requirements.in/development.txt @@ -5,3 +5,6 @@ pytest httpx>=0.23.0 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 jinja2>=3.1.3 +pytest-docker +pytest-httpserver +requests diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 000000000..8b14585da --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,118 @@ +import os +import shutil +from subprocess import CalledProcessError, check_output + +import pytest +import requests +from requests.adapters import HTTPAdapter +from svix.api import Svix, SvixOptions +from urllib3.util.retry import Retry + +SVIX_ORG_ID = "org_svix_python_tests" + + +def pytest_collection_modifyitems(config, items): + """Client tests require docker compose (v2 or v1) so skip them if it is not + installed on host.""" + skipper = None + if shutil.which("docker") is None: + skipper = pytest.mark.skip(reason="skipping test as docker command is missing") + else: + docker_compose_available = False + try: + # check if docker compose v2 if available + check_output(["docker", "compose", "version"]) + docker_compose_available = True + except CalledProcessError: + # check if docker compose v1 if available + docker_compose_available = shutil.which("docker-compose") is not None + finally: + if not docker_compose_available: + skipper = pytest.mark.skip( + reason="skipping test as docker compose is missing" + ) + if skipper is not None: + for item in items: + if item.module.__name__ == "tests.test_client": + item.add_marker(skipper) + + +@pytest.fixture(scope="session") +def docker_compose_command(): + try: + # use docker compose v2 if available + check_output(["docker", "compose", "version"]) + return "docker compose" + except Exception: + # fallback on v1 otherwise + return "docker-compose" + + +@pytest.fixture(scope="session") +def docker_compose_file(): + return [ + os.path.join(os.path.dirname(__file__), "../../server/docker-compose.yml"), + os.path.join(os.path.dirname(__file__), "docker-compose.override.yml"), + ] + + +@pytest.fixture(scope="session") +def docker_compose(docker_services): + return docker_services._docker_compose + + +@pytest.fixture(scope="session") +def httpserver_listen_address(): + # Use IP address in the docker bridge network as server hostname in order for + # the svix server executed in a docker container to successfully send webhooks + # to the HTTP server executed on the host + return ("172.17.0.1", 0) + + +@pytest.fixture(scope="session") +def svix_server_url(docker_services): + # svix server container exposes a free port to the docker host, + # we use the docker network gateway IP in case the tests are also + # executed in a container + svix_server_port = docker_services.port_for("backend", 8071) + return f"http://172.17.0.1:{svix_server_port}" + + +@pytest.fixture(autouse=True, scope="session") +def svix_server(svix_server_url): + """Spawn a Svix server for the tests session using docker compose""" + # wait for the svix backend service to be up and responding + request_session = requests.Session() + retries = Retry(total=10, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504]) + request_session.mount("http://", HTTPAdapter(max_retries=retries)) + api_url = f"{svix_server_url}/api/v1/health/" + response = request_session.get(api_url) + assert response + + +@pytest.fixture(autouse=True) +def svix_wiper(docker_compose): + """Ensure stateless tests""" + yield + # wipe svix database after each test to ensure stateless tests + docker_compose.execute( + f"exec -T backend svix-server wipe --yes-i-know-what-im-doing {SVIX_ORG_ID}" + ) + + +@pytest.fixture(scope="session") +def svix_api(svix_server_url, docker_compose): + # generate bearer token to authorize communication with the svix server + exec_output = docker_compose.execute( + f"exec -T backend svix-server jwt generate {SVIX_ORG_ID}" + ) + svix_auth_token = ( + exec_output.decode() + .replace("Token (Bearer): ", "") + .replace("\r", "") + .replace("\n", "") + ) + return Svix( + svix_auth_token, + SvixOptions(server_url=svix_server_url), + ) diff --git a/python/tests/docker-compose.override.yml b/python/tests/docker-compose.override.yml new file mode 100644 index 000000000..59ec515f4 --- /dev/null +++ b/python/tests/docker-compose.override.yml @@ -0,0 +1,13 @@ +version: "3.7" +services: + backend: + environment: + SVIX_JWT_SECRET: "x" + SVIX_ENDPOINT_HTTPS_ONLY: "false" + SVIX_WHITELIST_SUBNETS: "[127.0.0.1/32, 172.17.0.0/16]" + pgbouncer: + ports: + - "8079:5432" # Needed for sqlx + redis: + ports: + - "8078:6379" # Needed for sqlx diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 000000000..cb314cc89 --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,236 @@ +import itertools +import uuid +from typing import Any, Dict, List, Optional + +import pytest +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +from svix.api import ( + ApplicationIn, + ApplicationOut, + EndpointIn, + EndpointOut, + EventTypeIn, + EventTypeOut, + MessageIn, + Svix, +) +from svix.webhooks import Webhook + + +def _gen_uuid(name: str) -> str: + return str(uuid.uuid5(uuid.NAMESPACE_DNS, name)) + + +@pytest.fixture +def svix_app_name() -> str: + return "svix_python_tests" + + +@pytest.fixture +def event_type_schema() -> Dict[str, Any]: + return { + "type": "object", + "title": "event.test", + "description": "A dummy event type", + "properties": { + "value": { + "type": "string", + "description": "A simple string value", + } + }, + "required": ["value"], + } + + +@pytest.fixture +def endpoint_url() -> str: + return "http://localhost/webhook/receiver" + + +def create_svix_app( + svix_api: Svix, svix_app_name: str, svix_app_uid: str +) -> ApplicationOut: + return svix_api.application.get_or_create( + ApplicationIn(name=svix_app_name, uid=svix_app_uid) + ) + + +def create_svix_event_type( + svix_api: Svix, event_type_schema: Dict[str, Any] +) -> EventTypeOut: + return svix_api.event_type.create( + EventTypeIn( + name=event_type_schema["title"], + description=event_type_schema["description"], + schemas={"1": event_type_schema}, + ) + ) + + +def create_svix_endpoint( + svix_api: Svix, + app_uid: str, + event_type_name: str, + endpoint_url: str, + endpoint_uid: str, + channel: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + secret: Optional[str] = None, +) -> EndpointOut: + return svix_api.endpoint.create( + app_uid, + EndpointIn( + url=endpoint_url, + uid=endpoint_uid, + version=1, + filter_types=[event_type_name], + channels=[channel] if channel else None, + metadata=metadata, # type: ignore[arg-type] + secret=secret, + ), + ) + + +def test_svix_application_create(svix_api: Svix, svix_app_name: str) -> None: + svix_app_uid = _gen_uuid(svix_app_name) + app = create_svix_app(svix_api, svix_app_name, svix_app_uid) + assert app.name == svix_app_name + assert app.uid == svix_app_uid + + +def test_svix_event_type_create( + svix_api: Svix, event_type_schema: Dict[str, Any] +) -> None: + event_type = create_svix_event_type(svix_api, event_type_schema) + assert event_type.name == event_type_schema["title"] + assert event_type.description == event_type_schema["description"] + assert event_type.schemas == {"1": event_type_schema} + + +def svix_endpoint_create_test_params_ids() -> List[str]: + ids = [] + for params in itertools.product([False, True], repeat=3): + with_channel, with_metadata, with_secret = params + ids.append( + "/".join( + [ + ("with" if with_channel else "without") + " channel", + ("with" if with_metadata else "without") + " metadata", + ("with" if with_secret else "without") + " secret", + ] + ) + ) + return ids + + +@pytest.mark.parametrize( + "with_channel,with_metadata,with_secret", + list(itertools.product([False, True], repeat=3)), + ids=svix_endpoint_create_test_params_ids(), +) +def test_svix_endpoint_create( + svix_api: Svix, + svix_app_name: str, + event_type_schema: Dict[str, Any], + endpoint_url: str, + with_channel: bool, + with_metadata: bool, + with_secret: bool, +) -> None: + svix_app_uid = _gen_uuid(svix_app_name) + app = create_svix_app(svix_api, svix_app_name, svix_app_uid) + event_type = create_svix_event_type(svix_api, event_type_schema) + endpoint_uid = _gen_uuid(endpoint_url) + channel = "test" if with_channel else None + metadata = {"test": "test"} if with_metadata else None + secret = "whsec_" + "e" * 32 if with_secret else None + assert app.uid + endpoint = create_svix_endpoint( + svix_api, + app.uid, + event_type.name, + endpoint_url, + endpoint_uid, + channel, + metadata, + secret, + ) + assert endpoint.url == endpoint_url + assert endpoint.uid == endpoint_uid + assert endpoint.filter_types == [event_type.name] + if with_channel: + assert endpoint.channels == [channel] + if with_metadata: + assert endpoint.metadata == metadata + if with_secret: + assert svix_api.endpoint.get_secret(app.uid, endpoint_uid).key == secret + + +@pytest.mark.parametrize( + "with_channel", [False, True], ids=["without channel", "with channel"] +) +def test_svix_message_create( + svix_api: Svix, + svix_app_name: str, + event_type_schema: Dict[str, Any], + httpserver: HTTPServer, + with_channel: bool, +) -> None: + svix_app_uid = _gen_uuid(svix_app_name) + create_svix_app(svix_api, svix_app_name, svix_app_uid) + event_type = create_svix_event_type(svix_api, event_type_schema) + + channel = "test" if with_channel else None + endpoint_path = "/webhook/receiver/" + endpoint_url = httpserver.url_for(endpoint_path) + endpoint_uid = _gen_uuid(endpoint_url) + create_svix_endpoint( + svix_api, + svix_app_uid, + event_type.name, + endpoint_url, + endpoint_uid, + channel=channel, + ) + secret = svix_api.endpoint.get_secret(svix_app_uid, endpoint_uid).key + + payload = {"value": "test"} + + def webhook_handler(request: Request) -> Response: + assert "Svix-Id" in request.headers + assert "Svix-Timestamp" in request.headers + assert "Svix-Signature" in request.headers + + webhook = Webhook(secret) + received_payload = webhook.verify(request.data, request.headers) + assert received_payload == payload + + return Response("OK") + + httpserver.expect_oneshot_request( + endpoint_path, + method="POST", + json=payload, + ).respond_with_handler(webhook_handler) + + # send message and check it is received by local http server + with httpserver.wait() as waiting: + message_out = svix_api.message.create( + svix_app_uid, + MessageIn( + event_type=event_type.name, + payload=payload, + channels=[channel] if channel else None, + ), + ) + + assert waiting.result + + httpserver.check() # type: ignore[no-untyped-call] + + assert message_out.event_type == event_type.name + assert message_out.event_type == event_type.name + if with_channel: + assert message_out.channels == [channel]