From 03dd87568e09c62b5d002c6425b72c45fd5fd96c Mon Sep 17 00:00:00 2001 From: zeevdr Date: Mon, 25 May 2026 00:35:52 +0300 Subject: [PATCH] feat(tests): add integration pytest marker and live-server tests - Register `integration` marker in pyproject.toml; auto-skip when DECREE_TEST_ADDR is unset so the unit-test suite is unaffected. - Add session-scoped fixtures to conftest: grpc_channel, schema_stub, live_schema (creates + publishes schema, deletes on teardown), live_tenant (creates tenant, deletes on teardown). - Add tests/test_integration.py covering sync and async ConfigClient: get/set (string, int, bool, float), get_all, set_many, set_null, NotFoundError, context-manager idiom. - Add `make integration` target (requires DECREE_TEST_ADDR). - Add optional integration job to CI (workflow_dispatch only): checks out decree, builds server image with registry cache, starts docker-compose, runs tests, tears down. Closes #72 Co-Authored-By: Claude --- .github/workflows/ci.yml | 94 +++++++++++++++++++++++- Makefile | 7 +- sdk/pyproject.toml | 3 + sdk/tests/conftest.py | 133 ++++++++++++++++++++++++++++++++++ sdk/tests/test_integration.py | 122 +++++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 sdk/tests/test_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8fef49..767f9f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ # CI pipeline for the OpenDecree Python SDK. # # Jobs: lint, typecheck, test (matrix: 3.11-3.13), examples → check (alls-green gate) -# The check job aggregates all results for branch protection. +# Integration job is optional — runs on workflow_dispatch or when +# DECREE_TEST_ADDR secret is set, starting a live server via docker-compose. name: CI @@ -11,6 +12,12 @@ on: pull_request: branches: [main] workflow_call: + workflow_dispatch: + inputs: + run-integration: + description: "Run integration tests against a live server" + type: boolean + default: false concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} @@ -162,6 +169,91 @@ jobs: print(f"OK: {found[0]}") EOF + integration: + name: Integration tests + runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' && + inputs.run-integration == true + timeout-minutes: 20 + permissions: + contents: read + packages: read + steps: + - name: Checkout decree-python + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Checkout decree (for docker-compose + server) + uses: actions/checkout@v6 + with: + repository: opendecree/decree + path: decree + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: sdk/pyproject.toml + + - name: Install SDK with dev dependencies + run: pip install -e "sdk[dev]" + + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver: docker-container + driver-opts: network=host + + - name: Build server image + uses: docker/build-push-action@v7 + with: + context: decree + file: decree/build/Dockerfile + load: true + tags: decree-server + cache-from: | + type=registry,ref=ghcr.io/opendecree/decree:buildcache + type=gha,scope=py-integ-server + cache-to: type=gha,scope=py-integ-server,mode=max + + - name: Build tools image (for migrations) + uses: docker/build-push-action@v7 + with: + context: decree/build + file: decree/build/Dockerfile.tools + load: true + tags: decree-tools + cache-from: | + type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache + type=gha,scope=py-integ-tools + cache-to: type=gha,scope=py-integ-tools,mode=max + + - name: Start decree service + run: docker compose -f decree/docker-compose.yml up -d --wait service + env: + SERVICE_IMAGE: decree-server + TOOLS_IMAGE: decree-tools + + - name: Run integration tests + run: cd sdk && pytest -m integration -v + env: + DECREE_TEST_ADDR: "localhost:9090" + + - name: Tear down services + if: always() + run: docker compose -f decree/docker-compose.yml down -v + check: name: CI check if: always() diff --git a/Makefile b/Makefile index 7c54303..d249f53 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ DOCKER_RUN_ROOT := docker run --rm -v $(CURDIR):/workspace -v $(CURDIR)/../decre PROTO_DIR := /proto GEN_DIR := sdk/src/opendecree/_generated -.PHONY: all generate lint format typecheck test build clean tools docs pre-commit help +.PHONY: all generate lint format typecheck test integration build clean tools docs pre-commit help all: generate lint typecheck test @@ -55,6 +55,11 @@ typecheck: $(TOOLS_SENTINEL) test: $(TOOLS_SENTINEL) $(DOCKER_RUN_ROOT) sh -c "cd sdk && pip install -e . -q 2>/dev/null && pytest --cov --cov-report=term-missing" +## integration: Run integration tests against a live server (DECREE_TEST_ADDR required) +integration: $(TOOLS_SENTINEL) + @test -n "$(DECREE_TEST_ADDR)" || (echo "Set DECREE_TEST_ADDR=host:port" && exit 1) + $(DOCKER_RUN_ROOT) sh -c "cd sdk && pip install -e . -q 2>/dev/null && DECREE_TEST_ADDR=$(DECREE_TEST_ADDR) pytest -m integration -v" + ## docs: Generate API reference HTML from docstrings (pdoc) docs: $(TOOLS_SENTINEL) @mkdir -p sdk/docs/api diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 13e43af..7fd242f 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -83,6 +83,9 @@ ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "integration: live server required — set DECREE_TEST_ADDR to run", +] [tool.coverage.run] source = ["opendecree"] diff --git a/sdk/tests/conftest.py b/sdk/tests/conftest.py index 2cedbb4..cb484fe 100644 --- a/sdk/tests/conftest.py +++ b/sdk/tests/conftest.py @@ -2,8 +2,141 @@ from __future__ import annotations +import os +import uuid + import grpc import grpc.aio +import pytest + +from opendecree._generated.centralconfig.v1 import ( + schema_service_pb2, + schema_service_pb2_grpc, + types_pb2, +) + +# --------------------------------------------------------------------------- +# Integration fixtures — skipped unless DECREE_TEST_ADDR is set +# --------------------------------------------------------------------------- + + +def _integration_addr() -> str | None: + return os.environ.get("DECREE_TEST_ADDR") + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Auto-skip integration tests when DECREE_TEST_ADDR is not set.""" + if _integration_addr(): + return + skip = pytest.mark.skip(reason="DECREE_TEST_ADDR not set") + for item in items: + if item.get_closest_marker("integration"): + item.add_marker(skip) + + +@pytest.fixture(scope="session") +def decree_addr() -> str: + addr = _integration_addr() + if not addr: + pytest.skip("DECREE_TEST_ADDR not set") + return addr + + +@pytest.fixture(scope="session") +def grpc_channel(decree_addr: str) -> grpc.Channel: + channel = grpc.insecure_channel(decree_addr) + yield channel + channel.close() + + +@pytest.fixture(scope="session") +def schema_stub(grpc_channel: grpc.Channel) -> schema_service_pb2_grpc.SchemaServiceStub: + return schema_service_pb2_grpc.SchemaServiceStub(grpc_channel) + + +def _superadmin_metadata() -> list[tuple[str, str]]: + return [("x-decree-subject", "pytest"), ("x-decree-role", "superadmin")] + + +@pytest.fixture(scope="session") +def live_schema( + schema_stub: schema_service_pb2_grpc.SchemaServiceStub, +) -> tuple[str, int]: + """Create + publish a schema; return (schema_id, version). + + Cleaned up after the session via DeleteSchema. + """ + meta = _superadmin_metadata() + tag = uuid.uuid4().hex[:8] + resp = schema_stub.CreateSchema( + schema_service_pb2.CreateSchemaRequest( + name=f"pytest-{tag}", + description="Created by pytest integration suite", + fields=[ + types_pb2.SchemaField( + path="greeting", + type=types_pb2.FIELD_TYPE_STRING, + ), + types_pb2.SchemaField( + path="count", + type=types_pb2.FIELD_TYPE_INT, + ), + types_pb2.SchemaField( + path="ratio", + type=types_pb2.FIELD_TYPE_NUMBER, + nullable=True, + ), + types_pb2.SchemaField( + path="enabled", + type=types_pb2.FIELD_TYPE_BOOL, + ), + ], + ), + metadata=meta, + ) + schema_id: str = resp.schema.id + version: int = resp.schema.current_version + + schema_stub.PublishSchema( + schema_service_pb2.PublishSchemaRequest(id=schema_id), + metadata=meta, + ) + + yield schema_id, version + + schema_stub.DeleteSchema( + schema_service_pb2.DeleteSchemaRequest(id=schema_id), + metadata=meta, + ) + + +@pytest.fixture(scope="session") +def live_tenant( + schema_stub: schema_service_pb2_grpc.SchemaServiceStub, + live_schema: tuple[str, int], +) -> str: + """Create a tenant against the live schema; return tenant_id (name slug). + + Cleaned up after the session via DeleteTenant. + """ + meta = _superadmin_metadata() + schema_id, version = live_schema + tag = uuid.uuid4().hex[:8] + name = f"pytest-{tag}" + resp = schema_stub.CreateTenant( + schema_service_pb2.CreateTenantRequest( + name=name, + schema_id=schema_id, + schema_version=version, + ), + metadata=meta, + ) + tenant_id: str = resp.tenant.id + yield name + schema_stub.DeleteTenant( + schema_service_pb2.DeleteTenantRequest(id=tenant_id), + metadata=meta, + ) class FakeRpcError(grpc.aio.AioRpcError): diff --git a/sdk/tests/test_integration.py b/sdk/tests/test_integration.py new file mode 100644 index 0000000..d4bbf93 --- /dev/null +++ b/sdk/tests/test_integration.py @@ -0,0 +1,122 @@ +"""Integration tests — require a live server (set DECREE_TEST_ADDR).""" + +from __future__ import annotations + +import pytest + +import opendecree +from opendecree.errors import NotFoundError +from opendecree.types import FieldUpdate + +# --------------------------------------------------------------------------- +# Sync client +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestConfigClientIntegration: + @pytest.fixture(autouse=True) + def _client(self, decree_addr: str, live_tenant: str) -> None: + self.tenant = live_tenant + self.client = opendecree.ConfigClient( + decree_addr, + subject="pytest", + role="superadmin", + insecure=True, + ) + + def teardown_method(self) -> None: + self.client.close() + + def test_set_and_get_string(self) -> None: + self.client.set(self.tenant, "greeting", "hello") + assert self.client.get(self.tenant, "greeting") == "hello" + + def test_set_and_get_int(self) -> None: + self.client.set(self.tenant, "count", 42) + assert self.client.get(self.tenant, "count", int) == 42 + + def test_set_and_get_bool(self) -> None: + self.client.set(self.tenant, "enabled", True) + assert self.client.get(self.tenant, "enabled", bool) is True + + def test_set_and_get_float(self) -> None: + self.client.set(self.tenant, "ratio", 1.5) + assert self.client.get(self.tenant, "ratio", float) == pytest.approx(1.5) + + def test_get_all_returns_set_fields(self) -> None: + self.client.set(self.tenant, "greeting", "world") + self.client.set(self.tenant, "count", 7) + result = self.client.get_all(self.tenant) + assert "greeting" in result + assert "count" in result + + def test_set_many(self) -> None: + self.client.set_many( + self.tenant, + [ + FieldUpdate("greeting", "batch"), + FieldUpdate("count", 99), + ], + ) + assert self.client.get(self.tenant, "greeting") == "batch" + assert self.client.get(self.tenant, "count", int) == 99 + + def test_set_null_clears_field(self) -> None: + self.client.set(self.tenant, "ratio", 3.14) + self.client.set_null(self.tenant, "ratio") + result = self.client.get_all(self.tenant) + assert result.get("ratio") is None + + def test_get_missing_field_raises_not_found(self) -> None: + with pytest.raises(NotFoundError): + self.client.get(self.tenant, "greeting.nonexistent") + + def test_context_manager(self, decree_addr: str, live_tenant: str) -> None: + with opendecree.ConfigClient( + decree_addr, subject="pytest", role="superadmin", insecure=True + ) as c: + c.set(live_tenant, "greeting", "ctx") + assert c.get(live_tenant, "greeting") == "ctx" + + +# --------------------------------------------------------------------------- +# Async client +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestAsyncConfigClientIntegration: + @pytest.fixture(autouse=True) + def _client(self, decree_addr: str, live_tenant: str) -> None: + self.tenant = live_tenant + self.addr = decree_addr + + async def test_set_and_get_string(self) -> None: + async with opendecree.AsyncConfigClient( + self.addr, subject="pytest", role="superadmin", insecure=True + ) as c: + await c.set(self.tenant, "greeting", "async-hello") + assert await c.get(self.tenant, "greeting") == "async-hello" + + async def test_set_and_get_int(self) -> None: + async with opendecree.AsyncConfigClient( + self.addr, subject="pytest", role="superadmin", insecure=True + ) as c: + await c.set(self.tenant, "count", 10) + assert await c.get(self.tenant, "count", int) == 10 + + async def test_get_all(self) -> None: + async with opendecree.AsyncConfigClient( + self.addr, subject="pytest", role="superadmin", insecure=True + ) as c: + await c.set(self.tenant, "greeting", "async-all") + result = await c.get_all(self.tenant) + assert "greeting" in result + + async def test_get_missing_raises_not_found(self) -> None: + async with opendecree.AsyncConfigClient( + self.addr, subject="pytest", role="superadmin", insecure=True + ) as c: + with pytest.raises(NotFoundError): + await c.get(self.tenant, "no.such.field")