From bd218a1b6baac6f6370af01707b9b46ef980c648 Mon Sep 17 00:00:00 2001 From: Sergio Herrera Date: Wed, 15 Apr 2026 15:22:48 +0200 Subject: [PATCH 01/28] Move old integration tests to examples/ Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 8 ++++---- README.md | 2 +- examples/AGENTS.md | 4 ++-- examples/conversation/real_llm_providers_example.py | 2 +- tests/clients/test_conversation_helpers.py | 2 +- tests/{integration => examples}/conftest.py | 0 tests/{integration => examples}/test_configuration.py | 0 tests/{integration => examples}/test_conversation.py | 0 tests/{integration => examples}/test_crypto.py | 0 tests/{integration => examples}/test_demo_actor.py | 0 tests/{integration => examples}/test_distributed_lock.py | 0 tests/{integration => examples}/test_error_handling.py | 0 tests/{integration => examples}/test_grpc_proxying.py | 0 tests/{integration => examples}/test_invoke_binding.py | 0 .../{integration => examples}/test_invoke_custom_data.py | 0 tests/{integration => examples}/test_invoke_http.py | 0 tests/{integration => examples}/test_invoke_simple.py | 0 tests/{integration => examples}/test_jobs.py | 0 .../test_langgraph_checkpointer.py | 0 tests/{integration => examples}/test_metadata.py | 0 tests/{integration => examples}/test_pubsub_simple.py | 0 tests/{integration => examples}/test_pubsub_streaming.py | 0 .../test_pubsub_streaming_async.py | 0 tests/{integration => examples}/test_secret_store.py | 0 tests/{integration => examples}/test_state_store.py | 0 tests/{integration => examples}/test_state_store_query.py | 0 tests/{integration => examples}/test_w3c_tracing.py | 0 tests/{integration => examples}/test_workflow.py | 0 tox.ini | 6 +++--- 29 files changed, 12 insertions(+), 12 deletions(-) rename tests/{integration => examples}/conftest.py (100%) rename tests/{integration => examples}/test_configuration.py (100%) rename tests/{integration => examples}/test_conversation.py (100%) rename tests/{integration => examples}/test_crypto.py (100%) rename tests/{integration => examples}/test_demo_actor.py (100%) rename tests/{integration => examples}/test_distributed_lock.py (100%) rename tests/{integration => examples}/test_error_handling.py (100%) rename tests/{integration => examples}/test_grpc_proxying.py (100%) rename tests/{integration => examples}/test_invoke_binding.py (100%) rename tests/{integration => examples}/test_invoke_custom_data.py (100%) rename tests/{integration => examples}/test_invoke_http.py (100%) rename tests/{integration => examples}/test_invoke_simple.py (100%) rename tests/{integration => examples}/test_jobs.py (100%) rename tests/{integration => examples}/test_langgraph_checkpointer.py (100%) rename tests/{integration => examples}/test_metadata.py (100%) rename tests/{integration => examples}/test_pubsub_simple.py (100%) rename tests/{integration => examples}/test_pubsub_streaming.py (100%) rename tests/{integration => examples}/test_pubsub_streaming_async.py (100%) rename tests/{integration => examples}/test_secret_store.py (100%) rename tests/{integration => examples}/test_state_store.py (100%) rename tests/{integration => examples}/test_state_store_query.py (100%) rename tests/{integration => examples}/test_w3c_tracing.py (100%) rename tests/{integration => examples}/test_workflow.py (100%) diff --git a/AGENTS.md b/AGENTS.md index 27eed72a5..98550e585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,8 @@ The `examples/` directory serves as both user-facing documentation and the proje Quick reference: ```bash -tox -e integration # Run all examples (needs Dapr runtime) -tox -e integration -- test_state_store.py # Run a single example +tox -e examples # Run all examples (needs Dapr runtime) +tox -e examples -- test_state_store.py # Run a single example ``` ## Python version support @@ -106,8 +106,8 @@ tox -e ruff # Run type checking tox -e type -# Run integration tests / validate examples (requires Dapr runtime) -tox -e integration +# Run examples tests / validate examples (requires Dapr runtime) +tox -e examples ``` To run tests directly without tox: diff --git a/README.md b/README.md index 333be6933..f6b203412 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ tox -e type 8. Run integration tests (validates the examples) ```bash -tox -e integration +tox -e examples ``` If you need to run the examples against a pre-released version of the runtime, you can use the following command: diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 5dcbdd4ec..4b61d3002 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -13,10 +13,10 @@ Run examples locally (requires a running Dapr runtime via `dapr init`): ```bash # All examples -tox -e integration +tox -e examples # Single example -tox -e integration -- test_state_store.py +tox -e examples -- test_state_store.py ``` In CI (`validate_examples.yaml`), examples run on all supported Python versions (3.10-3.14) on Ubuntu with a full Dapr runtime including Docker, Redis, and (for LLM examples) Ollama. diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py index 2347f4b50..e37cec745 100644 --- a/examples/conversation/real_llm_providers_example.py +++ b/examples/conversation/real_llm_providers_example.py @@ -1237,7 +1237,7 @@ def main(): print(f'\n{"=" * 60}') print('πŸŽ‰ All Alpha2 tests completed!') - print('βœ… Real LLM provider integration with Alpha2 API is working correctly') + print('βœ… Real LLM provider examples with Alpha2 API is working correctly') print('πŸ”§ Features demonstrated:') print(' β€’ Alpha2 conversation API with sophisticated message types') print(' β€’ Automatic parameter conversion (raw Python values)') diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py index e7c69b30e..c9d86db25 100644 --- a/tests/clients/test_conversation_helpers.py +++ b/tests/clients/test_conversation_helpers.py @@ -1511,7 +1511,7 @@ def google_function(data: str): class TestIntegrationScenarios(unittest.TestCase): - """Test real-world integration scenarios.""" + """Test real-world examples scenarios.""" def test_restaurant_finder_scenario(self): """Test the restaurant finder example from the documentation.""" diff --git a/tests/integration/conftest.py b/tests/examples/conftest.py similarity index 100% rename from tests/integration/conftest.py rename to tests/examples/conftest.py diff --git a/tests/integration/test_configuration.py b/tests/examples/test_configuration.py similarity index 100% rename from tests/integration/test_configuration.py rename to tests/examples/test_configuration.py diff --git a/tests/integration/test_conversation.py b/tests/examples/test_conversation.py similarity index 100% rename from tests/integration/test_conversation.py rename to tests/examples/test_conversation.py diff --git a/tests/integration/test_crypto.py b/tests/examples/test_crypto.py similarity index 100% rename from tests/integration/test_crypto.py rename to tests/examples/test_crypto.py diff --git a/tests/integration/test_demo_actor.py b/tests/examples/test_demo_actor.py similarity index 100% rename from tests/integration/test_demo_actor.py rename to tests/examples/test_demo_actor.py diff --git a/tests/integration/test_distributed_lock.py b/tests/examples/test_distributed_lock.py similarity index 100% rename from tests/integration/test_distributed_lock.py rename to tests/examples/test_distributed_lock.py diff --git a/tests/integration/test_error_handling.py b/tests/examples/test_error_handling.py similarity index 100% rename from tests/integration/test_error_handling.py rename to tests/examples/test_error_handling.py diff --git a/tests/integration/test_grpc_proxying.py b/tests/examples/test_grpc_proxying.py similarity index 100% rename from tests/integration/test_grpc_proxying.py rename to tests/examples/test_grpc_proxying.py diff --git a/tests/integration/test_invoke_binding.py b/tests/examples/test_invoke_binding.py similarity index 100% rename from tests/integration/test_invoke_binding.py rename to tests/examples/test_invoke_binding.py diff --git a/tests/integration/test_invoke_custom_data.py b/tests/examples/test_invoke_custom_data.py similarity index 100% rename from tests/integration/test_invoke_custom_data.py rename to tests/examples/test_invoke_custom_data.py diff --git a/tests/integration/test_invoke_http.py b/tests/examples/test_invoke_http.py similarity index 100% rename from tests/integration/test_invoke_http.py rename to tests/examples/test_invoke_http.py diff --git a/tests/integration/test_invoke_simple.py b/tests/examples/test_invoke_simple.py similarity index 100% rename from tests/integration/test_invoke_simple.py rename to tests/examples/test_invoke_simple.py diff --git a/tests/integration/test_jobs.py b/tests/examples/test_jobs.py similarity index 100% rename from tests/integration/test_jobs.py rename to tests/examples/test_jobs.py diff --git a/tests/integration/test_langgraph_checkpointer.py b/tests/examples/test_langgraph_checkpointer.py similarity index 100% rename from tests/integration/test_langgraph_checkpointer.py rename to tests/examples/test_langgraph_checkpointer.py diff --git a/tests/integration/test_metadata.py b/tests/examples/test_metadata.py similarity index 100% rename from tests/integration/test_metadata.py rename to tests/examples/test_metadata.py diff --git a/tests/integration/test_pubsub_simple.py b/tests/examples/test_pubsub_simple.py similarity index 100% rename from tests/integration/test_pubsub_simple.py rename to tests/examples/test_pubsub_simple.py diff --git a/tests/integration/test_pubsub_streaming.py b/tests/examples/test_pubsub_streaming.py similarity index 100% rename from tests/integration/test_pubsub_streaming.py rename to tests/examples/test_pubsub_streaming.py diff --git a/tests/integration/test_pubsub_streaming_async.py b/tests/examples/test_pubsub_streaming_async.py similarity index 100% rename from tests/integration/test_pubsub_streaming_async.py rename to tests/examples/test_pubsub_streaming_async.py diff --git a/tests/integration/test_secret_store.py b/tests/examples/test_secret_store.py similarity index 100% rename from tests/integration/test_secret_store.py rename to tests/examples/test_secret_store.py diff --git a/tests/integration/test_state_store.py b/tests/examples/test_state_store.py similarity index 100% rename from tests/integration/test_state_store.py rename to tests/examples/test_state_store.py diff --git a/tests/integration/test_state_store_query.py b/tests/examples/test_state_store_query.py similarity index 100% rename from tests/integration/test_state_store_query.py rename to tests/examples/test_state_store_query.py diff --git a/tests/integration/test_w3c_tracing.py b/tests/examples/test_w3c_tracing.py similarity index 100% rename from tests/integration/test_w3c_tracing.py rename to tests/examples/test_w3c_tracing.py diff --git a/tests/integration/test_workflow.py b/tests/examples/test_workflow.py similarity index 100% rename from tests/integration/test_workflow.py rename to tests/examples/test_workflow.py diff --git a/tox.ini b/tox.ini index de0b30a2d..711206c1b 100644 --- a/tox.ini +++ b/tox.ini @@ -39,9 +39,9 @@ commands = ruff format [testenv:integration] -; Pytest-based integration tests that validate the examples/ directory. -; Usage: tox -e integration # run all -; tox -e integration -- test_state_store.py # run one +; Pytest-based examples tests that validate the examples/ directory. +; Usage: tox -e examples # run all +; tox -e examples -- test_state_store.py # run one passenv = HOME basepython = python3 changedir = ./tests/integration/ From 24730757bf37606d9269292df22b7a53e376f7e8 Mon Sep 17 00:00:00 2001 From: Sergio Herrera Date: Wed, 15 Apr 2026 15:36:44 +0200 Subject: [PATCH 02/28] Test DaprClient directly Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .github/workflows/validate_examples.yaml | 3 + tests/integration/apps/invoke_receiver.py | 13 ++ tests/integration/apps/pubsub_subscriber.py | 25 ++++ .../components/configurationstore.yaml | 11 ++ .../components/localsecretstore.yaml | 13 ++ tests/integration/components/lockstore.yaml | 11 ++ tests/integration/components/pubsub.yaml | 12 ++ tests/integration/components/statestore.yaml | 12 ++ tests/integration/conftest.py | 133 ++++++++++++++++++ tests/integration/secrets.json | 4 + tests/integration/test_configuration.py | 91 ++++++++++++ tests/integration/test_distributed_lock.py | 66 +++++++++ tests/integration/test_invoke.py | 34 +++++ tests/integration/test_metadata.py | 42 ++++++ tests/integration/test_pubsub.py | 49 +++++++ tests/integration/test_secret_store.py | 19 +++ tests/integration/test_state_store.py | 102 ++++++++++++++ tox.ini | 29 +++- 18 files changed, 666 insertions(+), 3 deletions(-) create mode 100644 tests/integration/apps/invoke_receiver.py create mode 100644 tests/integration/apps/pubsub_subscriber.py create mode 100644 tests/integration/components/configurationstore.yaml create mode 100644 tests/integration/components/localsecretstore.yaml create mode 100644 tests/integration/components/lockstore.yaml create mode 100644 tests/integration/components/pubsub.yaml create mode 100644 tests/integration/components/statestore.yaml create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/secrets.json create mode 100644 tests/integration/test_configuration.py create mode 100644 tests/integration/test_distributed_lock.py create mode 100644 tests/integration/test_invoke.py create mode 100644 tests/integration/test_metadata.py create mode 100644 tests/integration/test_pubsub.py create mode 100644 tests/integration/test_secret_store.py create mode 100644 tests/integration/test_state_store.py diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/validate_examples.yaml index ae784965e..cd69c953c 100644 --- a/.github/workflows/validate_examples.yaml +++ b/.github/workflows/validate_examples.yaml @@ -104,5 +104,8 @@ jobs: sleep 10 ollama pull llama3.2:latest - name: Check examples + run: | + tox -e examples + - name: Run integration tests run: | tox -e integration diff --git a/tests/integration/apps/invoke_receiver.py b/tests/integration/apps/invoke_receiver.py new file mode 100644 index 000000000..41592eb0e --- /dev/null +++ b/tests/integration/apps/invoke_receiver.py @@ -0,0 +1,13 @@ +"""gRPC method handler for invoke integration tests.""" + +from dapr.ext.grpc import App, InvokeMethodRequest, InvokeMethodResponse + +app = App() + + +@app.method(name='my-method') +def my_method(request: InvokeMethodRequest) -> InvokeMethodResponse: + return InvokeMethodResponse(b'INVOKE_RECEIVED', 'text/plain; charset=UTF-8') + + +app.run(50051) diff --git a/tests/integration/apps/pubsub_subscriber.py b/tests/integration/apps/pubsub_subscriber.py new file mode 100644 index 000000000..2c1c4761b --- /dev/null +++ b/tests/integration/apps/pubsub_subscriber.py @@ -0,0 +1,25 @@ +"""Pub/sub subscriber that persists received messages to state store. + +Used by integration tests to verify message delivery without relying on stdout. +""" + +import json + +from cloudevents.sdk.event import v1 +from dapr.ext.grpc import App + +from dapr.clients import DaprClient +from dapr.clients.grpc._response import TopicEventResponse + +app = App() + + +@app.subscribe(pubsub_name='pubsub', topic='TOPIC_A') +def handle_topic_a(event: v1.Event) -> TopicEventResponse: + data = json.loads(event.Data()) + with DaprClient() as d: + d.save_state('statestore', f'received-topic-a-{data["id"]}', event.Data()) + return TopicEventResponse('success') + + +app.run(50051) diff --git a/tests/integration/components/configurationstore.yaml b/tests/integration/components/configurationstore.yaml new file mode 100644 index 000000000..fcf6569d0 --- /dev/null +++ b/tests/integration/components/configurationstore.yaml @@ -0,0 +1,11 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: configurationstore +spec: + type: configuration.redis + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/localsecretstore.yaml b/tests/integration/components/localsecretstore.yaml new file mode 100644 index 000000000..fd574a077 --- /dev/null +++ b/tests/integration/components/localsecretstore.yaml @@ -0,0 +1,13 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: localsecretstore +spec: + type: secretstores.local.file + metadata: + - name: secretsFile + # Relative to the Dapr process CWD (tests/integration/), set by + # DaprTestEnvironment via cwd=INTEGRATION_DIR. + value: secrets.json + - name: nestedSeparator + value: ":" diff --git a/tests/integration/components/lockstore.yaml b/tests/integration/components/lockstore.yaml new file mode 100644 index 000000000..424caceeb --- /dev/null +++ b/tests/integration/components/lockstore.yaml @@ -0,0 +1,11 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: lockstore +spec: + type: lock.redis + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/pubsub.yaml b/tests/integration/components/pubsub.yaml new file mode 100644 index 000000000..18764d8ce --- /dev/null +++ b/tests/integration/components/pubsub.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: pubsub +spec: + type: pubsub.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/components/statestore.yaml b/tests/integration/components/statestore.yaml new file mode 100644 index 000000000..a0c53bc40 --- /dev/null +++ b/tests/integration/components/statestore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: statestore +spec: + type: state.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6379 + - name: redisPassword + value: "" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..f8755f50f --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,133 @@ +import shlex +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any, Generator + +import pytest + +from dapr.clients import DaprClient + +INTEGRATION_DIR = Path(__file__).resolve().parent +COMPONENTS_DIR = INTEGRATION_DIR / 'components' +APPS_DIR = INTEGRATION_DIR / 'apps' + + +class DaprTestEnvironment: + """Manages Dapr sidecars and returns SDK clients for programmatic testing. + + Unlike tests.examples.DaprRunner (which captures stdout for output-based assertions), this + class returns real DaprClient instances so tests can make assertions against SDK return values. + """ + + def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: + self._default_components = default_components + self._processes: list[subprocess.Popen[str]] = [] + self._log_files: list[Path] = [] + self._clients: list[DaprClient] = [] + + def start_sidecar( + self, + app_id: str, + *, + grpc_port: int = 50001, + http_port: int = 3500, + app_port: int | None = None, + app_cmd: str | None = None, + components: Path | None = None, + wait: int = 5, + ) -> DaprClient: + """Start a Dapr sidecar and return a connected DaprClient. + + Args: + app_id: Dapr application ID. + grpc_port: Sidecar gRPC port (must match DAPR_GRPC_PORT setting). + http_port: Sidecar HTTP port (must match DAPR_HTTP_PORT setting for + the SDK health check). + app_port: Port the app listens on (implies ``--app-protocol grpc``). + app_cmd: Shell command to start alongside the sidecar. + components: Path to component YAML directory. Defaults to + ``tests/integration/components/``. + wait: Seconds to sleep after launching (before the SDK health check). + """ + resources = components or self._default_components + + cmd = [ + 'dapr', + 'run', + '--app-id', + app_id, + '--resources-path', + str(resources), + '--dapr-grpc-port', + str(grpc_port), + '--dapr-http-port', + str(http_port), + ] + if app_port is not None: + cmd.extend(['--app-port', str(app_port), '--app-protocol', 'grpc']) + if app_cmd is not None: + cmd.extend(['--', *shlex.split(app_cmd)]) + + with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log', delete=False) as log: + self._log_files.append(Path(log.name)) + proc = subprocess.Popen( + cmd, + cwd=INTEGRATION_DIR, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + ) + self._processes.append(proc) + + # Give the sidecar a moment to bind its ports before the SDK health + # check starts hitting the HTTP endpoint. + time.sleep(wait) + + # DaprClient constructor calls DaprHealth.wait_for_sidecar(), which + # polls http://localhost:{DAPR_HTTP_PORT}/v1.0/healthz/outbound until + # the sidecar is ready (up to DAPR_HEALTH_TIMEOUT seconds). + client = DaprClient(address=f'127.0.0.1:{grpc_port}') + self._clients.append(client) + return client + + def cleanup(self) -> None: + for client in self._clients: + client.close() + self._clients.clear() + for proc in self._processes: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + self._processes.clear() + for log_path in self._log_files: + log_path.unlink(missing_ok=True) + self._log_files.clear() + + +@pytest.fixture(scope='module') +def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: + """Provides a DaprTestEnvironment for programmatic SDK testing. + + Module-scoped so that all tests in a file share a single Dapr sidecar, + avoiding port conflicts from rapid start/stop cycles and cutting total + test time significantly. + """ + env = DaprTestEnvironment() + yield env + env.cleanup() + + +@pytest.fixture(scope='module') +def apps_dir() -> Path: + return APPS_DIR + + +@pytest.fixture(scope='module') +def components_dir() -> Path: + return COMPONENTS_DIR diff --git a/tests/integration/secrets.json b/tests/integration/secrets.json new file mode 100644 index 000000000..e8db35141 --- /dev/null +++ b/tests/integration/secrets.json @@ -0,0 +1,4 @@ +{ + "secretKey": "secretValue", + "random": "randomValue" +} diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py new file mode 100644 index 000000000..d43fc8c8f --- /dev/null +++ b/tests/integration/test_configuration.py @@ -0,0 +1,91 @@ +import subprocess +import threading +import time + +import pytest + +from dapr.clients.grpc._response import ConfigurationResponse + +STORE = 'configurationstore' +REDIS_CONTAINER = 'dapr_redis' + + +def _redis_set(key: str, value: str, version: int = 1) -> None: + """Seed a configuration value directly in Redis. + + Dapr's Redis configuration store encodes values as ``value||version``. + """ + subprocess.run( + f'docker exec {REDIS_CONTAINER} redis-cli SET {key} "{value}||{version}"', + shell=True, + check=True, + capture_output=True, + ) + + +@pytest.fixture(scope='module') +def client(dapr_env): + _redis_set('cfg-key-1', 'val-1') + _redis_set('cfg-key-2', 'val-2') + return dapr_env.start_sidecar(app_id='test-config') + + +class TestGetConfiguration: + def test_get_single_key(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + assert 'cfg-key-1' in resp.items + assert resp.items['cfg-key-1'].value == 'val-1' + + def test_get_multiple_keys(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1', 'cfg-key-2']) + assert resp.items['cfg-key-1'].value == 'val-1' + assert resp.items['cfg-key-2'].value == 'val-2' + + def test_get_missing_key_returns_empty_items(self, client): + resp = client.get_configuration(store_name=STORE, keys=['nonexistent-cfg-key']) + # Dapr omits keys that don't exist from the response. + assert 'nonexistent-cfg-key' not in resp.items + + def test_items_have_version(self, client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + item = resp.items['cfg-key-1'] + assert item.version + + +class TestSubscribeConfiguration: + def test_subscribe_receives_update(self, client): + received: list[ConfigurationResponse] = [] + event = threading.Event() + + def handler(_id: str, resp: ConfigurationResponse) -> None: + received.append(resp) + event.set() + + sub_id = client.subscribe_configuration( + store_name=STORE, keys=['cfg-sub-key'], handler=handler + ) + assert sub_id + + # Give the subscription watcher thread time to establish its gRPC + # stream before pushing the update, otherwise the notification is missed. + time.sleep(1) + _redis_set('cfg-sub-key', 'updated-val', version=2) + event.wait(timeout=10) + + assert len(received) >= 1 + last = received[-1] + assert 'cfg-sub-key' in last.items + assert last.items['cfg-sub-key'].value == 'updated-val' + + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok + + def test_unsubscribe_returns_true(self, client): + sub_id = client.subscribe_configuration( + store_name=STORE, + keys=['cfg-unsub-key'], + handler=lambda _id, _resp: None, + ) + time.sleep(1) + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok diff --git a/tests/integration/test_distributed_lock.py b/tests/integration/test_distributed_lock.py new file mode 100644 index 000000000..68362c296 --- /dev/null +++ b/tests/integration/test_distributed_lock.py @@ -0,0 +1,66 @@ +import pytest + +from dapr.clients.grpc._response import UnlockResponseStatus + +STORE = 'lockstore' + +# The distributed lock API emits alpha warnings on every call. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-lock') + + +class TestTryLock: + def test_acquire_lock(self, client): + lock = client.try_lock(STORE, 'res-acquire', 'owner-a', expiry_in_seconds=10) + assert lock.success + + def test_second_owner_is_rejected(self, client): + first = client.try_lock(STORE, 'res-contention', 'owner-a', expiry_in_seconds=10) + second = client.try_lock(STORE, 'res-contention', 'owner-b', expiry_in_seconds=10) + assert first.success + assert not second.success + + def test_lock_is_truthy_on_success(self, client): + lock = client.try_lock(STORE, 'res-truthy', 'owner-a', expiry_in_seconds=10) + assert bool(lock) is True + + def test_failed_lock_is_falsy(self, client): + client.try_lock(STORE, 'res-falsy', 'owner-a', expiry_in_seconds=10) + contested = client.try_lock(STORE, 'res-falsy', 'owner-b', expiry_in_seconds=10) + assert bool(contested) is False + + +class TestUnlock: + def test_unlock_own_lock(self, client): + client.try_lock(STORE, 'res-unlock', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-unlock', 'owner-a') + assert resp.status == UnlockResponseStatus.success + + def test_unlock_wrong_owner(self, client): + client.try_lock(STORE, 'res-wrong-owner', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-wrong-owner', 'owner-b') + assert resp.status == UnlockResponseStatus.lock_belongs_to_others + + def test_unlock_nonexistent(self, client): + resp = client.unlock(STORE, 'res-does-not-exist', 'owner-a') + assert resp.status == UnlockResponseStatus.lock_does_not_exist + + def test_unlock_frees_resource_for_others(self, client): + client.try_lock(STORE, 'res-release', 'owner-a', expiry_in_seconds=10) + client.unlock(STORE, 'res-release', 'owner-a') + second = client.try_lock(STORE, 'res-release', 'owner-b', expiry_in_seconds=10) + assert second.success + + +class TestLockContextManager: + def test_context_manager_auto_unlocks(self, client): + with client.try_lock(STORE, 'res-ctx', 'owner-a', expiry_in_seconds=10) as lock: + assert lock + + # After the context manager exits, another owner should be able to acquire. + second = client.try_lock(STORE, 'res-ctx', 'owner-b', expiry_in_seconds=10) + assert second.success diff --git a/tests/integration/test_invoke.py b/tests/integration/test_invoke.py new file mode 100644 index 000000000..45abdcdcb --- /dev/null +++ b/tests/integration/test_invoke.py @@ -0,0 +1,34 @@ +import pytest + + +@pytest.fixture(scope='module') +def client(dapr_env, apps_dir): + return dapr_env.start_sidecar( + app_id='invoke-receiver', + grpc_port=50001, + app_port=50051, + app_cmd=f'python3 {apps_dir / "invoke_receiver.py"}', + ) + + +def test_invoke_method_returns_expected_response(client): + resp = client.invoke_method( + app_id='invoke-receiver', + method_name='my-method', + data=b'{"id": 1, "message": "hello world"}', + content_type='application/json', + ) + # The app returns 'text/plain; charset=UTF-8', but Dapr may strip + # parameters when proxying through gRPC, so only check the media type. + assert resp.content_type.startswith('text/plain') + assert resp.data == b'INVOKE_RECEIVED' + + +def test_invoke_method_with_text_data(client): + resp = client.invoke_method( + app_id='invoke-receiver', + method_name='my-method', + data=b'plain text', + content_type='text/plain', + ) + assert resp.data == b'INVOKE_RECEIVED' diff --git a/tests/integration/test_metadata.py b/tests/integration/test_metadata.py new file mode 100644 index 000000000..88430ebbb --- /dev/null +++ b/tests/integration/test_metadata.py @@ -0,0 +1,42 @@ +import pytest + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-metadata') + + +class TestGetMetadata: + def test_application_id_matches(self, client): + meta = client.get_metadata() + assert meta.application_id == 'test-metadata' + + def test_registered_components_present(self, client): + meta = client.get_metadata() + component_types = {c.type for c in meta.registered_components} + assert any(t.startswith('state.') for t in component_types) + + def test_registered_components_have_names(self, client): + meta = client.get_metadata() + for comp in meta.registered_components: + assert comp.name + assert comp.type + + +class TestSetMetadata: + def test_set_and_get_roundtrip(self, client): + client.set_metadata('test-key', 'test-value') + meta = client.get_metadata() + assert meta.extended_metadata.get('test-key') == 'test-value' + + def test_overwrite_existing_key(self, client): + client.set_metadata('overwrite-key', 'first') + client.set_metadata('overwrite-key', 'second') + meta = client.get_metadata() + assert meta.extended_metadata['overwrite-key'] == 'second' + + def test_empty_value_is_allowed(self, client): + client.set_metadata('empty-key', '') + meta = client.get_metadata() + assert 'empty-key' in meta.extended_metadata + assert meta.extended_metadata['empty-key'] == '' diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py new file mode 100644 index 000000000..cee9cf0cf --- /dev/null +++ b/tests/integration/test_pubsub.py @@ -0,0 +1,49 @@ +import json +import time + +import pytest + +STORE = 'statestore' +PUBSUB = 'pubsub' +TOPIC = 'TOPIC_A' + + +@pytest.fixture(scope='module') +def client(dapr_env, apps_dir): + return dapr_env.start_sidecar( + app_id='test-subscriber', + grpc_port=50001, + app_port=50051, + app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}', + wait=10, + ) + + +def test_published_messages_are_received_by_subscriber(client): + for n in range(1, 4): + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=json.dumps({'id': n, 'message': 'hello world'}), + data_content_type='application/json', + ) + time.sleep(1) + + time.sleep(3) + + for n in range(1, 4): + state = client.get_state(store_name=STORE, key=f'received-topic-a-{n}') + assert state.data != b'', f'Subscriber did not receive message {n}' + msg = json.loads(state.data) + assert msg['id'] == n + assert msg['message'] == 'hello world' + + +def test_publish_event_succeeds(client): + """Verify publish_event does not raise on a valid topic.""" + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=json.dumps({'id': 99, 'message': 'smoke test'}), + data_content_type='application/json', + ) diff --git a/tests/integration/test_secret_store.py b/tests/integration/test_secret_store.py new file mode 100644 index 000000000..b4e8e8679 --- /dev/null +++ b/tests/integration/test_secret_store.py @@ -0,0 +1,19 @@ +import pytest + +STORE = 'localsecretstore' + + +@pytest.fixture(scope='module') +def client(dapr_env, components_dir): + return dapr_env.start_sidecar(app_id='test-secret', components=components_dir) + + +def test_get_secret(client): + resp = client.get_secret(store_name=STORE, key='secretKey') + assert resp.secret == {'secretKey': 'secretValue'} + + +def test_get_bulk_secret(client): + resp = client.get_bulk_secret(store_name=STORE) + assert 'secretKey' in resp.secrets + assert resp.secrets['secretKey'] == {'secretKey': 'secretValue'} diff --git a/tests/integration/test_state_store.py b/tests/integration/test_state_store.py new file mode 100644 index 000000000..26ef51cad --- /dev/null +++ b/tests/integration/test_state_store.py @@ -0,0 +1,102 @@ +import grpc +import pytest + +from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType +from dapr.clients.grpc._state import StateItem + +STORE = 'statestore' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-state') + + +class TestSaveAndGetState: + def test_save_and_get(self, client): + client.save_state(store_name=STORE, key='k1', value='v1') + state = client.get_state(store_name=STORE, key='k1') + assert state.data == b'v1' + assert state.etag + + def test_save_with_wrong_etag_fails(self, client): + client.save_state(store_name=STORE, key='etag-test', value='original') + with pytest.raises(grpc.RpcError) as exc_info: + client.save_state(store_name=STORE, key='etag-test', value='bad', etag='9999') + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + def test_get_missing_key_returns_empty(self, client): + state = client.get_state(store_name=STORE, key='nonexistent-key') + assert state.data == b'' + + +class TestBulkState: + def test_save_and_get_bulk(self, client): + client.save_bulk_state( + store_name=STORE, + states=[ + StateItem(key='bulk-1', value='v1'), + StateItem(key='bulk-2', value='v2'), + ], + ) + items = client.get_bulk_state(store_name=STORE, keys=['bulk-1', 'bulk-2']).items + by_key = {i.key: i.data for i in items} + assert by_key['bulk-1'] == b'v1' + assert by_key['bulk-2'] == b'v2' + + def test_save_bulk_with_wrong_etag_fails(self, client): + client.save_state(store_name=STORE, key='bulk-etag-1', value='original') + with pytest.raises(grpc.RpcError) as exc_info: + client.save_bulk_state( + store_name=STORE, + states=[StateItem(key='bulk-etag-1', value='updated', etag='9999')], + ) + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + +class TestStateTransactions: + def test_transaction_upsert(self, client): + client.save_state(store_name=STORE, key='tx-1', value='original') + etag = client.get_state(store_name=STORE, key='tx-1').etag + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.upsert, + key='tx-1', + data='updated', + etag=etag, + ), + TransactionalStateOperation(key='tx-2', data='new'), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-1').data == b'updated' + assert client.get_state(store_name=STORE, key='tx-2').data == b'new' + + def test_transaction_delete(self, client): + client.save_state(store_name=STORE, key='tx-del-1', value='v1') + client.save_state(store_name=STORE, key='tx-del-2', value='v2') + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-1' + ), + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-2' + ), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-del-1').data == b'' + assert client.get_state(store_name=STORE, key='tx-del-2').data == b'' + + +class TestDeleteState: + def test_delete_single(self, client): + client.save_state(store_name=STORE, key='del-1', value='v1') + client.delete_state(store_name=STORE, key='del-1') + assert client.get_state(store_name=STORE, key='del-1').data == b'' diff --git a/tox.ini b/tox.ini index 711206c1b..67ecc4dd2 100644 --- a/tox.ini +++ b/tox.ini @@ -38,13 +38,13 @@ commands = ruff check --fix ruff format -[testenv:integration] -; Pytest-based examples tests that validate the examples/ directory. +[testenv:examples] +; Stdout-based smoke tests that run examples/ and check expected output. ; Usage: tox -e examples # run all ; tox -e examples -- test_state_store.py # run one passenv = HOME basepython = python3 -changedir = ./tests/integration/ +changedir = ./tests/examples/ commands = pytest {posargs} -v --tb=short @@ -63,6 +63,29 @@ commands_pre = opentelemetry-exporter-zipkin \ langchain-ollama +[testenv:integration] +; SDK-based integration tests using DaprClient directly. +; Usage: tox -e integration # run all +; tox -e integration -- test_state_store.py # run one +passenv = HOME +basepython = python3 +changedir = ./tests/integration/ +commands = + pytest {posargs} -v --tb=short + +allowlist_externals=* + +commands_pre = + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask dapr-ext-langgraph dapr-ext-strands + pip install -r {toxinidir}/dev-requirements.txt \ + -e {toxinidir}/ \ + -e {toxinidir}/ext/dapr-ext-workflow/ \ + -e {toxinidir}/ext/dapr-ext-grpc/ \ + -e {toxinidir}/ext/dapr-ext-fastapi/ \ + -e {toxinidir}/ext/dapr-ext-langgraph/ \ + -e {toxinidir}/ext/dapr-ext-strands/ \ + -e {toxinidir}/ext/flask_dapr/ + [testenv:type] basepython = python3 usedevelop = False From c2b587540b737df8eb3b457039bcd452d92c8bcc Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:21:01 +0200 Subject: [PATCH 03/28] Update docs to new test structure Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 33 ++++++++++++++++++++++----------- examples/AGENTS.md | 14 +++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 98550e585..b71b26c9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ ext/ # Extension packages (each is a separate PyPI packa └── flask_dapr/ # Flask integration ← see ext/flask_dapr/AGENTS.md tests/ # Unit tests (mirrors dapr/ package structure) -examples/ # Integration test suite ← see examples/AGENTS.md +β”œβ”€β”€ examples/ # Output-based tests that run examples and check stdout +β”œβ”€β”€ integration/ # Programmatic SDK tests using DaprClient directly +examples/ # User-facing example applications ← see examples/AGENTS.md docs/ # Sphinx documentation source tools/ # Build and release scripts ``` @@ -59,16 +61,21 @@ Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.p | `dapr-ext-langgraph` | `dapr.ext.langgraph` | LangGraph checkpoint persistence to Dapr state store | Moderate | | `dapr-ext-strands` | `dapr.ext.strands` | Strands agent session management via Dapr state store | New | -## Examples (integration test suite) +## Examples and testing -The `examples/` directory serves as both user-facing documentation and the project's integration test suite. Examples are validated by pytest-based integration tests in `tests/integration/`. +The `examples/` directory contains user-facing example applications. These are validated by two test suites: + +- **`tests/examples/`** β€” Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. +- **`tests/integration/`** β€” Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. **See `examples/AGENTS.md`** for the full guide on example structure and how to add new examples. Quick reference: ```bash -tox -e examples # Run all examples (needs Dapr runtime) -tox -e examples -- test_state_store.py # Run a single example +tox -e examples # Run output-based example tests +tox -e examples -- test_state_store.py # Run a single example test +tox -e integration # Run programmatic SDK tests +tox -e integration -- test_state_store.py # Run a single integration test ``` ## Python version support @@ -106,8 +113,11 @@ tox -e ruff # Run type checking tox -e type -# Run examples tests / validate examples (requires Dapr runtime) +# Run output-based example tests (requires Dapr runtime) tox -e examples + +# Run programmatic integration tests (requires Dapr runtime) +tox -e integration ``` To run tests directly without tox: @@ -189,8 +199,8 @@ When completing any task on this project, work through this checklist. Not every ### Examples (integration tests) - [ ] If you added a new user-facing feature or building block, add or update an example in `examples/` -- [ ] Add a corresponding pytest integration test in `tests/integration/` -- [ ] If you changed output format of existing functionality, update expected output in the affected integration tests +- [ ] Add a corresponding pytest test in `tests/examples/` (output-based) and/or `tests/integration/` (programmatic) +- [ ] If you changed output format of existing functionality, update expected output in `tests/examples/` - [ ] See `examples/AGENTS.md` for full details on writing examples ### Documentation @@ -202,7 +212,7 @@ When completing any task on this project, work through this checklist. Not every - [ ] Run `tox -e ruff` β€” linting must be clean - [ ] Run `tox -e py311` (or your Python version) β€” all unit tests must pass -- [ ] If you touched examples: `tox -e integration -- test_.py` to validate locally +- [ ] If you touched examples: `tox -e examples -- test_.py` to validate locally - [ ] Commits must be signed off for DCO: `git commit -s` ## Important files @@ -217,7 +227,8 @@ When completing any task on this project, work through this checklist. Not every | `dev-requirements.txt` | Development/test dependencies | | `dapr/version/__init__.py` | SDK version string | | `ext/*/setup.cfg` | Extension package metadata and dependencies | -| `tests/integration/` | Pytest-based integration tests that validate examples | +| `tests/examples/` | Output-based tests that validate examples by checking stdout | +| `tests/integration/` | Programmatic SDK tests using DaprClient directly | ## Gotchas @@ -226,6 +237,6 @@ When completing any task on this project, work through this checklist. Not every - **Extension independence**: Each extension is a separate PyPI package. Core SDK changes should not break extensions; extension changes should not require core SDK changes unless intentional. - **DCO signoff**: PRs will be blocked by the DCO bot if commits lack `Signed-off-by`. Always use `git commit -s`. - **Ruff version pinned**: Dev requirements pin `ruff === 0.14.1`. Use this exact version to match CI. -- **Examples are integration tests**: Changing output format (log messages, print statements) can break integration tests. Always check expected output in `tests/integration/` when modifying user-visible output. +- **Examples are tested by output matching**: Changing output format (log messages, print statements) can break `tests/examples/`. Always check expected output there when modifying user-visible output. - **Background processes in examples**: Examples that start background services (servers, subscribers) must include a cleanup step to stop them, or CI will hang. - **Workflow is the most active area**: See `ext/dapr-ext-workflow/AGENTS.md` for workflow-specific architecture and constraints. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 4b61d3002..e356db9f8 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,11 +1,11 @@ # AGENTS.md β€” Dapr Python SDK Examples -The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based integration tests in `tests/integration/`. +The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. ## How validation works -1. Each example has a corresponding test file in `tests/integration/` (e.g., `test_state_store.py`) -2. Tests use a `DaprRunner` helper (defined in `conftest.py`) that wraps `dapr run` commands +1. Each example has a corresponding test file in `tests/examples/` (e.g., `test_state_store.py`) +2. Tests use a `DaprRunner` helper (defined in `tests/examples/conftest.py`) that wraps `dapr run` commands 3. `DaprRunner.run()` executes a command and captures stdout; `DaprRunner.start()`/`stop()` manage background services 4. Tests assert that expected output lines appear in the captured output @@ -132,17 +132,17 @@ The `workflow` example includes: `simple.py`, `task_chaining.py`, `fan_out_fan_i 2. Add Python source files and a `requirements.txt` referencing the needed SDK packages 3. Add Dapr component YAMLs in a `components/` subdirectory if the example uses state, pubsub, etc. 4. Write a `README.md` with introduction, pre-requisites, install instructions, and running instructions -5. Add a corresponding test in `tests/integration/test_.py`: +5. Add a corresponding test in `tests/examples/test_.py`: - Use the `@pytest.mark.example_dir('')` marker to set the working directory - Use `dapr.run()` for scripts that exit on their own, `dapr.start()`/`dapr.stop()` for long-running services - Assert expected output lines appear in the captured output -6. Test locally: `tox -e integration -- test_.py` +6. Test locally: `tox -e examples -- test_.py` ## Gotchas -- **Output format changes break tests**: If you modify print statements or log output in SDK code, check whether any integration test's expected lines depend on that output. +- **Output format changes break tests**: If you modify print statements or log output in SDK code, check whether any test's expected lines in `tests/examples/` depend on that output. - **Background processes must be cleaned up**: The `DaprRunner` fixture handles cleanup on teardown, but tests should still call `dapr.stop()` to capture output. - **Dapr prefixes output**: Application stdout appears as `== APP == ` when run via `dapr run`. - **Redis is available in CI**: The CI environment has Redis running on `localhost:6379` β€” most component YAMLs use this. - **Some examples need special setup**: `crypto` generates keys, `configuration` seeds Redis, `conversation` needs LLM config β€” check individual READMEs. -- **Infinite-loop example scripts**: Some example scripts (e.g., `invoke-caller.py`) have `while True` loops for demo purposes. Integration tests must either bypass these with HTTP API calls or use `dapr.run(until=...)` for early termination. \ No newline at end of file +- **Infinite-loop example scripts**: Some example scripts (e.g., `invoke-caller.py`) have `while True` loops for demo purposes. Tests must either bypass these with HTTP API calls or use `dapr.run(until=...)` for early termination. \ No newline at end of file From 47ccd36e91611504ed713ffc1133ee28ec47e782 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:26:35 +0200 Subject: [PATCH 04/28] Address Copilot comments (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- AGENTS.md | 6 +- examples/AGENTS.md | 2 +- .../real_llm_providers_example.py | 2 +- tests/clients/test_conversation_helpers.py | 2 +- tests/integration/AGENTS.md | 94 +++++++++++++++++++ tests/integration/conftest.py | 15 +-- tests/integration/test_configuration.py | 3 +- 7 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/integration/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index b71b26c9a..d1c67c21e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,10 +65,8 @@ Each extension is a **separate PyPI package** with its own `setup.cfg`, `setup.p The `examples/` directory contains user-facing example applications. These are validated by two test suites: -- **`tests/examples/`** β€” Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. -- **`tests/integration/`** β€” Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. - -**See `examples/AGENTS.md`** for the full guide on example structure and how to add new examples. +- **`tests/examples/`** β€” Output-based tests that run examples via `dapr run` and check stdout for expected strings. Uses a `DaprRunner` helper to manage process lifecycle. See `examples/AGENTS.md`. +- **`tests/integration/`** β€” Programmatic SDK tests that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. More reliable than output-based tests since they don't depend on print statement formatting. See `tests/integration/AGENTS.md`. Quick reference: ```bash diff --git a/examples/AGENTS.md b/examples/AGENTS.md index e356db9f8..36bd171ef 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md β€” Dapr Python SDK Examples -The `examples/` directory serves as both **user-facing documentation** and the project's **integration test suite**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. +The `examples/` directory serves as the **user-facing documentation**. Each example is a self-contained application validated by pytest-based tests in `tests/examples/`. ## How validation works diff --git a/examples/conversation/real_llm_providers_example.py b/examples/conversation/real_llm_providers_example.py index e37cec745..2347f4b50 100644 --- a/examples/conversation/real_llm_providers_example.py +++ b/examples/conversation/real_llm_providers_example.py @@ -1237,7 +1237,7 @@ def main(): print(f'\n{"=" * 60}') print('πŸŽ‰ All Alpha2 tests completed!') - print('βœ… Real LLM provider examples with Alpha2 API is working correctly') + print('βœ… Real LLM provider integration with Alpha2 API is working correctly') print('πŸ”§ Features demonstrated:') print(' β€’ Alpha2 conversation API with sophisticated message types') print(' β€’ Automatic parameter conversion (raw Python values)') diff --git a/tests/clients/test_conversation_helpers.py b/tests/clients/test_conversation_helpers.py index c9d86db25..e7c69b30e 100644 --- a/tests/clients/test_conversation_helpers.py +++ b/tests/clients/test_conversation_helpers.py @@ -1511,7 +1511,7 @@ def google_function(data: str): class TestIntegrationScenarios(unittest.TestCase): - """Test real-world examples scenarios.""" + """Test real-world integration scenarios.""" def test_restaurant_finder_scenario(self): """Test the restaurant finder example from the documentation.""" diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md new file mode 100644 index 000000000..bb391a418 --- /dev/null +++ b/tests/integration/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md β€” Programmatic Integration Tests + +This directory contains **programmatic SDK tests** that call `DaprClient` methods directly and assert on return values, gRPC status codes, and SDK types. Unlike the output-based tests in `tests/examples/` (which run example scripts and check stdout), these tests don't depend on print statement formatting. + +## How it works + +1. `DaprTestEnvironment` (defined in `conftest.py`) manages Dapr sidecar processes +2. `start_sidecar()` launches `dapr run` with explicit ports, waits for the health check, and returns a connected `DaprClient` +3. Tests call SDK methods on that client and assert on the response objects +4. Sidecar stdout is written to temp files (not pipes) to avoid buffer deadlocks +5. Cleanup terminates sidecars, closes clients, and removes log files + +Run locally (requires a running Dapr runtime via `dapr init`): + +```bash +# All integration tests +tox -e integration + +# Single test file +tox -e integration -- test_state_store.py + +# Single test +tox -e integration -- test_state_store.py -k test_save_and_get +``` + +## Directory structure + +``` +tests/integration/ +β”œβ”€β”€ conftest.py # DaprTestEnvironment + fixtures (dapr_env, apps_dir, components_dir) +β”œβ”€β”€ test_*.py # Test files (one per building block) +β”œβ”€β”€ apps/ # Helper apps started alongside sidecars +β”‚ β”œβ”€β”€ invoke_receiver.py # gRPC method handler for invoke tests +β”‚ └── pubsub_subscriber.py # Subscriber that persists messages to state store +β”œβ”€β”€ components/ # Dapr component YAMLs loaded by all sidecars +β”‚ β”œβ”€β”€ statestore.yaml # state.redis +β”‚ β”œβ”€β”€ pubsub.yaml # pubsub.redis +β”‚ β”œβ”€β”€ lockstore.yaml # lock.redis +β”‚ β”œβ”€β”€ configurationstore.yaml # configuration.redis +β”‚ └── localsecretstore.yaml # secretstores.local.file +└── secrets.json # Secrets file for localsecretstore component +``` + +## Fixtures + +All fixtures are **module-scoped** β€” one sidecar per test file. + +| Fixture | Type | Description | +|---------|------|-------------| +| `dapr_env` | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | +| `apps_dir` | `Path` | Path to `tests/integration/apps/` | +| `components_dir` | `Path` | Path to `tests/integration/components/` | + +Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. + +## Building blocks covered + +| Test file | Building block | SDK methods tested | +|-----------|---------------|-------------------| +| `test_state_store.py` | State management | `save_state`, `get_state`, `save_bulk_state`, `get_bulk_state`, `execute_state_transaction`, `delete_state` | +| `test_invoke.py` | Service invocation | `invoke_method` | +| `test_pubsub.py` | Pub/sub | `publish_event`, `get_state` (to verify delivery) | +| `test_secret_store.py` | Secrets | `get_secret`, `get_bulk_secret` | +| `test_metadata.py` | Metadata | `get_metadata`, `set_metadata` | +| `test_distributed_lock.py` | Distributed lock | `try_lock`, `unlock`, context manager | +| `test_configuration.py` | Configuration | `get_configuration`, `subscribe_configuration`, `unsubscribe_configuration` | + +## Port allocation + +All sidecars default to gRPC port 50001 and HTTP port 3500. Since fixtures are module-scoped and tests run sequentially, only one sidecar is active at a time. If parallel execution is needed in the future, sidecars will need dynamic port allocation. + +## Helper apps + +Some building blocks (invoke, pubsub) require an app process running alongside the sidecar: + +- **`invoke_receiver.py`** β€” A `dapr.ext.grpc.App` that handles `my-method` and returns `INVOKE_RECEIVED`. +- **`pubsub_subscriber.py`** β€” Subscribes to `TOPIC_A` and persists received messages to the state store. This lets tests verify message delivery by reading state rather than parsing stdout. + +## Adding a new test + +1. Create `test_.py` +2. Add a module-scoped `client` fixture that calls `dapr_env.start_sidecar(app_id='test-')` +3. If the building block needs a new Dapr component, add a YAML to `components/` +4. If the building block needs a running app, add it to `apps/` and pass `app_cmd` / `app_port` to `start_sidecar()` +5. Use unique keys/resource IDs per test to avoid interference (the sidecar is shared within a module) +6. Assert on SDK return types and gRPC status codes, not on string output + +## Gotchas + +- **Requires `dapr init`** β€” the tests assume a local Dapr runtime with Redis (`dapr_redis` container on `localhost:6379`), which `dapr init` sets up automatically. +- **Configuration tests seed Redis directly** via `docker exec dapr_redis redis-cli`. +- **Lock and configuration APIs are alpha** and emit `UserWarning` on every call. Tests suppress these with `pytestmark = pytest.mark.filterwarnings('ignore::UserWarning')`. +- **`localsecretstore.yaml` uses a relative path** (`secrets.json`) resolved against `cwd=INTEGRATION_DIR`. +- **Dapr may normalize response fields** β€” e.g., `content_type` may lose charset parameters when proxied through gRPC. Assert on the media type prefix, not the full string. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f8755f50f..5552b2038 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -8,6 +8,7 @@ import pytest from dapr.clients import DaprClient +from dapr.conf import settings INTEGRATION_DIR = Path(__file__).resolve().parent COMPONENTS_DIR = INTEGRATION_DIR / 'components' @@ -42,9 +43,8 @@ def start_sidecar( Args: app_id: Dapr application ID. - grpc_port: Sidecar gRPC port (must match DAPR_GRPC_PORT setting). - http_port: Sidecar HTTP port (must match DAPR_HTTP_PORT setting for - the SDK health check). + grpc_port: Sidecar gRPC port. + http_port: Sidecar HTTP port (also used for the SDK health check). app_port: Port the app listens on (implies ``--app-protocol grpc``). app_cmd: Shell command to start alongside the sidecar. components: Path to component YAML directory. Defaults to @@ -85,9 +85,12 @@ def start_sidecar( # check starts hitting the HTTP endpoint. time.sleep(wait) - # DaprClient constructor calls DaprHealth.wait_for_sidecar(), which - # polls http://localhost:{DAPR_HTTP_PORT}/v1.0/healthz/outbound until - # the sidecar is ready (up to DAPR_HEALTH_TIMEOUT seconds). + # Point the SDK health check at the actual sidecar HTTP port. + # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which + # is initialized once at import time and won't reflect a non-default + # http_port unless we update it here. + settings.DAPR_HTTP_PORT = http_port + client = DaprClient(address=f'127.0.0.1:{grpc_port}') self._clients.append(client) return client diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index d43fc8c8f..960aed6e0 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -16,8 +16,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: Dapr's Redis configuration store encodes values as ``value||version``. """ subprocess.run( - f'docker exec {REDIS_CONTAINER} redis-cli SET {key} "{value}||{version}"', - shell=True, + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'"{value}||{version}"'), check=True, capture_output=True, ) From 7a2e7e1dbd3e628cbb07e18f511a2a4e740011c5 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:31:23 +0200 Subject: [PATCH 05/28] Address Copilot comments (2) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/apps/pubsub_subscriber.py | 3 ++- tests/integration/test_configuration.py | 2 +- tests/integration/test_pubsub.py | 26 ++++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/integration/apps/pubsub_subscriber.py b/tests/integration/apps/pubsub_subscriber.py index 2c1c4761b..110fa14c8 100644 --- a/tests/integration/apps/pubsub_subscriber.py +++ b/tests/integration/apps/pubsub_subscriber.py @@ -17,8 +17,9 @@ @app.subscribe(pubsub_name='pubsub', topic='TOPIC_A') def handle_topic_a(event: v1.Event) -> TopicEventResponse: data = json.loads(event.Data()) + key = f'received-{data["run_id"]}-{data["id"]}' with DaprClient() as d: - d.save_state('statestore', f'received-topic-a-{data["id"]}', event.Data()) + d.save_state('statestore', key, event.Data()) return TopicEventResponse('success') diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 960aed6e0..10e7df835 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -16,7 +16,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: Dapr's Redis configuration store encodes values as ``value||version``. """ subprocess.run( - args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'"{value}||{version}"'), + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'{value}||{version}'), check=True, capture_output=True, ) diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index cee9cf0cf..e4037a8c1 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,15 +1,34 @@ import json +import subprocess import time +import uuid import pytest STORE = 'statestore' PUBSUB = 'pubsub' TOPIC = 'TOPIC_A' +REDIS_CONTAINER = 'dapr_redis' + + +def _flush_redis() -> None: + """Flush the Dapr Redis instance to prevent state leaking between runs. + + Both the state store and the pubsub component point at the same + ``dapr_redis`` container (see ``tests/integration/components/``), so a + previous run's ``received-*`` keys could otherwise satisfy this test's + assertions even if no new message was delivered. + """ + subprocess.run( + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), + check=True, + capture_output=True, + ) @pytest.fixture(scope='module') def client(dapr_env, apps_dir): + _flush_redis() return dapr_env.start_sidecar( app_id='test-subscriber', grpc_port=50001, @@ -20,11 +39,12 @@ def client(dapr_env, apps_dir): def test_published_messages_are_received_by_subscriber(client): + run_id = uuid.uuid4().hex for n in range(1, 4): client.publish_event( pubsub_name=PUBSUB, topic_name=TOPIC, - data=json.dumps({'id': n, 'message': 'hello world'}), + data=json.dumps({'run_id': run_id, 'id': n, 'message': 'hello world'}), data_content_type='application/json', ) time.sleep(1) @@ -32,7 +52,7 @@ def test_published_messages_are_received_by_subscriber(client): time.sleep(3) for n in range(1, 4): - state = client.get_state(store_name=STORE, key=f'received-topic-a-{n}') + state = client.get_state(store_name=STORE, key=f'received-{run_id}-{n}') assert state.data != b'', f'Subscriber did not receive message {n}' msg = json.loads(state.data) assert msg['id'] == n @@ -44,6 +64,6 @@ def test_publish_event_succeeds(client): client.publish_event( pubsub_name=PUBSUB, topic_name=TOPIC, - data=json.dumps({'id': 99, 'message': 'smoke test'}), + data=json.dumps({'run_id': uuid.uuid4().hex, 'id': 99, 'message': 'smoke test'}), data_content_type='application/json', ) From 5cfee7580d7e10fa045f5361d4edac749ddbc7f4 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:11:21 +0200 Subject: [PATCH 06/28] Address Copilot comments (3) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 23 +++++++++++++++++++---- tests/integration/test_configuration.py | 1 + tests/integration/test_pubsub.py | 1 + 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5552b2038..207520e1a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -2,8 +2,9 @@ import subprocess import tempfile import time +from contextlib import contextmanager from pathlib import Path -from typing import Any, Generator +from typing import Any, Generator, Iterator import pytest @@ -113,6 +114,17 @@ def cleanup(self) -> None: self._log_files.clear() +@contextmanager +def _preserve_http_port() -> Iterator[None]: + # start_sidecar() mutates settings.DAPR_HTTP_PORT. + # This restores the original value so it does not leak across test modules. + original = settings.DAPR_HTTP_PORT + try: + yield + finally: + settings.DAPR_HTTP_PORT = original + + @pytest.fixture(scope='module') def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: """Provides a DaprTestEnvironment for programmatic SDK testing. @@ -121,9 +133,12 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: avoiding port conflicts from rapid start/stop cycles and cutting total test time significantly. """ - env = DaprTestEnvironment() - yield env - env.cleanup() + with _preserve_http_port(): + env = DaprTestEnvironment() + try: + yield env + finally: + env.cleanup() @pytest.fixture(scope='module') diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 10e7df835..d7a953107 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -19,6 +19,7 @@ def _redis_set(key: str, value: str, version: int = 1) -> None: args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'{value}||{version}'), check=True, capture_output=True, + timeout=10, ) diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index e4037a8c1..a9fe6c416 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -23,6 +23,7 @@ def _flush_redis() -> None: args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), check=True, capture_output=True, + timeout=10, ) From 3b9b8b63016f51851788f7138e0fc2190cb06698 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:54:19 +0200 Subject: [PATCH 07/28] Replace sleep() with polls when possible Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 92 +++++++++++++++++++++---- tests/integration/test_configuration.py | 1 - tests/integration/test_pubsub.py | 16 ++--- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 207520e1a..858c7f6a6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,13 +4,16 @@ import time from contextlib import contextmanager from pathlib import Path -from typing import Any, Generator, Iterator +from typing import Any, Callable, Generator, Iterator, TypeVar +import httpx import pytest from dapr.clients import DaprClient from dapr.conf import settings +T = TypeVar('T') + INTEGRATION_DIR = Path(__file__).resolve().parent COMPONENTS_DIR = INTEGRATION_DIR / 'components' APPS_DIR = INTEGRATION_DIR / 'apps' @@ -38,7 +41,6 @@ def start_sidecar( app_port: int | None = None, app_cmd: str | None = None, components: Path | None = None, - wait: int = 5, ) -> DaprClient: """Start a Dapr sidecar and return a connected DaprClient. @@ -50,7 +52,6 @@ def start_sidecar( app_cmd: Shell command to start alongside the sidecar. components: Path to component YAML directory. Defaults to ``tests/integration/components/``. - wait: Seconds to sleep after launching (before the SDK health check). """ resources = components or self._default_components @@ -82,18 +83,22 @@ def start_sidecar( ) self._processes.append(proc) - # Give the sidecar a moment to bind its ports before the SDK health - # check starts hitting the HTTP endpoint. - time.sleep(wait) - # Point the SDK health check at the actual sidecar HTTP port. # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which # is initialized once at import time and won't reflect a non-default - # http_port unless we update it here. + # http_port unless we update it here. The DaprClient constructor + # polls /healthz/outbound on this port, so we don't need to sleep first. settings.DAPR_HTTP_PORT = http_port client = DaprClient(address=f'127.0.0.1:{grpc_port}') self._clients.append(client) + + # /healthz/outbound (polled by DaprClient) only checks sidecar-side + # readiness. When we launched an app alongside the sidecar, also wait + # for /v1.0/healthz so invoke_method et al. don't race the app's server. + if app_cmd is not None: + _wait_for_app_health(http_port) + return client def cleanup(self) -> None: @@ -114,15 +119,68 @@ def cleanup(self) -> None: self._log_files.clear() +def _wait_until( + predicate: Callable[[], T | None], + timeout: float = 10.0, + interval: float = 0.1, +) -> T: + """Poll `predicate` until it returns a truthy value. eaises `TimeoutError` if it never does.""" + deadline = time.monotonic() + timeout + while True: + result = predicate() + if result: + return result + if time.monotonic() >= deadline: + raise TimeoutError(f'wait_until timed out after {timeout}s') + time.sleep(interval) + + +def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: + """Poll Dapr's app-facing /v1.0/healthz endpoint until it returns 2xx. + + ``/v1.0/healthz`` requires the app behind the sidecar to be reachable, + unlike ``/v1.0/healthz/outbound`` which only checks sidecar readiness. + """ + url = f'http://127.0.0.1:{http_port}/v1.0/healthz' + + def _check() -> bool: + try: + response = httpx.get(url, timeout=2.0) + except httpx.HTTPError: + return False + return response.is_success + + _wait_until(_check, timeout=timeout, interval=0.2) + + @contextmanager -def _preserve_http_port() -> Iterator[None]: - # start_sidecar() mutates settings.DAPR_HTTP_PORT. - # This restores the original value so it does not leak across test modules. - original = settings.DAPR_HTTP_PORT +def _isolate_dapr_settings() -> Iterator[None]: + """Pin SDK HTTP settings to the local test sidecar for the duration. + + ``DaprHealth.get_api_url()`` consults three settings (see + ``dapr/clients/http/helpers.py``): + + - ``DAPR_HTTP_ENDPOINT``, if set, wins and bypasses host/port entirely. + - ``DAPR_RUNTIME_HOST`` is the host component of the fallback URL. + - ``DAPR_HTTP_PORT`` is the port component of the fallback URL. + + Any of these may be populated from the developer's environment (the Dapr + CLI sets them); without an override the SDK health check could target the + wrong sidecar. All three are snapshotted and restored so the test's + mutations don't leak across modules either. + """ + originals = { + 'DAPR_HTTP_ENDPOINT': settings.DAPR_HTTP_ENDPOINT, + 'DAPR_RUNTIME_HOST': settings.DAPR_RUNTIME_HOST, + 'DAPR_HTTP_PORT': settings.DAPR_HTTP_PORT, + } + settings.DAPR_HTTP_ENDPOINT = None + settings.DAPR_RUNTIME_HOST = '127.0.0.1' try: yield finally: - settings.DAPR_HTTP_PORT = original + for name, value in originals.items(): + setattr(settings, name, value) @pytest.fixture(scope='module') @@ -133,7 +191,7 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: avoiding port conflicts from rapid start/stop cycles and cutting total test time significantly. """ - with _preserve_http_port(): + with _isolate_dapr_settings(): env = DaprTestEnvironment() try: yield env @@ -141,6 +199,12 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: env.cleanup() +@pytest.fixture +def wait_until() -> Callable[..., Any]: + """Returns the ``_wait_until(predicate, timeout=10, interval=0.1)`` helper.""" + return _wait_until + + @pytest.fixture(scope='module') def apps_dir() -> Path: return APPS_DIR diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index d7a953107..e73f1a16a 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -86,6 +86,5 @@ def test_unsubscribe_returns_true(self, client): keys=['cfg-unsub-key'], handler=lambda _id, _resp: None, ) - time.sleep(1) ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) assert ok diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index a9fe6c416..612405b89 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,6 +1,5 @@ import json import subprocess -import time import uuid import pytest @@ -35,11 +34,10 @@ def client(dapr_env, apps_dir): grpc_port=50001, app_port=50051, app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}', - wait=10, ) -def test_published_messages_are_received_by_subscriber(client): +def test_published_messages_are_received_by_subscriber(client, wait_until): run_id = uuid.uuid4().hex for n in range(1, 4): client.publish_event( @@ -48,14 +46,14 @@ def test_published_messages_are_received_by_subscriber(client): data=json.dumps({'run_id': run_id, 'id': n, 'message': 'hello world'}), data_content_type='application/json', ) - time.sleep(1) - - time.sleep(3) for n in range(1, 4): - state = client.get_state(store_name=STORE, key=f'received-{run_id}-{n}') - assert state.data != b'', f'Subscriber did not receive message {n}' - msg = json.loads(state.data) + key = f'received-{run_id}-{n}' + data = wait_until( + lambda k=key: client.get_state(store_name=STORE, key=k).data or None, + timeout=10, + ) + msg = json.loads(data) assert msg['id'] == n assert msg['message'] == 'hello world' From 5dbb5e3fa4bbbb1a37b44ed31ece00b45a5a59d1 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:54:32 +0200 Subject: [PATCH 08/28] Address Copilot comments (4) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- README.md | 8 +++++++- tox.ini | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6b203412..441c37e5b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,13 @@ tox -e py311 tox -e type ``` -8. Run integration tests (validates the examples) +8. Run integration tests + +```bash +tox -e integration +``` + +9. Validate the examples ```bash tox -e examples diff --git a/tox.ini b/tox.ini index 67ecc4dd2..ce3b4279d 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask dapr-ext-langgraph dapr-ext-strands + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From 0a5f0f1d72f9cf5fbd7845056607cc4c069c4b60 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:07:44 +0200 Subject: [PATCH 09/28] Address Copilot comments (5) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 3 ++- tox.ini | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 858c7f6a6..551d0e6d9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -124,7 +124,8 @@ def _wait_until( timeout: float = 10.0, interval: float = 0.1, ) -> T: - """Poll `predicate` until it returns a truthy value. eaises `TimeoutError` if it never does.""" + """Poll `predicate` until it returns a truthy value. + Raises `TimeoutError` if it never returns.""" deadline = time.monotonic() + timeout while True: result = predicate() diff --git a/tox.ini b/tox.ini index ce3b4279d..ca2a5c66f 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands dapr-ext-flask + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask_dapr pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From 836a2ccb412c7ea8828e96cd8034075c3a38f3b9 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:28:52 +0200 Subject: [PATCH 10/28] Update README to include both test suites Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 441c37e5b..f47160556 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,11 @@ tox -e integration tox -e examples ``` -If you need to run the examples against a pre-released version of the runtime, you can use the following command: +If you need to run the examples or integration tests against a pre-released version of the runtime, you can use the following command: - Get your daprd runtime binary from [here](https://github.com/dapr/dapr/releases) for your platform. - Copy the binary to your dapr home folder at $HOME/.dapr/bin/daprd. Or using dapr cli directly: `dapr init --runtime-version ` -- Now you can run the examples with `tox -e integration`. +- Now you can run the examples with `tox -e examples` or the integration tests with `tox -e integration`. ## Documentation From 2d0ea3d662bcd5eefba3c84494e0548ea7430dff Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:01:39 +0200 Subject: [PATCH 11/28] Document wait_until() in AGENTS.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/AGENTS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index bb391a418..2f40750f8 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -43,13 +43,14 @@ tests/integration/ ## Fixtures -All fixtures are **module-scoped** β€” one sidecar per test file. - -| Fixture | Type | Description | -|---------|------|-------------| -| `dapr_env` | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | -| `apps_dir` | `Path` | Path to `tests/integration/apps/` | -| `components_dir` | `Path` | Path to `tests/integration/components/` | +Sidecar and client fixtures are **module-scoped** β€” one sidecar per test file. Helper fixtures may use a different scope; see the table below. + +| Fixture | Scope | Type | Description | +|---------|-------|------|-------------| +| `dapr_env` | module | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | +| `apps_dir` | module | `Path` | Path to `tests/integration/apps/` | +| `components_dir` | module | `Path` | Path to `tests/integration/components/` | +| `wait_until` | function | `Callable` | Polling helper `(predicate, timeout=10, interval=0.1)` for eventual-consistency assertions | Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. From 6720cda7fb2178be04987e16e79b2632ca364ce2 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:38:31 +0200 Subject: [PATCH 12/28] Update CLAUDE.md Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 551d0e6d9..faca00341 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -105,6 +105,7 @@ def cleanup(self) -> None: for client in self._clients: client.close() self._clients.clear() + for proc in self._processes: if proc.poll() is None: proc.terminate() @@ -114,6 +115,7 @@ def cleanup(self) -> None: proc.kill() proc.wait() self._processes.clear() + for log_path in self._log_files: log_path.unlink(missing_ok=True) self._log_files.clear() From 58590ce9756f17e8cdf09936ad8832a5afa07a27 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:05:10 +0200 Subject: [PATCH 13/28] Fix package name Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index ca2a5c66f..4d448210e 100644 --- a/tox.ini +++ b/tox.ini @@ -76,7 +76,7 @@ commands = allowlist_externals=* commands_pre = - pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask_dapr + pip uninstall -y dapr dapr-ext-grpc dapr-ext-fastapi dapr-ext-langgraph dapr-ext-strands flask-dapr pip install -r {toxinidir}/dev-requirements.txt \ -e {toxinidir}/ \ -e {toxinidir}/ext/dapr-ext-workflow/ \ From 62fd1c0431f4950b441be713a80e9b2e7f6500e3 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:27:00 +0200 Subject: [PATCH 14/28] Clean up entire process group Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- ...{validate_examples.yaml => run-tests.yaml} | 0 tests/integration/conftest.py | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) rename .github/workflows/{validate_examples.yaml => run-tests.yaml} (100%) diff --git a/.github/workflows/validate_examples.yaml b/.github/workflows/run-tests.yaml similarity index 100% rename from .github/workflows/validate_examples.yaml rename to .github/workflows/run-tests.yaml diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index faca00341..8af2c92ee 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,5 +1,8 @@ +import os import shlex +import signal import subprocess +import sys import tempfile import time from contextlib import contextmanager @@ -19,6 +22,31 @@ APPS_DIR = INTEGRATION_DIR / 'apps' +def _new_process_group_kwargs() -> dict[str, Any]: + """Popen kwargs that place the child at the head of its own process group. + + ``dapr run`` spawns ``daprd`` and the user's app as siblings; signaling + only the immediate process can orphan them if the signal isn't forwarded, + which leaves stale listeners on the test ports across runs. Putting the + whole subtree in its own group lets cleanup take them all down together. + """ + if sys.platform == 'win32': + return {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} + return {'start_new_session': True} + + +def _terminate_process_group(proc: subprocess.Popen[str], *, force: bool = False) -> None: + """Sends the right termination signal to an entire process group.""" + if sys.platform == 'win32': + if force: + proc.kill() + else: + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + cleanup_signal_unix = signal.SIGKILL if force else signal.SIGTERM + os.killpg(os.getpgid(proc.pid), cleanup_signal_unix) + + class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. @@ -80,6 +108,7 @@ def start_sidecar( stdout=log, stderr=subprocess.STDOUT, text=True, + **_new_process_group_kwargs(), ) self._processes.append(proc) @@ -108,11 +137,11 @@ def cleanup(self) -> None: for proc in self._processes: if proc.poll() is None: - proc.terminate() + _terminate_process_group(proc) try: proc.wait(timeout=10) except subprocess.TimeoutExpired: - proc.kill() + _terminate_process_group(proc, force=True) proc.wait() self._processes.clear() From ebaaded79ce23085160b2996da1d521c080b24c3 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:53:47 +0200 Subject: [PATCH 15/28] PR cleanup (1) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/conftest.py | 43 ++++------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8af2c92ee..554ef918d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,8 +1,5 @@ -import os import shlex -import signal import subprocess -import sys import tempfile import time from contextlib import contextmanager @@ -14,6 +11,7 @@ from dapr.clients import DaprClient from dapr.conf import settings +from tests._process_utils import get_kwargs_for_process_group, terminate_process_group T = TypeVar('T') @@ -22,31 +20,6 @@ APPS_DIR = INTEGRATION_DIR / 'apps' -def _new_process_group_kwargs() -> dict[str, Any]: - """Popen kwargs that place the child at the head of its own process group. - - ``dapr run`` spawns ``daprd`` and the user's app as siblings; signaling - only the immediate process can orphan them if the signal isn't forwarded, - which leaves stale listeners on the test ports across runs. Putting the - whole subtree in its own group lets cleanup take them all down together. - """ - if sys.platform == 'win32': - return {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} - return {'start_new_session': True} - - -def _terminate_process_group(proc: subprocess.Popen[str], *, force: bool = False) -> None: - """Sends the right termination signal to an entire process group.""" - if sys.platform == 'win32': - if force: - proc.kill() - else: - proc.send_signal(signal.CTRL_BREAK_EVENT) - else: - cleanup_signal_unix = signal.SIGKILL if force else signal.SIGTERM - os.killpg(os.getpgid(proc.pid), cleanup_signal_unix) - - class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. @@ -57,7 +30,6 @@ class returns real DaprClient instances so tests can make assertions against SDK def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: self._default_components = default_components self._processes: list[subprocess.Popen[str]] = [] - self._log_files: list[Path] = [] self._clients: list[DaprClient] = [] def start_sidecar( @@ -100,15 +72,14 @@ def start_sidecar( if app_cmd is not None: cmd.extend(['--', *shlex.split(app_cmd)]) - with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log', delete=False) as log: - self._log_files.append(Path(log.name)) + with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log') as log: proc = subprocess.Popen( cmd, cwd=INTEGRATION_DIR, stdout=log, stderr=subprocess.STDOUT, text=True, - **_new_process_group_kwargs(), + **get_kwargs_for_process_group(), ) self._processes.append(proc) @@ -137,18 +108,14 @@ def cleanup(self) -> None: for proc in self._processes: if proc.poll() is None: - _terminate_process_group(proc) + terminate_process_group(proc) try: proc.wait(timeout=10) except subprocess.TimeoutExpired: - _terminate_process_group(proc, force=True) + terminate_process_group(proc, force=True) proc.wait() self._processes.clear() - for log_path in self._log_files: - log_path.unlink(missing_ok=True) - self._log_files.clear() - def _wait_until( predicate: Callable[[], T | None], From 4246672a5d8968914d80cd2f16dcf97f2276f5e0 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:30:44 +0200 Subject: [PATCH 16/28] Fix possible race running example test Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/examples/test_pubsub_streaming_async.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/examples/test_pubsub_streaming_async.py b/tests/examples/test_pubsub_streaming_async.py index 4ea446968..f12695a7a 100644 --- a/tests/examples/test_pubsub_streaming_async.py +++ b/tests/examples/test_pubsub_streaming_async.py @@ -6,7 +6,6 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B1...", - 'Closing subscription...', ] EXPECTED_HANDLER_SUBSCRIBER = [ @@ -15,7 +14,6 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B2...", - 'Closing subscription...', ] EXPECTED_PUBLISHER = [ From 411c3cc14c29b906942544c1db923f99d05749c5 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:53:45 +0200 Subject: [PATCH 17/28] Refactor functions common to example tests and integration tests Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- dev-requirements.txt | 2 + tests/conftest.py | 40 +++++ tests/examples/conftest.py | 2 +- tests/examples/test_configuration.py | 29 +-- tests/examples/test_langgraph_checkpointer.py | 25 +-- tests/integration/conftest.py | 86 +++++---- tests/integration/test_configuration.py | 127 ++++++------- tests/integration/test_distributed_lock.py | 84 ++++----- tests/integration/test_metadata.py | 70 ++++---- tests/integration/test_pubsub.py | 121 ++++++++++--- tests/integration/test_state_store.py | 170 +++++++++--------- tests/{_process_utils.py => process_utils.py} | 11 +- tests/wait_utils.py | 40 +++++ 13 files changed, 463 insertions(+), 344 deletions(-) create mode 100644 tests/conftest.py rename tests/{_process_utils.py => process_utils.py} (70%) create mode 100644 tests/wait_utils.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 670e3ba42..d58d51caa 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,6 +4,7 @@ mypy-protobuf>=2.9 tox>=4.3.0 coverage>=5.3 pytest>=7.0 +pytest-asyncio>=0.23 wheel # used in unit test only opentelemetry-sdk @@ -20,3 +21,4 @@ python-dotenv>=1.0.0 pydantic>=2.13.3 # needed for yaml file generation in examples PyYAML>=6.0.3 +# needed for direct Redis access in integration tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d4accdfc3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +import subprocess +from typing import Callable + +import pytest + +REDIS_CONTAINER = 'dapr_redis' + + +@pytest.fixture(scope='session') +def flush_redis() -> None: + """Flush the ``dapr_redis`` container once per session.""" + subprocess.run( + args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), + check=True, + capture_output=True, + timeout=10, + ) + + +@pytest.fixture(scope='session') +def redis_set_config() -> Callable[..., None]: + """Dapr encodes values in the config store as ``value||version``""" + + def _set(key: str, value: str, version: int = 1) -> None: + subprocess.run( + args=( + 'docker', + 'exec', + REDIS_CONTAINER, + 'redis-cli', + 'SET', + key, + f'{value}||{version}', + ), + check=True, + capture_output=True, + timeout=10, + ) + + return _set diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index 33be85c66..50c381117 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -8,7 +8,7 @@ import pytest -from tests._process_utils import get_kwargs_for_process_group, terminate_process_group +from tests.process_utils import get_kwargs_for_process_group, terminate_process_group REPO_ROOT = Path(__file__).resolve().parent.parent.parent EXAMPLES_DIR = REPO_ROOT / 'examples' diff --git a/tests/examples/test_configuration.py b/tests/examples/test_configuration.py index 45f76624a..0d11eb966 100644 --- a/tests/examples/test_configuration.py +++ b/tests/examples/test_configuration.py @@ -1,10 +1,7 @@ -import subprocess import time import pytest -REDIS_CONTAINER = 'dapr_redis' - EXPECTED_LINES = [ 'Got key=orderId1 value=100 version=1 metadata={}', 'Got key=orderId2 value=200 version=1 metadata={}', @@ -13,33 +10,17 @@ ] -@pytest.fixture() -def redis_config(): - """Seed configuration values in Redis before the test.""" - subprocess.run( - ('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId1', '100||1'), - check=True, - capture_output=True, - ) - subprocess.run( - ('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId2', '200||1'), - check=True, - capture_output=True, - ) - - @pytest.mark.example_dir('configuration') -def test_configuration(dapr, redis_config): +def test_configuration(dapr, redis_set_config): + redis_set_config('orderId1', '100') + redis_set_config('orderId2', '200') + dapr.start( '--app-id configexample --resources-path components/ -- python3 configuration.py', wait=5, ) # Update Redis to trigger the subscription notification - subprocess.run( - ('docker', 'exec', 'dapr_redis', 'redis-cli', 'SET', 'orderId2', '210||2'), - check=True, - capture_output=True, - ) + redis_set_config('orderId2', '210', version=2) # configuration.py sleeps 10s after subscribing before it unsubscribes. # Wait long enough for the full script to finish. time.sleep(10) diff --git a/tests/examples/test_langgraph_checkpointer.py b/tests/examples/test_langgraph_checkpointer.py index 07f58788f..8c57e6250 100644 --- a/tests/examples/test_langgraph_checkpointer.py +++ b/tests/examples/test_langgraph_checkpointer.py @@ -1,9 +1,10 @@ import subprocess -import time import httpx import pytest +from tests.wait_utils import wait_until + OLLAMA_URL = 'http://localhost:11434' MODEL = 'llama3.2:latest' @@ -31,15 +32,6 @@ def _model_available() -> bool: return any(m['name'] == MODEL for m in resp.json().get('models', [])) -def _wait_for_ollama(timeout: float = 30.0, interval: float = 0.5) -> None: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if _ollama_ready(): - return - time.sleep(interval) - raise TimeoutError(f'ollama serve did not become ready within {timeout}s') - - @pytest.fixture() def ollama(): """Ensure Ollama is running and the required model is pulled. @@ -57,7 +49,7 @@ def ollama(): ) except FileNotFoundError: pytest.skip('ollama is not installed') - _wait_for_ollama() + wait_until(_ollama_ready, timeout=30.0, interval=0.5) if not _model_available(): subprocess.run(['ollama', 'pull', MODEL], check=True, capture_output=True) @@ -69,17 +61,6 @@ def ollama(): started.wait(timeout=10) -@pytest.fixture() -def flush_redis(): - """This test is not replayable if the checkpointer state store is not clean.""" - subprocess.run( - ['docker', 'exec', 'dapr_redis', 'redis-cli', 'FLUSHDB'], - capture_output=True, - check=True, - timeout=10, - ) - - @pytest.mark.example_dir('langgraph-checkpointer') def test_langgraph_checkpointer(dapr, ollama, flush_redis): output = dapr.run( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 554ef918d..e83720b13 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,24 +1,25 @@ import shlex +import shutil import subprocess import tempfile -import time from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Generator, Iterator, TypeVar +from typing import IO, Any, Generator, Iterator import httpx import pytest from dapr.clients import DaprClient from dapr.conf import settings -from tests._process_utils import get_kwargs_for_process_group, terminate_process_group - -T = TypeVar('T') +from tests.process_utils import get_kwargs_for_process_group, terminate_process_group +from tests.wait_utils import wait_until INTEGRATION_DIR = Path(__file__).resolve().parent COMPONENTS_DIR = INTEGRATION_DIR / 'components' APPS_DIR = INTEGRATION_DIR / 'apps' +BINDING_DATA_DIR = INTEGRATION_DIR / '.binding-data' + class DaprTestEnvironment: """Manages Dapr sidecars and returns SDK clients for programmatic testing. @@ -30,6 +31,7 @@ class returns real DaprClient instances so tests can make assertions against SDK def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: self._default_components = default_components self._processes: list[subprocess.Popen[str]] = [] + self._log_files: list[IO[str]] = [] self._clients: list[DaprClient] = [] def start_sidecar( @@ -72,16 +74,18 @@ def start_sidecar( if app_cmd is not None: cmd.extend(['--', *shlex.split(app_cmd)]) - with tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log') as log: - proc = subprocess.Popen( - cmd, - cwd=INTEGRATION_DIR, - stdout=log, - stderr=subprocess.STDOUT, - text=True, - **get_kwargs_for_process_group(), - ) + # Keep the log file handle alive for the lifetime of the sidecar + log_file = tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log') + proc = subprocess.Popen( + cmd, + cwd=INTEGRATION_DIR, + stdout=log_file, + stderr=subprocess.STDOUT, + text=True, + **get_kwargs_for_process_group(), + ) self._processes.append(proc) + self._log_files.append(log_file) # Point the SDK health check at the actual sidecar HTTP port. # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which @@ -116,22 +120,9 @@ def cleanup(self) -> None: proc.wait() self._processes.clear() - -def _wait_until( - predicate: Callable[[], T | None], - timeout: float = 10.0, - interval: float = 0.1, -) -> T: - """Poll `predicate` until it returns a truthy value. - Raises `TimeoutError` if it never returns.""" - deadline = time.monotonic() + timeout - while True: - result = predicate() - if result: - return result - if time.monotonic() >= deadline: - raise TimeoutError(f'wait_until timed out after {timeout}s') - time.sleep(interval) + for log_file in self._log_files: + log_file.close() + self._log_files.clear() def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: @@ -144,12 +135,11 @@ def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: def _check() -> bool: try: - response = httpx.get(url, timeout=2.0) + return httpx.get(url, timeout=2.0).is_success except httpx.HTTPError: return False - return response.is_success - _wait_until(_check, timeout=timeout, interval=0.2) + wait_until(_check, timeout=timeout, interval=0.2) @contextmanager @@ -187,8 +177,7 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: """Provides a DaprTestEnvironment for programmatic SDK testing. Module-scoped so that all tests in a file share a single Dapr sidecar, - avoiding port conflicts from rapid start/stop cycles and cutting total - test time significantly. + avoiding port conflicts from rapid start/stop cycles. """ with _isolate_dapr_settings(): env = DaprTestEnvironment() @@ -198,10 +187,18 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]: env.cleanup() -@pytest.fixture -def wait_until() -> Callable[..., Any]: - """Returns the ``_wait_until(predicate, timeout=10, interval=0.1)`` helper.""" - return _wait_until +@pytest.fixture(autouse=True) +def fail_if_dead_sidecars(dapr_env: DaprTestEnvironment) -> None: + """Fail the next test cleanly if a managed sidecar has died. + + Without this, a crashed sidecar produces a cascade of gRPC connection + timeouts on every subsequent test in the module. + """ + dead = [proc for proc in dapr_env._processes if proc.poll() is not None] + if not dead: + return + details = ', '.join(f'pid={p.pid} exit={p.returncode}' for p in dead) + raise RuntimeError(f'Dapr sidecar exited unexpectedly: {details}') @pytest.fixture(scope='module') @@ -212,3 +209,14 @@ def apps_dir() -> Path: @pytest.fixture(scope='module') def components_dir() -> Path: return COMPONENTS_DIR + + +@pytest.fixture(scope='session', autouse=True) +def _binding_data_dir() -> Generator[None, None, None]: + """Provide a fresh ``.binding-data/`` for the localbinding component""" + shutil.rmtree(BINDING_DATA_DIR, ignore_errors=True) + BINDING_DATA_DIR.mkdir() + try: + yield + finally: + shutil.rmtree(BINDING_DATA_DIR, ignore_errors=True) diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index e73f1a16a..81083c269 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -1,32 +1,18 @@ -import subprocess import threading -import time import pytest +import redis from dapr.clients.grpc._response import ConfigurationResponse +from tests.wait_utils import wait_until STORE = 'configurationstore' -REDIS_CONTAINER = 'dapr_redis' - - -def _redis_set(key: str, value: str, version: int = 1) -> None: - """Seed a configuration value directly in Redis. - - Dapr's Redis configuration store encodes values as ``value||version``. - """ - subprocess.run( - args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'SET', key, f'{value}||{version}'), - check=True, - capture_output=True, - timeout=10, - ) @pytest.fixture(scope='module') -def client(dapr_env): - _redis_set('cfg-key-1', 'val-1') - _redis_set('cfg-key-2', 'val-2') +def client(dapr_env, redis_set_config): + redis_set_config('cfg-key-1', 'val-1') + redis_set_config('cfg-key-2', 'val-2') return dapr_env.start_sidecar(app_id='test-config') @@ -36,55 +22,58 @@ def test_get_single_key(self, client): assert 'cfg-key-1' in resp.items assert resp.items['cfg-key-1'].value == 'val-1' - def test_get_multiple_keys(self, client): - resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1', 'cfg-key-2']) - assert resp.items['cfg-key-1'].value == 'val-1' - assert resp.items['cfg-key-2'].value == 'val-2' - def test_get_missing_key_returns_empty_items(self, client): - resp = client.get_configuration(store_name=STORE, keys=['nonexistent-cfg-key']) - # Dapr omits keys that don't exist from the response. - assert 'nonexistent-cfg-key' not in resp.items +def test_get_multiple_keys(client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1', 'cfg-key-2']) + assert resp.items['cfg-key-1'].value == 'val-1' + assert resp.items['cfg-key-2'].value == 'val-2' - def test_items_have_version(self, client): - resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) - item = resp.items['cfg-key-1'] - assert item.version - - -class TestSubscribeConfiguration: - def test_subscribe_receives_update(self, client): - received: list[ConfigurationResponse] = [] - event = threading.Event() - - def handler(_id: str, resp: ConfigurationResponse) -> None: - received.append(resp) - event.set() - - sub_id = client.subscribe_configuration( - store_name=STORE, keys=['cfg-sub-key'], handler=handler - ) - assert sub_id - - # Give the subscription watcher thread time to establish its gRPC - # stream before pushing the update, otherwise the notification is missed. - time.sleep(1) - _redis_set('cfg-sub-key', 'updated-val', version=2) - event.wait(timeout=10) - - assert len(received) >= 1 - last = received[-1] - assert 'cfg-sub-key' in last.items - assert last.items['cfg-sub-key'].value == 'updated-val' - - ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) - assert ok - - def test_unsubscribe_returns_true(self, client): - sub_id = client.subscribe_configuration( - store_name=STORE, - keys=['cfg-unsub-key'], - handler=lambda _id, _resp: None, - ) - ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) - assert ok + +def test_get_missing_key_returns_empty_items(client): + resp = client.get_configuration(store_name=STORE, keys=['nonexistent-cfg-key']) + # Dapr omits keys that don't exist from the response. + assert 'nonexistent-cfg-key' not in resp.items + + +def test_items_have_version(client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + item = resp.items['cfg-key-1'] + assert item.version + + +def test_subscribe_receives_update(client, redis_set_config): + received: list[ConfigurationResponse] = [] + event = threading.Event() + + def handler(_id: str, resp: ConfigurationResponse) -> None: + received.append(resp) + event.set() + + sub_id = client.subscribe_configuration(store_name=STORE, keys=['cfg-sub-key'], handler=handler) + assert sub_id + + # This is necessary because the Dapr runtime returns the subscription ID before the Redis + # configuration component finishes registering the subscription + def _set_and_check() -> bool: + redis_set_config('cfg-sub-key', 'updated-val', version=2) + return event.is_set() + + wait_until(_set_and_check, timeout=10, interval=0.2) + + assert len(received) >= 1 + last = received[-1] + assert 'cfg-sub-key' in last.items + assert last.items['cfg-sub-key'].value == 'updated-val' + + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok + + +def test_unsubscribe_returns_true(client): + sub_id = client.subscribe_configuration( + store_name=STORE, + keys=['cfg-unsub-key'], + handler=lambda _id, _resp: None, + ) + ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id) + assert ok diff --git a/tests/integration/test_distributed_lock.py b/tests/integration/test_distributed_lock.py index 68362c296..44ee73da4 100644 --- a/tests/integration/test_distributed_lock.py +++ b/tests/integration/test_distributed_lock.py @@ -4,7 +4,7 @@ STORE = 'lockstore' -# The distributed lock API emits alpha warnings on every call. +# The distributed lock API re-emits the alpha warnings on every test run. pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') @@ -13,54 +13,56 @@ def client(dapr_env): return dapr_env.start_sidecar(app_id='test-lock') -class TestTryLock: - def test_acquire_lock(self, client): - lock = client.try_lock(STORE, 'res-acquire', 'owner-a', expiry_in_seconds=10) - assert lock.success +def test_try_lock_acquires(client): + lock = client.try_lock(STORE, 'res-acquire', 'owner-a', expiry_in_seconds=10) + assert lock.success - def test_second_owner_is_rejected(self, client): - first = client.try_lock(STORE, 'res-contention', 'owner-a', expiry_in_seconds=10) - second = client.try_lock(STORE, 'res-contention', 'owner-b', expiry_in_seconds=10) - assert first.success - assert not second.success - def test_lock_is_truthy_on_success(self, client): - lock = client.try_lock(STORE, 'res-truthy', 'owner-a', expiry_in_seconds=10) - assert bool(lock) is True +def test_try_lock_second_owner_is_rejected(client): + first = client.try_lock(STORE, 'res-contention', 'owner-a', expiry_in_seconds=10) + second = client.try_lock(STORE, 'res-contention', 'owner-b', expiry_in_seconds=10) + assert first.success + assert not second.success - def test_failed_lock_is_falsy(self, client): - client.try_lock(STORE, 'res-falsy', 'owner-a', expiry_in_seconds=10) - contested = client.try_lock(STORE, 'res-falsy', 'owner-b', expiry_in_seconds=10) - assert bool(contested) is False +def test_try_lock_is_truthy_on_success(client): + lock = client.try_lock(STORE, 'res-truthy', 'owner-a', expiry_in_seconds=10) + assert bool(lock) is True -class TestUnlock: - def test_unlock_own_lock(self, client): - client.try_lock(STORE, 'res-unlock', 'owner-a', expiry_in_seconds=10) - resp = client.unlock(STORE, 'res-unlock', 'owner-a') - assert resp.status == UnlockResponseStatus.success - def test_unlock_wrong_owner(self, client): - client.try_lock(STORE, 'res-wrong-owner', 'owner-a', expiry_in_seconds=10) - resp = client.unlock(STORE, 'res-wrong-owner', 'owner-b') - assert resp.status == UnlockResponseStatus.lock_belongs_to_others +def test_try_lock_failed_lock_is_falsy(client): + client.try_lock(STORE, 'res-falsy', 'owner-a', expiry_in_seconds=10) + contested = client.try_lock(STORE, 'res-falsy', 'owner-b', expiry_in_seconds=10) + assert bool(contested) is False - def test_unlock_nonexistent(self, client): - resp = client.unlock(STORE, 'res-does-not-exist', 'owner-a') - assert resp.status == UnlockResponseStatus.lock_does_not_exist - def test_unlock_frees_resource_for_others(self, client): - client.try_lock(STORE, 'res-release', 'owner-a', expiry_in_seconds=10) - client.unlock(STORE, 'res-release', 'owner-a') - second = client.try_lock(STORE, 'res-release', 'owner-b', expiry_in_seconds=10) - assert second.success +def test_unlock_own_lock(client): + client.try_lock(STORE, 'res-unlock', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-unlock', 'owner-a') + assert resp.status == UnlockResponseStatus.success -class TestLockContextManager: - def test_context_manager_auto_unlocks(self, client): - with client.try_lock(STORE, 'res-ctx', 'owner-a', expiry_in_seconds=10) as lock: - assert lock +def test_unlock_wrong_owner(client): + client.try_lock(STORE, 'res-wrong-owner', 'owner-a', expiry_in_seconds=10) + resp = client.unlock(STORE, 'res-wrong-owner', 'owner-b') + assert resp.status == UnlockResponseStatus.lock_belongs_to_others - # After the context manager exits, another owner should be able to acquire. - second = client.try_lock(STORE, 'res-ctx', 'owner-b', expiry_in_seconds=10) - assert second.success + +def test_unlock_nonexistent(client): + resp = client.unlock(STORE, 'res-does-not-exist', 'owner-a') + assert resp.status == UnlockResponseStatus.lock_does_not_exist + + +def test_unlock_frees_resource_for_others(client): + client.try_lock(STORE, 'res-release', 'owner-a', expiry_in_seconds=10) + client.unlock(STORE, 'res-release', 'owner-a') + second = client.try_lock(STORE, 'res-release', 'owner-b', expiry_in_seconds=10) + assert second.success + + +def test_context_manager_auto_unlocks(client): + with client.try_lock(STORE, 'res-ctx', 'owner-a', expiry_in_seconds=10) as lock: + assert lock + + second = client.try_lock(STORE, 'res-ctx', 'owner-b', expiry_in_seconds=10) + assert second.success diff --git a/tests/integration/test_metadata.py b/tests/integration/test_metadata.py index 88430ebbb..19126d6cf 100644 --- a/tests/integration/test_metadata.py +++ b/tests/integration/test_metadata.py @@ -6,37 +6,39 @@ def client(dapr_env): return dapr_env.start_sidecar(app_id='test-metadata') -class TestGetMetadata: - def test_application_id_matches(self, client): - meta = client.get_metadata() - assert meta.application_id == 'test-metadata' - - def test_registered_components_present(self, client): - meta = client.get_metadata() - component_types = {c.type for c in meta.registered_components} - assert any(t.startswith('state.') for t in component_types) - - def test_registered_components_have_names(self, client): - meta = client.get_metadata() - for comp in meta.registered_components: - assert comp.name - assert comp.type - - -class TestSetMetadata: - def test_set_and_get_roundtrip(self, client): - client.set_metadata('test-key', 'test-value') - meta = client.get_metadata() - assert meta.extended_metadata.get('test-key') == 'test-value' - - def test_overwrite_existing_key(self, client): - client.set_metadata('overwrite-key', 'first') - client.set_metadata('overwrite-key', 'second') - meta = client.get_metadata() - assert meta.extended_metadata['overwrite-key'] == 'second' - - def test_empty_value_is_allowed(self, client): - client.set_metadata('empty-key', '') - meta = client.get_metadata() - assert 'empty-key' in meta.extended_metadata - assert meta.extended_metadata['empty-key'] == '' +def test_application_id_matches(client): + meta = client.get_metadata() + assert meta.application_id == 'test-metadata' + + +def test_registered_components_present(client): + meta = client.get_metadata() + component_types = {c.type for c in meta.registered_components} + assert any(t.startswith('state.') for t in component_types) + + +def test_registered_components_have_names(client): + meta = client.get_metadata() + for comp in meta.registered_components: + assert comp.name + assert comp.type + + +def test_set_and_get_roundtrip(client): + client.set_metadata('test-key', 'test-value') + meta = client.get_metadata() + assert meta.extended_metadata.get('test-key') == 'test-value' + + +def test_overwrite_existing_key(client): + client.set_metadata('overwrite-key', 'first') + client.set_metadata('overwrite-key', 'second') + meta = client.get_metadata() + assert meta.extended_metadata['overwrite-key'] == 'second' + + +def test_empty_value_is_allowed(client): + client.set_metadata('empty-key', '') + meta = client.get_metadata() + assert 'empty-key' in meta.extended_metadata + assert meta.extended_metadata['empty-key'] == '' diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index 612405b89..8cdb78777 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,34 +1,21 @@ import json -import subprocess +import threading import uuid import pytest +from dapr.clients.grpc._response import TopicEventResponse +from tests.wait_utils import wait_until + STORE = 'statestore' PUBSUB = 'pubsub' TOPIC = 'TOPIC_A' -REDIS_CONTAINER = 'dapr_redis' - - -def _flush_redis() -> None: - """Flush the Dapr Redis instance to prevent state leaking between runs. - - Both the state store and the pubsub component point at the same - ``dapr_redis`` container (see ``tests/integration/components/``), so a - previous run's ``received-*`` keys could otherwise satisfy this test's - assertions even if no new message was delivered. - """ - subprocess.run( - args=('docker', 'exec', REDIS_CONTAINER, 'redis-cli', 'FLUSHDB'), - check=True, - capture_output=True, - timeout=10, - ) +TOPIC_STREAM = 'TOPIC_STREAM' +TOPIC_HANDLER = 'TOPIC_HANDLER' @pytest.fixture(scope='module') -def client(dapr_env, apps_dir): - _flush_redis() +def client(dapr_env, apps_dir, flush_redis): return dapr_env.start_sidecar( app_id='test-subscriber', grpc_port=50001, @@ -37,7 +24,7 @@ def client(dapr_env, apps_dir): ) -def test_published_messages_are_received_by_subscriber(client, wait_until): +def test_published_messages_are_received_by_subscriber(client): run_id = uuid.uuid4().hex for n in range(1, 4): client.publish_event( @@ -59,10 +46,98 @@ def test_published_messages_are_received_by_subscriber(client, wait_until): def test_publish_event_succeeds(client): - """Verify publish_event does not raise on a valid topic.""" + run_id = uuid.uuid4().hex client.publish_event( pubsub_name=PUBSUB, topic_name=TOPIC, - data=json.dumps({'run_id': uuid.uuid4().hex, 'id': 99, 'message': 'smoke test'}), + data=json.dumps({'run_id': run_id, 'id': 99, 'message': 'smoke test'}), data_content_type='application/json', ) + + key = f'received-{run_id}-99' + data = wait_until( + lambda: client.get_state(store_name=STORE, key=key).data or None, + timeout=10, + ) + msg = json.loads(data) + assert msg['id'] == 99 + assert msg['message'] == 'smoke test' + + +def test_bulk_publish_delivers_all_messages(client): + run_id = uuid.uuid4().hex + payloads = [ + json.dumps({'run_id': run_id, 'id': n, 'message': f'bulk-{n}'}) for n in range(1, 4) + ] + + response = client.publish_events( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=payloads, + data_content_type='application/json', + ) + assert response.failed_entries == [] + + for n in range(1, 4): + key = f'received-{run_id}-{n}' + data = wait_until( + lambda k=key: client.get_state(store_name=STORE, key=k).data or None, + timeout=10, + ) + msg = json.loads(data) + assert msg['id'] == n + assert msg['message'] == f'bulk-{n}' + + +def test_streaming_subscribe_receives_published_message(client): + subscription = client.subscribe(pubsub_name=PUBSUB, topic=TOPIC_STREAM) + try: + run_id = uuid.uuid4().hex + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC_STREAM, + data=json.dumps({'run_id': run_id, 'message': 'streaming hello'}), + data_content_type='application/json', + ) + + message = subscription.next_message() + assert message is not None + subscription.respond_success(message) + + payload = message.data() + assert payload['run_id'] == run_id + assert payload['message'] == 'streaming hello' + finally: + subscription.close() + + +def test_subscribe_with_handler_invokes_callback(client): + received: list[dict] = [] + handler_done = threading.Event() + + def handler(message) -> TopicEventResponse: + received.append(message.data()) + if len(received) >= 2: + handler_done.set() + return TopicEventResponse('success') + + close_fn = client.subscribe_with_handler( + pubsub_name=PUBSUB, + topic=TOPIC_HANDLER, + handler_fn=handler, + ) + try: + run_id = uuid.uuid4().hex + for n in range(1, 3): + client.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC_HANDLER, + data=json.dumps({'run_id': run_id, 'id': n}), + data_content_type='application/json', + ) + + assert handler_done.wait(timeout=10), 'handler was not invoked' + ids = sorted(msg['id'] for msg in received if msg['run_id'] == run_id) + assert ids == [1, 2] + finally: + close_fn() diff --git a/tests/integration/test_state_store.py b/tests/integration/test_state_store.py index 26ef51cad..bc094e19d 100644 --- a/tests/integration/test_state_store.py +++ b/tests/integration/test_state_store.py @@ -12,91 +12,91 @@ def client(dapr_env): return dapr_env.start_sidecar(app_id='test-state') -class TestSaveAndGetState: - def test_save_and_get(self, client): - client.save_state(store_name=STORE, key='k1', value='v1') - state = client.get_state(store_name=STORE, key='k1') - assert state.data == b'v1' - assert state.etag - - def test_save_with_wrong_etag_fails(self, client): - client.save_state(store_name=STORE, key='etag-test', value='original') - with pytest.raises(grpc.RpcError) as exc_info: - client.save_state(store_name=STORE, key='etag-test', value='bad', etag='9999') - assert exc_info.value.code() == grpc.StatusCode.ABORTED - - def test_get_missing_key_returns_empty(self, client): - state = client.get_state(store_name=STORE, key='nonexistent-key') - assert state.data == b'' - - -class TestBulkState: - def test_save_and_get_bulk(self, client): +def test_save_and_get(client): + client.save_state(store_name=STORE, key='k1', value='v1') + state = client.get_state(store_name=STORE, key='k1') + assert state.data == b'v1' + assert state.etag + + +def test_save_with_wrong_etag_fails(client): + client.save_state(store_name=STORE, key='etag-test', value='original') + with pytest.raises(grpc.RpcError) as exc_info: + client.save_state(store_name=STORE, key='etag-test', value='bad', etag='9999') + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + +def test_get_missing_key_returns_empty(client): + state = client.get_state(store_name=STORE, key='nonexistent-key') + assert state.data == b'' + + +def test_save_and_get_bulk(client): + client.save_bulk_state( + store_name=STORE, + states=[ + StateItem(key='bulk-1', value='v1'), + StateItem(key='bulk-2', value='v2'), + ], + ) + items = client.get_bulk_state(store_name=STORE, keys=['bulk-1', 'bulk-2']).items + by_key = {i.key: i.data for i in items} + assert by_key['bulk-1'] == b'v1' + assert by_key['bulk-2'] == b'v2' + + +def test_save_bulk_with_wrong_etag_fails(client): + client.save_state(store_name=STORE, key='bulk-etag-1', value='original') + with pytest.raises(grpc.RpcError) as exc_info: client.save_bulk_state( store_name=STORE, - states=[ - StateItem(key='bulk-1', value='v1'), - StateItem(key='bulk-2', value='v2'), - ], + states=[StateItem(key='bulk-etag-1', value='updated', etag='9999')], ) - items = client.get_bulk_state(store_name=STORE, keys=['bulk-1', 'bulk-2']).items - by_key = {i.key: i.data for i in items} - assert by_key['bulk-1'] == b'v1' - assert by_key['bulk-2'] == b'v2' - - def test_save_bulk_with_wrong_etag_fails(self, client): - client.save_state(store_name=STORE, key='bulk-etag-1', value='original') - with pytest.raises(grpc.RpcError) as exc_info: - client.save_bulk_state( - store_name=STORE, - states=[StateItem(key='bulk-etag-1', value='updated', etag='9999')], - ) - assert exc_info.value.code() == grpc.StatusCode.ABORTED - - -class TestStateTransactions: - def test_transaction_upsert(self, client): - client.save_state(store_name=STORE, key='tx-1', value='original') - etag = client.get_state(store_name=STORE, key='tx-1').etag - - client.execute_state_transaction( - store_name=STORE, - operations=[ - TransactionalStateOperation( - operation_type=TransactionOperationType.upsert, - key='tx-1', - data='updated', - etag=etag, - ), - TransactionalStateOperation(key='tx-2', data='new'), - ], - ) - - assert client.get_state(store_name=STORE, key='tx-1').data == b'updated' - assert client.get_state(store_name=STORE, key='tx-2').data == b'new' - - def test_transaction_delete(self, client): - client.save_state(store_name=STORE, key='tx-del-1', value='v1') - client.save_state(store_name=STORE, key='tx-del-2', value='v2') - - client.execute_state_transaction( - store_name=STORE, - operations=[ - TransactionalStateOperation( - operation_type=TransactionOperationType.delete, key='tx-del-1' - ), - TransactionalStateOperation( - operation_type=TransactionOperationType.delete, key='tx-del-2' - ), - ], - ) - - assert client.get_state(store_name=STORE, key='tx-del-1').data == b'' - assert client.get_state(store_name=STORE, key='tx-del-2').data == b'' - - -class TestDeleteState: - def test_delete_single(self, client): - client.save_state(store_name=STORE, key='del-1', value='v1') - client.delete_state(store_name=STORE, key='del-1') - assert client.get_state(store_name=STORE, key='del-1').data == b'' + assert exc_info.value.code() == grpc.StatusCode.ABORTED + + +def test_transaction_upsert(client): + client.save_state(store_name=STORE, key='tx-1', value='original') + etag = client.get_state(store_name=STORE, key='tx-1').etag + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.upsert, + key='tx-1', + data='updated', + etag=etag, + ), + TransactionalStateOperation(key='tx-2', data='new'), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-1').data == b'updated' + assert client.get_state(store_name=STORE, key='tx-2').data == b'new' + + +def test_transaction_delete(client): + client.save_state(store_name=STORE, key='tx-del-1', value='v1') + client.save_state(store_name=STORE, key='tx-del-2', value='v2') + + client.execute_state_transaction( + store_name=STORE, + operations=[ + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-1' + ), + TransactionalStateOperation( + operation_type=TransactionOperationType.delete, key='tx-del-2' + ), + ], + ) + + assert client.get_state(store_name=STORE, key='tx-del-1').data == b'' + assert client.get_state(store_name=STORE, key='tx-del-2').data == b'' + + +def test_delete_single(client): + client.save_state(store_name=STORE, key='del-1', value='v1') + client.delete_state(store_name=STORE, key='del-1') + assert client.get_state(store_name=STORE, key='del-1').data == b'' diff --git a/tests/_process_utils.py b/tests/process_utils.py similarity index 70% rename from tests/_process_utils.py rename to tests/process_utils.py index 0fb6fd483..7aa693989 100644 --- a/tests/_process_utils.py +++ b/tests/process_utils.py @@ -1,9 +1,8 @@ -"""Cross-platform helpers for managing subprocess trees in tests. - -``dapr run`` spawns ``daprd`` and the user's app as siblings; signaling only -the immediate process can orphan them if the signal isn't forwarded, which -leaves stale listeners on the test ports across runs. Putting the whole -subtree in its own group lets cleanup take them all down together. +""" +``dapr run`` spawns ``daprd`` and the user's app as siblings, not as children. +Terminating only the immediate process can orphan them if the signal isn't forwarded, +leaving stale listeners on the test ports across runs. +Putting all the processes in the same group lets cleanup take them all down together. """ from __future__ import annotations diff --git a/tests/wait_utils.py b/tests/wait_utils.py new file mode 100644 index 000000000..0996561ac --- /dev/null +++ b/tests/wait_utils.py @@ -0,0 +1,40 @@ +import asyncio +import time +from typing import Awaitable, Callable, TypeVar + +T = TypeVar('T') + + +def wait_until( + condition: Callable[[], T | None], + timeout: float = 10.0, + interval: float = 0.1, +) -> T: + """Poll ``condition`` until it returns a truthy value. + + Raises ``TimeoutError`` if the deadline elapses first. + """ + deadline = time.monotonic() + timeout + while True: + result = condition() + if result: + return result + if time.monotonic() >= deadline: + raise TimeoutError(f'wait_until timed out after {timeout}s') + time.sleep(interval) + + +async def wait_until_async( + condition: Callable[[], Awaitable[T | None]], + timeout: float = 10.0, + interval: float = 0.1, +) -> T: + """Async counterpart to `wait_until`: polls an awaitable condition.""" + deadline = time.monotonic() + timeout + while True: + result = await condition() + if result: + return result + if time.monotonic() >= deadline: + raise TimeoutError(f'wait_until_async timed out after {timeout}s') + await asyncio.sleep(interval) From 31892adfa7063bdde2291158c6b82d812287df84 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:55:55 +0200 Subject: [PATCH 18/28] Add regression test for config race Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/integration/test_configuration.py | 26 ++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 81083c269..f1fe35f92 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -16,11 +16,27 @@ def client(dapr_env, redis_set_config): return dapr_env.start_sidecar(app_id='test-config') -class TestGetConfiguration: - def test_get_single_key(self, client): - resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) - assert 'cfg-key-1' in resp.items - assert resp.items['cfg-key-1'].value == 'val-1' +@pytest.mark.xfail( + reason='The sidecar returns the subscription ID before the subscription is active', +) +def test_subscribe_first_update_race(client): + r = redis.Redis(host='127.0.0.1', port=6379) + r.ping() + event = threading.Event() + sub_id = client.subscribe_configuration( + store_name=STORE, + keys=['cfg-race-key'], + handler=lambda _id, _resp: event.set(), + ) + assert sub_id + r.set('cfg-race-key', 'val||1') + assert event.wait(timeout=2) + + +def test_get_single_key(client): + resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) + assert 'cfg-key-1' in resp.items + assert resp.items['cfg-key-1'].value == 'val-1' def test_get_multiple_keys(client): From cd6e4e1f3d721ed4795f9c9cb96cd9cbe11c78f4 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:56:33 +0200 Subject: [PATCH 19/28] Add tests for uncovered components Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .../integration/components/conversation.yaml | 7 + tests/integration/components/cryptostore.yaml | 10 ++ .../integration/components/localbinding.yaml | 10 ++ tests/integration/components/statestore.yaml | 2 + tests/integration/keys/rsa-private-key.pem | 52 ++++++ tests/integration/keys/symmetric-key-256 | 1 + tests/integration/test_conversation.py | 114 +++++++++++++ tests/integration/test_crypto.py | 107 ++++++++++++ tests/integration/test_invoke_binding.py | 99 ++++++++++++ tests/integration/test_jobs.py | 101 ++++++++++++ tests/integration/test_workflow.py | 152 ++++++++++++++++++ 11 files changed, 655 insertions(+) create mode 100644 tests/integration/components/conversation.yaml create mode 100644 tests/integration/components/cryptostore.yaml create mode 100644 tests/integration/components/localbinding.yaml create mode 100644 tests/integration/keys/rsa-private-key.pem create mode 100644 tests/integration/keys/symmetric-key-256 create mode 100644 tests/integration/test_conversation.py create mode 100644 tests/integration/test_crypto.py create mode 100644 tests/integration/test_invoke_binding.py create mode 100644 tests/integration/test_jobs.py create mode 100644 tests/integration/test_workflow.py diff --git a/tests/integration/components/conversation.yaml b/tests/integration/components/conversation.yaml new file mode 100644 index 000000000..efb651fef --- /dev/null +++ b/tests/integration/components/conversation.yaml @@ -0,0 +1,7 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: echo +spec: + type: conversation.echo + version: v1 diff --git a/tests/integration/components/cryptostore.yaml b/tests/integration/components/cryptostore.yaml new file mode 100644 index 000000000..7eea9b06b --- /dev/null +++ b/tests/integration/components/cryptostore.yaml @@ -0,0 +1,10 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: cryptostore +spec: + type: crypto.dapr.localstorage + version: v1 + metadata: + - name: path + value: ./keys \ No newline at end of file diff --git a/tests/integration/components/localbinding.yaml b/tests/integration/components/localbinding.yaml new file mode 100644 index 000000000..ae0ee7603 --- /dev/null +++ b/tests/integration/components/localbinding.yaml @@ -0,0 +1,10 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: localbinding +spec: + type: bindings.localstorage + version: v1 + metadata: + - name: rootPath + value: ./.binding-data diff --git a/tests/integration/components/statestore.yaml b/tests/integration/components/statestore.yaml index a0c53bc40..2f676bff8 100644 --- a/tests/integration/components/statestore.yaml +++ b/tests/integration/components/statestore.yaml @@ -10,3 +10,5 @@ spec: value: localhost:6379 - name: redisPassword value: "" + - name: actorStateStore + value: "true" diff --git a/tests/integration/keys/rsa-private-key.pem b/tests/integration/keys/rsa-private-key.pem new file mode 100644 index 000000000..a2de6762a --- /dev/null +++ b/tests/integration/keys/rsa-private-key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDNPVTQbkKW4eja +oCDnsacryzQF/Q4vAMfl7oMjtVm9QHM/k5AJqPDJQhjollQsRzJ5g8FkPdSUnlio +C+yd+3lSmKx2j5LC6F5YIkzFJOjVU5rQwAxJkbpVhoXF2t4b5UXrbZxgoU/tjnAc +FIIPCALbbm02HSb2SZQYPMgDGI+wZ11HI2AyAqAftfCrdmT8PkW0+XmPRz3auSWm +5o0I4v7chx7T1LN6Y7xAXd+3sE7CWhWR5cGpabxAZ2Zz5u48vdBhF/5hh6R+Ebov +jXW6i5ZpU5DEk6z3aU1fbciCZqIkIFHD8rErwMJasnZhNys/4U5R1xM86IVwI1Eb +fa3AnbtE3Vj66TUSwTWM664xoxuNCvK2/v33Mbwg2AM9a8Nd9n6dNIdQ3Ryh1gtF +SK7yyA95E53mHt6l47m6/qt5dXehngBK79vlE0gkJCxLuzf35UgTsrEBl601dXp5 +SQHkjRFnToDzNTeiWzlzLcBu50lDyPMXtBkjop6mSKrtFsb4puXEd/WVgNlcfSZe +TK0ASmCuXdBia8kdVUlDizYEWtz5T+E4r3OmWiflxH4YAFrhxT++fNe1WO3+Y1uu +XAR7RzU7eE68ETDwvLkYCvraDP1iMXGeTMCjIZdu49vLNMdpFsJPVAW5bAz2kJOr +9sLSLoHGwAn+WWE4NhfHeInYstA2YwIDAQABAoICABaUSsJrfvPugpmayEP1LXkJ +7/1Aq9DL+zH2nYLOLsM9VfCGoXAOn/7kQK1F7Ji6dHkd36bRjpOTIBnMxglTYzON +DFw2y2SZ/9ceXufJebwOaJfSqQdm+uLx28G6pHjZLmoKMwwGcy6lXvwX3X8d2IKf +kXBEoMazrZFFDpQYnaZAmOh8odaep1MVxxZ1/gIqL60LTS5QHiPz/opwDtANeRB1 +5RRU8DHkyw8hxL0GroN/OaRFbJrgwQ8s0P6rR0Zzc3tbEmdUbupXtO4KWAtf0/pe +cSzPOlY1xYdcIpUGCYyD6bru9kLj//3OaGulkCKE/QLP8JPg2N1PZVrq5rSsJa/p +YFoJR5uvK02YPJA/+tWbd78WRXt6iPcARkcwB5YDk7hAbuzYGFrU0CNRtY2bPTOX +n4ogkHx8921/nxLlsf0SMzeZWGPb9/rbAUmM/TZJXSHy5XgeiTckI2HA5tyN4QBT +Yues38Aefda46oSTqo135A3D3MbCHeGu401+zlgftV4IuF0XApGyRWK67E3VVmoA +0hvmkzmC/qNgawR5lkk08+ZpyDUnT42RI+KsO9cRE4vRSJiVZdjFAE3rcf8R2gyQ +Xf3liFicV3YlpoxGB3/AO510wVq0yNfbCOhJQ54fA/ZVE97zIC7HhmFCcB6coygm +uXyYGePwDH6bo66/F83RAoIBAQDpmHwI/K9MdZKF7geS3MPQOKAGby6vncgvApeL +rGxM8ScO1QxV8uQ3emvKS0KLvMRDtpyPz4aHzq00DaEI7UYPxCGsN+/pf/PyI+Tm +WrfQXOZUjTL/0CsSXmwcvGQVMruB3cjrmj7B9RPH0jIZv+esNfH6u7gpvrNgbqxs +PneEN1XtFxe08G91R0hN07ggbhqqUChW4hbytl/KqVDlYPCKGZfDIigBTI5vsd4L +KtMGfZ5fW6acj8Dn3A8hzYHnNXI1E1mAl9Zu+TRW/pDaaPBoKqhodSSDAb0RoJGc +y1bZbWSy3QoAYN0wla/kE6P3LQ7diMtmj3d3b3ChSI0Mx5w/AoIBAQDg7J1+zcO4 +rH5a4U9TYnLwQfzooXfokuTv09uxH3bE+Q0vdEyofxCXbh6qUK5upGmCda0dWKdw +OxGEk/TNOl7Qw3J1R1CLJVPPCU4b/d7qi3oPaF523cMdEpxS85KfA3LDOFMgqTZ/ +RyuIQbH3iS1w+gRsFYh8DDJdcSSu8RKjX8JVhkz2UQFPfA8YqRvLNUf1QSRmB53Y +zeNJ367SV4FzJym0VqTsiaVHQPpBXawltGNn0eqXNpv4TOLpQ3Q7Y1S3Y39prLJ1 +g5Ufr87kwh0BwS8dXDOgF43ATyHwwPCOo1ZjudVyqYvJVV+ITZJ1eZ3l/0U2nnsD +PYNcZKVhfKzdAoIBAENFB0srQWw6W4S4JHQ1oSpAdE0GDaLDRFfNXkj50YJi3AWY +cuH5faFAXvQ1sic9qCN73iBH+gz4Bsb7uckxU0DNEYlf3nYWw/CSR6PSsiaN6kKl +Gu+ySgUTLf0kf4nfP0JJ1UeL9tCyPA0KSiVCL3xXWKUFFCbpZQy7MmpFnvNzYApT +4R7ZMq/KZFcNRnQIYSN0y/khSMyCmplpIwO7Y+nRLvQhzPV6z3X4+eGrZnPzDv2V +Dij9+OaMZ8srPGKR8J66QMcYcscoetsmmh5bpAfLaQ4T1fzoLkN6QxStNgiNSTd9 +EhlDy87m/G0o/sn6rtI7R5/0Zsn9TKkVlJD+ls8CggEANPklQrcdcIIXpDnKX/4g +ydsQwI0+22S1TJKd/EJHy65IX7PJVinO84s4563m1yIbw2EJq460qKcQwiPClQ85 +Q3u0mlB4dL0O1wT/A3KwLJc64SQYk3A5QsCeVp8NGixKvBWo5llT/3f4lbe7PWxu +alxH7FjJ80VAG2fJVvZqCFZGQ7RErgJ4B4tVVt6FMD/VObrk4q7Ki0Q6Uqy+1MVN +NJy1osaBQ0BLz9NK3Vg9cgfhHZN/56sx4rHhA0Uiu9XyHtrtKCtHQIwD9BmI5bGd ++UrRWN3dPsgtV2yLttMKFN39O7GJxt6NkJZt0IFMjCRffsq3N1zt5d538Ku3k5U0 +dQKCAQBT5ZpGpuGeG2RI4lzF2iejApZ8Qa7YmTGem7M2T062wlhgyBNogKXxbrl/ +TyvpB5gXSkcCMmdD8727WJNUnnX7EWk+zzqBF1mn6KGoar23YDHLuMKxv6NEF8kI +D4l92SpMJNWkQaoOLKwNz8x8bJ8uYutLLJlDjnUpbdMbUgnw8Nkcflfr3nAKZd5e +BJ46tSNjMV9KyQd5b+pietirVyS3afJaPJNE6Uu8VIPbbxApAW3dfIQznIwgx62E +bWBtDNguJzLLv4zJ+XhcOEIdgAaNBUsT+owfF0ok6EEBzIl51pSo7w4Nh5PkMw4d +VfTYN1T7nfugAi8VqPcL/5ZKQzIz +-----END PRIVATE KEY----- diff --git a/tests/integration/keys/symmetric-key-256 b/tests/integration/keys/symmetric-key-256 new file mode 100644 index 000000000..e9a909954 --- /dev/null +++ b/tests/integration/keys/symmetric-key-256 @@ -0,0 +1 @@ +sΤίsŸΑΖΏkU>A@{0ΥϋZJύlΐ“CQυ \ No newline at end of file diff --git a/tests/integration/test_conversation.py b/tests/integration/test_conversation.py new file mode 100644 index 000000000..0a762fdad --- /dev/null +++ b/tests/integration/test_conversation.py @@ -0,0 +1,114 @@ +import pytest + +from dapr.clients.grpc.conversation import ( + ConversationInput, + ConversationInputAlpha2, + ConversationTools, + ConversationToolsFunction, + create_user_message, +) + +COMPONENT = 'echo' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-conversation') + + +def test_alpha1_single_input_is_echoed(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='hello world', role='user')], + ) + + assert len(response.outputs) == 1 + assert response.outputs[0].result == 'hello world' + + +def test_alpha1_multiple_inputs_are_joined_by_echo(client): + messages = ['first', 'second', 'third'] + inputs = [ConversationInput(content=text, role='user') for text in messages] + + response = client.converse_alpha1(name=COMPONENT, inputs=inputs) + + assert len(response.outputs) == 1 + assert response.outputs[0].result == '\n'.join(messages) + + +def test_alpha1_context_id_is_echoed(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='ping', role='user')], + context_id='chat-123', + ) + + assert response.context_id == 'chat-123' + + +def test_alpha1_temperature_and_scrub_pii_are_accepted(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='hi', role='user', scrub_pii=True)], + temperature=0.7, + scrub_pii=True, + ) + + assert response.outputs[0].result == 'hi' + + +def test_alpha2_user_message_is_echoed(client): + response = client.converse_alpha2( + name=COMPONENT, + inputs=[ConversationInputAlpha2(messages=[create_user_message("What's Dapr?")])], + ) + + assert len(response.outputs) == 1 + assert len(response.outputs[0].choices) == 1 + assert response.outputs[0].choices[0].message.content == "What's Dapr?" + + +def test_alpha2_multiple_inputs_are_joined_by_echo(client): + prompts = ['one', 'two'] + inputs = [ConversationInputAlpha2(messages=[create_user_message(text)]) for text in prompts] + + response = client.converse_alpha2(name=COMPONENT, inputs=inputs) + + assert len(response.outputs) == 1 + assert response.outputs[0].choices[0].message.content == '\n'.join(prompts) + + +def test_alpha2_tools_and_tool_choice_are_accepted(client): + weather_tool = ConversationTools( + function=ConversationToolsFunction( + name='get_weather', + description='Look up the weather for a city', + parameters={ + 'type': 'object', + 'properties': {'city': {'type': 'string'}}, + 'required': ['city'], + }, + ), + ) + + response = client.converse_alpha2( + name=COMPONENT, + inputs=[ConversationInputAlpha2(messages=[create_user_message('weather in Paris?')])], + tools=[weather_tool], + tool_choice='auto', + ) + + assert response.outputs[0].choices[0].message.content == 'weather in Paris?' + + +def test_alpha2_parameters_and_metadata_are_accepted(client): + response = client.converse_alpha2( + name=COMPONENT, + inputs=[ConversationInputAlpha2(messages=[create_user_message('hi')])], + parameters={'top_p': 0.9, 'max_tokens': 64}, + metadata={'model': 'echo', 'cacheTTL': '1m'}, + context_id='ctx-xyz', + ) + + assert response.context_id == 'ctx-xyz' + assert response.outputs[0].choices[0].message.content == 'hi' diff --git a/tests/integration/test_crypto.py b/tests/integration/test_crypto.py new file mode 100644 index 000000000..a87e30b8b --- /dev/null +++ b/tests/integration/test_crypto.py @@ -0,0 +1,107 @@ +import pytest + +from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions + +# The crypto API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + +CRYPTO_COMPONENT = 'cryptostore' +RSA_KEY = 'rsa-private-key.pem' +SYMMETRIC_KEY = 'symmetric-key-256' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-crypto') + + +def test_rsa_round_trip(client): + plaintext = b'The secret is "passw0rd"' + + encrypted = client.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ).read() + assert encrypted != plaintext + assert len(encrypted) > 0 + + decrypted = client.decrypt( + data=encrypted, + options=DecryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + ), + ).read() + + assert decrypted == plaintext + + +def test_aes_round_trip_on_large_payload(client): + plaintext = b'A' * (64 * 1024) + + encrypted = client.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=SYMMETRIC_KEY, + key_wrap_algorithm='AES', + ), + ).read() + assert encrypted != plaintext + + decrypted = client.decrypt( + data=encrypted, + options=DecryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=SYMMETRIC_KEY, + ), + ).read() + + assert decrypted == plaintext + + +def test_string_input_round_trip(client): + message = 'hello dapr' + + encrypted = client.encrypt( + data=message, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ).read() + + decrypted = client.decrypt( + data=encrypted, + options=DecryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + ), + ).read() + + assert decrypted.decode() == message + + +def test_encrypt_with_empty_component_raises(client): + with pytest.raises(ValueError): + client.encrypt( + data=b'x', + options=EncryptOptions( + component_name='', + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ) + + +def test_decrypt_with_empty_component_raises(client): + with pytest.raises(ValueError): + client.decrypt( + data=b'x', + options=DecryptOptions(component_name='', key_name=RSA_KEY), + ) diff --git a/tests/integration/test_invoke_binding.py b/tests/integration/test_invoke_binding.py new file mode 100644 index 000000000..04066670c --- /dev/null +++ b/tests/integration/test_invoke_binding.py @@ -0,0 +1,99 @@ +import json +import uuid +from pathlib import Path + +import grpc +import pytest + +BINDING = 'localbinding' +BINDING_ROOT = Path(__file__).resolve().parent / '.binding-data' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-invoke-binding') + + +def _unique_file() -> str: + return f'binding-{uuid.uuid4().hex[:8]}.txt' + + +def test_create_writes_file_to_disk(client): + file_name = _unique_file() + payload = b'hello from invoke_binding' + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + + assert (BINDING_ROOT / file_name).read_bytes() == payload + + +def test_get_reads_file_from_disk(client): + file_name = _unique_file() + payload = json.dumps({'id': 1, 'message': 'hello'}).encode() + (BINDING_ROOT / file_name).write_bytes(payload) + + response = client.invoke_binding( + binding_name=BINDING, + operation='get', + binding_metadata={'fileName': file_name}, + ) + + assert response.data == payload + + +def test_create_then_get_round_trip(client): + file_name = _unique_file() + payload = b'round-trip payload' + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + response = client.invoke_binding( + binding_name=BINDING, + operation='get', + binding_metadata={'fileName': file_name}, + ) + + assert response.data == payload + + +def test_delete_removes_file(client): + file_name = _unique_file() + target_file = BINDING_ROOT / file_name + target_file.write_bytes(b'to be deleted') + + client.invoke_binding( + binding_name=BINDING, + operation='delete', + binding_metadata={'fileName': file_name}, + ) + + assert not target_file.exists() + + +def test_unknown_binding_raises(client): + with pytest.raises(grpc.RpcError): + client.invoke_binding( + binding_name='does-not-exist', + operation='create', + data=b'x', + binding_metadata={'fileName': _unique_file()}, + ) + + +def test_unknown_operation_raises(client): + with pytest.raises(grpc.RpcError): + client.invoke_binding( + binding_name=BINDING, + operation='bogus-op', + data=b'x', + binding_metadata={'fileName': _unique_file()}, + ) diff --git a/tests/integration/test_jobs.py b/tests/integration/test_jobs.py new file mode 100644 index 000000000..d9c83f00d --- /dev/null +++ b/tests/integration/test_jobs.py @@ -0,0 +1,101 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from dapr.clients import Job +from dapr.clients.exceptions import DaprGrpcError + +# The jobs API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +def _future(days: int) -> str: + """Return an RFC3339 timestamp ``days`` days from now in UTC.""" + return (datetime.now(timezone.utc) + timedelta(days=days)).strftime('%Y-%m-%dT%H:%M:%SZ') + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-jobs') + + +def _unique_name(prefix: str) -> str: + return f'{prefix}-{uuid.uuid4().hex[:8]}' + + +def test_schedule_then_get_returns_job(client): + name = _unique_name('job-get') + due = _future(days=365) + + client.schedule_job_alpha1(Job(name=name, due_time=due)) + try: + retrieved = client.get_job_alpha1(name=name) + assert retrieved.name == name + assert retrieved.due_time == due + finally: + client.delete_job_alpha1(name=name) + + +def test_schedule_with_schedule_expression(client): + name = _unique_name('job-sched') + job_recurring = Job(name=name, schedule='@every 1h', due_time=_future(days=365), repeats=1) + + client.schedule_job_alpha1(job_recurring) + try: + retrieved = client.get_job_alpha1(name=name) + assert retrieved.schedule == '@every 1h' + assert retrieved.repeats == 1 + finally: + client.delete_job_alpha1(name=name) + + +def test_schedule_without_overwrite_rejects_duplicate(client): + name = _unique_name('job-dup') + due = _future(days=365) + + client.schedule_job_alpha1(Job(name=name, due_time=due)) + try: + with pytest.raises(DaprGrpcError): + client.schedule_job_alpha1(Job(name=name, due_time=due)) + finally: + client.delete_job_alpha1(name=name) + + +def test_schedule_with_overwrite_replaces_existing(client): + name = _unique_name('job-overwrite') + updated_due = _future(days=730) + job_original = Job(name=name, due_time=_future(days=365)) + job_replacement = Job(name=name, due_time=updated_due) + + client.schedule_job_alpha1(job_original) + try: + client.schedule_job_alpha1(job_replacement, overwrite=True) + retrieved = client.get_job_alpha1(name=name) + assert retrieved.due_time == updated_due + finally: + client.delete_job_alpha1(name=name) + + +def test_delete_then_get_raises_not_found(client): + name = _unique_name('job-del') + job = Job(name=name, due_time=_future(days=365)) + + client.schedule_job_alpha1(job) + client.delete_job_alpha1(name=name) + with pytest.raises(DaprGrpcError): + client.get_job_alpha1(name=name) + + +def test_schedule_with_empty_name_raises(client): + job_unnamed = Job(name='', due_time=_future(days=365)) + + with pytest.raises(ValueError): + client.schedule_job_alpha1(job_unnamed) + + +def test_schedule_without_schedule_or_due_time_raises(client): + job_timeless = Job(name=_unique_name('job-nosched')) + + with pytest.raises(ValueError): + client.schedule_job_alpha1(job_timeless) diff --git a/tests/integration/test_workflow.py b/tests/integration/test_workflow.py new file mode 100644 index 000000000..d1b94516b --- /dev/null +++ b/tests/integration/test_workflow.py @@ -0,0 +1,152 @@ +import uuid +from typing import Generator + +import pytest +from dapr.ext.workflow import ( + DaprWorkflowClient, + DaprWorkflowContext, + WorkflowActivityContext, + WorkflowRuntime, +) + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + return dapr_env.start_sidecar(app_id='test-workflow') + + +@pytest.fixture(scope='module') +def runtime(sidecar) -> Generator[WorkflowRuntime, None, None]: + wfr = WorkflowRuntime() + + @wfr.activity(name='double_it') + def double_it(ctx: WorkflowActivityContext, value: int) -> int: + return value * 2 + + @wfr.activity(name='add_one') + def add_one(ctx: WorkflowActivityContext, value: int) -> int: + return value + 1 + + @wfr.workflow(name='chain_math_wf') + def chain_math_wf(ctx: DaprWorkflowContext, wf_input: int): + doubled = yield ctx.call_activity(double_it, input=wf_input) + final = yield ctx.call_activity(add_one, input=doubled) + return final + + @wfr.workflow(name='echo_input_wf') + def echo_input_wf(ctx: DaprWorkflowContext, wf_input: str): + return wf_input + + @wfr.workflow(name='external_event_wf') + def external_event_wf(ctx: DaprWorkflowContext, _): + event_payload = yield ctx.wait_for_external_event('go') + return event_payload + + wfr.start() + try: + yield wfr + finally: + wfr.shutdown() + + +@pytest.fixture(scope='module') +def wf_client(runtime) -> Generator[DaprWorkflowClient, None, None]: + wfc = DaprWorkflowClient() + try: + yield wfc + finally: + wfc.close() + + +def _instance_id(prefix: str) -> str: + return f'{prefix}-{uuid.uuid4().hex[:8]}' + + +def test_activity_chain_returns_final_output(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='chain_math_wf', input=5, instance_id=_instance_id('chain') + ) + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + wf_client.purge_workflow(instance_id) + + assert state is not None + assert state.runtime_status.name == 'COMPLETED' + assert state.serialized_output == '11' + + +def test_workflow_echoes_input(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='echo_input_wf', input='ping', instance_id=_instance_id('echo') + ) + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + wf_client.purge_workflow(instance_id) + + assert state.runtime_status.name == 'COMPLETED' + assert state.serialized_output == '"ping"' + + +def test_external_event_unblocks_workflow(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='external_event_wf', instance_id=_instance_id('event') + ) + wf_client.wait_for_workflow_start(instance_id, timeout_in_seconds=30) + + wf_client.raise_workflow_event(instance_id, event_name='go', data='unlocked') + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + wf_client.purge_workflow(instance_id) + + assert state.runtime_status.name == 'COMPLETED' + assert state.serialized_output == '"unlocked"' + + +def test_pause_and_resume_transitions_status(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='external_event_wf', instance_id=_instance_id('pause') + ) + wf_client.wait_for_workflow_start(instance_id, timeout_in_seconds=30) + + wf_client.pause_workflow(instance_id) + paused_state = wf_client.get_workflow_state(instance_id) + assert paused_state is not None + assert paused_state.runtime_status.name == 'SUSPENDED' + + wf_client.resume_workflow(instance_id) + wf_client.raise_workflow_event(instance_id, event_name='go', data='resumed') + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + wf_client.purge_workflow(instance_id) + + assert state.runtime_status.name == 'COMPLETED' + assert state.serialized_output == '"resumed"' + + +def test_terminate_sets_terminated_status(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='external_event_wf', instance_id=_instance_id('terminate') + ) + wf_client.wait_for_workflow_start(instance_id, timeout_in_seconds=30) + + wf_client.terminate_workflow(instance_id) + + state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + wf_client.purge_workflow(instance_id) + + assert state.runtime_status.name == 'TERMINATED' + + +def test_purge_removes_completed_instance(wf_client): + instance_id = wf_client.schedule_new_workflow( + workflow='echo_input_wf', input='bye', instance_id=_instance_id('purge') + ) + wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=30) + + wf_client.purge_workflow(instance_id) + + assert wf_client.get_workflow_state(instance_id) is None + + +def test_get_state_returns_none_for_unknown_instance(wf_client): + assert wf_client.get_workflow_state(_instance_id('missing')) is None From a5d6e4c2a6ed40962de2ffe545d29c21e207198a Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:56:55 +0200 Subject: [PATCH 20/28] Add async tests Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .gitignore | 3 + pyproject.toml | 1 + tests/integration/test_configuration_async.py | 27 ++++++++ tests/integration/test_conversation_async.py | 36 ++++++++++ tests/integration/test_crypto_async.py | 63 +++++++++++++++++ .../test_distributed_lock_async.py | 40 +++++++++++ tests/integration/test_invoke_async.py | 39 +++++++++++ .../integration/test_invoke_binding_async.py | 50 ++++++++++++++ tests/integration/test_jobs_async.py | 46 +++++++++++++ tests/integration/test_metadata_async.py | 25 +++++++ tests/integration/test_pubsub_async.py | 69 +++++++++++++++++++ tests/integration/test_secret_store_async.py | 26 +++++++ tests/integration/test_state_store_async.py | 49 +++++++++++++ 13 files changed, 474 insertions(+) create mode 100644 tests/integration/test_configuration_async.py create mode 100644 tests/integration/test_conversation_async.py create mode 100644 tests/integration/test_crypto_async.py create mode 100644 tests/integration/test_distributed_lock_async.py create mode 100644 tests/integration/test_invoke_async.py create mode 100644 tests/integration/test_invoke_binding_async.py create mode 100644 tests/integration/test_jobs_async.py create mode 100644 tests/integration/test_metadata_async.py create mode 100644 tests/integration/test_pubsub_async.py create mode 100644 tests/integration/test_secret_store_async.py create mode 100644 tests/integration/test_state_store_async.py diff --git a/.gitignore b/.gitignore index ef2cd267b..c16f3a22e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,6 @@ coverage.lcov # macOS specific files .DS_Store + +# Integration test scratch dirs +tests/integration/.binding-data/ diff --git a/pyproject.toml b/pyproject.toml index 7f0d3cbb1..8870d6a5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,4 @@ markers = [ 'example_dir(name): set the example directory for the dapr fixture', ] pythonpath = ["."] +asyncio_mode = "auto" diff --git a/tests/integration/test_configuration_async.py b/tests/integration/test_configuration_async.py new file mode 100644 index 000000000..1d0fbc7f4 --- /dev/null +++ b/tests/integration/test_configuration_async.py @@ -0,0 +1,27 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient + +STORE = 'configurationstore' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env, redis_set_config): + redis_set_config('async-cfg-key-1', 'async-val-1') + dapr_env.start_sidecar(app_id='test-config-async') + + +async def test_get_configuration_single_key(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.get_configuration(store_name=STORE, keys=['async-cfg-key-1']) + + assert 'async-cfg-key-1' in resp.items + assert resp.items['async-cfg-key-1'].value == 'async-val-1' + + +async def test_get_configuration_missing_key_returns_empty_items(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.get_configuration(store_name=STORE, keys=['nonexistent-async-cfg-key']) + + assert 'nonexistent-async-cfg-key' not in resp.items diff --git a/tests/integration/test_conversation_async.py b/tests/integration/test_conversation_async.py new file mode 100644 index 000000000..993683c4f --- /dev/null +++ b/tests/integration/test_conversation_async.py @@ -0,0 +1,36 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc.conversation import ( + ConversationInput, + ConversationInputAlpha2, + create_user_message, +) + +COMPONENT = 'echo' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-conversation-async') + + +async def test_converse_alpha1_echoes_input(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + response = await d.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='async hello', role='user')], + ) + + assert response.outputs[0].result == 'async hello' + + +async def test_converse_alpha2_echoes_user_message(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + response = await d.converse_alpha2( + name=COMPONENT, + inputs=[ConversationInputAlpha2(messages=[create_user_message('async world')])], + ) + + assert response.outputs[0].choices[0].message.content == 'async world' diff --git a/tests/integration/test_crypto_async.py b/tests/integration/test_crypto_async.py new file mode 100644 index 000000000..f531eac46 --- /dev/null +++ b/tests/integration/test_crypto_async.py @@ -0,0 +1,63 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions + +GRPC_ADDRESS = '127.0.0.1:50001' +CRYPTO_COMPONENT = 'cryptostore' +RSA_KEY = 'rsa-private-key.pem' +SYMMETRIC_KEY = 'symmetric-key-256' + +# The crypto API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-crypto-async') + + +async def test_rsa_round_trip(sidecar): + plaintext = b'async crypto secret' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + encrypted_stream = await d.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ) + encrypted = await encrypted_stream.read() + + decrypted_stream = await d.decrypt( + data=encrypted, + options=DecryptOptions(component_name=CRYPTO_COMPONENT, key_name=RSA_KEY), + ) + decrypted = await decrypted_stream.read() + + assert decrypted == plaintext + + +async def test_aes_round_trip(sidecar): + plaintext = b'A' * (32 * 1024) + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + encrypted_stream = await d.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=SYMMETRIC_KEY, + key_wrap_algorithm='AES', + ), + ) + encrypted = await encrypted_stream.read() + + decrypted_stream = await d.decrypt( + data=encrypted, + options=DecryptOptions(component_name=CRYPTO_COMPONENT, key_name=SYMMETRIC_KEY), + ) + decrypted = await decrypted_stream.read() + + assert decrypted == plaintext diff --git a/tests/integration/test_distributed_lock_async.py b/tests/integration/test_distributed_lock_async.py new file mode 100644 index 000000000..ed4cf1370 --- /dev/null +++ b/tests/integration/test_distributed_lock_async.py @@ -0,0 +1,40 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc._response import UnlockResponseStatus + +STORE = 'lockstore' +GRPC_ADDRESS = '127.0.0.1:50001' + +# The distributed lock API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-lock-async') + + +async def test_acquire_and_release_lock(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + lock = await d.try_lock(STORE, 'res-async-acquire', 'owner-a', expiry_in_seconds=10) + assert lock.success + + resp = await d.unlock(STORE, 'res-async-acquire', 'owner-a') + assert resp.status == UnlockResponseStatus.success + + +async def test_second_owner_is_rejected(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + first = await d.try_lock(STORE, 'res-async-contention', 'owner-a', expiry_in_seconds=10) + second = await d.try_lock(STORE, 'res-async-contention', 'owner-b', expiry_in_seconds=10) + + assert first.success + assert not second.success + + +async def test_unlock_nonexistent_returns_not_found(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.unlock(STORE, 'res-async-missing', 'owner-a') + + assert resp.status == UnlockResponseStatus.lock_does_not_exist diff --git a/tests/integration/test_invoke_async.py b/tests/integration/test_invoke_async.py new file mode 100644 index 000000000..ca53662f5 --- /dev/null +++ b/tests/integration/test_invoke_async.py @@ -0,0 +1,39 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient + +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env, apps_dir): + dapr_env.start_sidecar( + app_id='invoke-receiver-async', + app_port=50051, + app_cmd=f'python3 {apps_dir / "invoke_receiver.py"}', + ) + + +async def test_invoke_method_returns_expected_response(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.invoke_method( + app_id='invoke-receiver-async', + method_name='my-method', + data=b'{"id": 1, "message": "async hello"}', + content_type='application/json', + ) + + assert resp.content_type.startswith('text/plain') + assert resp.data == b'INVOKE_RECEIVED' + + +async def test_invoke_method_with_text_data(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.invoke_method( + app_id='invoke-receiver-async', + method_name='my-method', + data=b'plain text', + content_type='text/plain', + ) + + assert resp.data == b'INVOKE_RECEIVED' diff --git a/tests/integration/test_invoke_binding_async.py b/tests/integration/test_invoke_binding_async.py new file mode 100644 index 000000000..9dd001132 --- /dev/null +++ b/tests/integration/test_invoke_binding_async.py @@ -0,0 +1,50 @@ +import uuid +from pathlib import Path + +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient + +BINDING = 'localbinding' +BINDING_ROOT = Path(__file__).resolve().parent / '.binding-data' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-invoke-binding-async') + + +async def test_create_writes_file_to_disk(sidecar): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + payload = b'hello from async invoke_binding' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + + assert (BINDING_ROOT / file_name).read_bytes() == payload + + +async def test_create_then_get_round_trip(sidecar): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + payload = b'async round-trip payload' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + response = await d.invoke_binding( + binding_name=BINDING, + operation='get', + binding_metadata={'fileName': file_name}, + ) + + assert response.data == payload diff --git a/tests/integration/test_jobs_async.py b/tests/integration/test_jobs_async.py new file mode 100644 index 000000000..7c8ec1ad0 --- /dev/null +++ b/tests/integration/test_jobs_async.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients import Job + +GRPC_ADDRESS = '127.0.0.1:50001' + +# The jobs API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +def _future(days: int) -> str: + return (datetime.now(timezone.utc) + timedelta(days=days)).strftime('%Y-%m-%dT%H:%M:%SZ') + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-jobs-async') + + +async def test_schedule_then_get_returns_job(sidecar): + name = f'async-job-{uuid.uuid4().hex[:8]}' + due = _future(days=365) + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.schedule_job_alpha1(Job(name=name, due_time=due)) + try: + retrieved = await d.get_job_alpha1(name=name) + assert retrieved.name == name + assert retrieved.due_time == due + finally: + await d.delete_job_alpha1(name=name) + + +async def test_delete_removes_job(sidecar): + name = f'async-job-del-{uuid.uuid4().hex[:8]}' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.schedule_job_alpha1(Job(name=name, due_time=_future(days=365))) + await d.delete_job_alpha1(name=name) + + with pytest.raises(Exception): + await d.get_job_alpha1(name=name) diff --git a/tests/integration/test_metadata_async.py b/tests/integration/test_metadata_async.py new file mode 100644 index 000000000..739496c9b --- /dev/null +++ b/tests/integration/test_metadata_async.py @@ -0,0 +1,25 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient + +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-metadata-async') + + +async def test_get_metadata_application_id_matches(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + meta = await d.get_metadata() + + assert meta.application_id == 'test-metadata-async' + + +async def test_set_and_get_metadata_round_trip(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.set_metadata('async-key', 'async-value') + meta = await d.get_metadata() + + assert meta.extended_metadata.get('async-key') == 'async-value' diff --git a/tests/integration/test_pubsub_async.py b/tests/integration/test_pubsub_async.py new file mode 100644 index 000000000..87e852a6d --- /dev/null +++ b/tests/integration/test_pubsub_async.py @@ -0,0 +1,69 @@ +import json +import uuid + +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from tests.wait_utils import wait_until_async + +STORE = 'statestore' +PUBSUB = 'pubsub' +TOPIC = 'TOPIC_A' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env, apps_dir, flush_redis): + dapr_env.start_sidecar( + app_id='test-subscriber-async', + app_port=50051, + app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}', + ) + + +async def test_publish_event_delivers_to_subscriber(sidecar): + run_id = uuid.uuid4().hex + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.publish_event( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=json.dumps({'run_id': run_id, 'id': 1, 'message': 'async hello'}), + data_content_type='application/json', + ) + + async def _fetch() -> bytes | None: + resp = await d.get_state(store_name=STORE, key=f'received-{run_id}-1') + return resp.data or None + + data = await wait_until_async(_fetch, timeout=10) + + msg = json.loads(data) + assert msg['message'] == 'async hello' + + +async def test_publish_events_bulk_delivery(sidecar): + run_id = uuid.uuid4().hex + payloads = [ + json.dumps({'run_id': run_id, 'id': n, 'message': f'bulk-async-{n}'}) for n in range(1, 3) + ] + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + response = await d.publish_events( + pubsub_name=PUBSUB, + topic_name=TOPIC, + data=payloads, + data_content_type='application/json', + ) + assert response.failed_entries == [] + + for n in range(1, 3): + key = f'received-{run_id}-{n}' + + async def _fetch(k: str = key) -> bytes | None: + resp = await d.get_state(store_name=STORE, key=k) + return resp.data or None + + data = await wait_until_async(_fetch, timeout=10) + msg = json.loads(data) + assert msg['message'] == f'bulk-async-{n}' diff --git a/tests/integration/test_secret_store_async.py b/tests/integration/test_secret_store_async.py new file mode 100644 index 000000000..07959c8d3 --- /dev/null +++ b/tests/integration/test_secret_store_async.py @@ -0,0 +1,26 @@ +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient + +STORE = 'localsecretstore' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-secrets-async') + + +async def test_get_secret_returns_expected_value(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.get_secret(store_name=STORE, key='secretKey') + + assert resp.secret == {'secretKey': 'secretValue'} + + +async def test_get_bulk_secret_returns_all(sidecar): + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + resp = await d.get_bulk_secret(store_name=STORE) + + assert 'secretKey' in resp.secrets + assert resp.secrets['secretKey'] == {'secretKey': 'secretValue'} diff --git a/tests/integration/test_state_store_async.py b/tests/integration/test_state_store_async.py new file mode 100644 index 000000000..0795ced1f --- /dev/null +++ b/tests/integration/test_state_store_async.py @@ -0,0 +1,49 @@ +import uuid + +import pytest + +from dapr.aio.clients import DaprClient as AsyncDaprClient +from dapr.clients.grpc._request import TransactionalStateOperation + +STORE = 'statestore' +GRPC_ADDRESS = '127.0.0.1:50001' + + +@pytest.fixture(scope='module') +def sidecar(dapr_env): + dapr_env.start_sidecar(app_id='test-state-async') + + +async def test_save_and_get_round_trip(sidecar): + key = f'async-key-{uuid.uuid4().hex[:8]}' + value = b'async-value' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.save_state(store_name=STORE, key=key, value=value) + resp = await d.get_state(store_name=STORE, key=key) + + assert resp.data == value + + +async def test_delete_state_removes_key(sidecar): + key = f'async-del-{uuid.uuid4().hex[:8]}' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.save_state(store_name=STORE, key=key, value=b'bye') + await d.delete_state(store_name=STORE, key=key) + resp = await d.get_state(store_name=STORE, key=key) + + assert resp.data == b'' + + +async def test_transaction_upsert_then_get(sidecar): + key = f'async-txn-{uuid.uuid4().hex[:8]}' + + async with AsyncDaprClient(address=GRPC_ADDRESS) as d: + await d.execute_state_transaction( + store_name=STORE, + operations=[TransactionalStateOperation(key=key, data=b'txn-value')], + ) + resp = await d.get_state(store_name=STORE, key=key) + + assert resp.data == b'txn-value' From 834ae27672520ef2357b935e2219ae8c61834c58 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:57:04 +0200 Subject: [PATCH 21/28] Update docs Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- CLAUDE.md | 22 +++++++++------ tests/integration/AGENTS.md | 54 ++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 490328fe9..b57ac6daf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,20 @@ @AGENTS.md -Use pathlib instead of os.path. -Use httpx instead of urllib. -subprocess(`shell=True`) is used only when it makes the code more readable. Use either shlex or args lists. -subprocess calls should have a reasonable timeout. -Use modern Python (3.10+) features. Make all code strongly typed. Keep conditional nesting to a minimum, and use guard clauses when possible. -Aim for medium "visual complexity": use intermediate variables to store results of nested/complex function calls, but don't create a new variable for everything. -Avoid comments unless there is a gotcha, a complex algorithm or anything an experienced code reviewer needs to be aware of. Focus on making better Google-style docstrings instead. +Aim for medium visual complexity: use intermediate variables to store results of nested/complex function calls. A complex function call could be: +- `f(Object(a=1, b=2, c=3))`, the inner object has more than 2 meaningful args +- `f(Object((a, b)))`, 2 levels of nesting or anything with a long chain of closing parens +- `small_transformation(ImportantObject())`, the object itself is the main subject of the function but the transformation steals the focus +Use descriptive, self-documenting names for these intermediate variables. +Closely related variable names should share a root and use different suffixes. For example, `request_original` and `request_clean`, but not `clean_request`. +Avoid comments unless there is a gotcha, a complex algorithm or anything an experienced code reviewer needs to be aware of. Focus on making short but descriptive Google-style docstrings instead. -The user is not always right. Be skeptical and do not blindly comply if something doesn't make sense. +Use modern Python (3.10+) features. +Use pathlib instead of os.path. +Use httpx instead of urllib. +`subprocess(shell=True)` is used only when it makes the code more readable. Use either shlex or args lists. +Anything that can have an explicit timeout should have one. Code should be cross-platform and production ready. + +The user is not always right. Be skeptical and do not blindly comply if something doesn't make sense. diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index 2f40750f8..5a7f0695d 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -33,12 +33,17 @@ tests/integration/ β”‚ β”œβ”€β”€ invoke_receiver.py # gRPC method handler for invoke tests β”‚ └── pubsub_subscriber.py # Subscriber that persists messages to state store β”œβ”€β”€ components/ # Dapr component YAMLs loaded by all sidecars -β”‚ β”œβ”€β”€ statestore.yaml # state.redis +β”‚ β”œβ”€β”€ statestore.yaml # state.redis (also configured as actor state store) β”‚ β”œβ”€β”€ pubsub.yaml # pubsub.redis β”‚ β”œβ”€β”€ lockstore.yaml # lock.redis β”‚ β”œβ”€β”€ configurationstore.yaml # configuration.redis -β”‚ └── localsecretstore.yaml # secretstores.local.file -└── secrets.json # Secrets file for localsecretstore component +β”‚ β”œβ”€β”€ localsecretstore.yaml # secretstores.local.file +β”‚ β”œβ”€β”€ localbinding.yaml # bindings.localstorage (rootPath=./.binding-data) +β”‚ β”œβ”€β”€ cryptostore.yaml # crypto.dapr.localstorage (path=./keys) +β”‚ └── conversation.yaml # conversation.echo +β”œβ”€β”€ keys/ # RSA + symmetric keys for cryptostore +β”œβ”€β”€ secrets.json # Secrets file for localsecretstore component +└── .binding-data/ # Created on demand for localbinding rootPath (gitignored) ``` ## Fixtures @@ -50,7 +55,12 @@ Sidecar and client fixtures are **module-scoped** β€” one sidecar per test file. | `dapr_env` | module | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | | `apps_dir` | module | `Path` | Path to `tests/integration/apps/` | | `components_dir` | module | `Path` | Path to `tests/integration/components/` | -| `wait_until` | function | `Callable` | Polling helper `(predicate, timeout=10, interval=0.1)` for eventual-consistency assertions | +| `wait_until` | session | `Callable` | Polling helper `(predicate, timeout=10, interval=0.1)` for eventual-consistency assertions | +| `wait_until_async` | session | `Callable` | Async counterpart of `wait_until` for awaitable predicates | +| `flush_redis` | session | `None` | Side-effect fixture that clears the `dapr_redis` container once per session | +| `redis_set` | session | `Callable` | Returns `set(key, value, version=1)` that seeds a Dapr configuration value into Redis (`value||version`) | + +All four are session-scoped (defined in `tests/conftest.py`) so that module-scoped fixtures can depend on them. Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. @@ -60,11 +70,36 @@ Each test file defines its own module-scoped `client` fixture that calls `dapr_e |-----------|---------------|-------------------| | `test_state_store.py` | State management | `save_state`, `get_state`, `save_bulk_state`, `get_bulk_state`, `execute_state_transaction`, `delete_state` | | `test_invoke.py` | Service invocation | `invoke_method` | -| `test_pubsub.py` | Pub/sub | `publish_event`, `get_state` (to verify delivery) | +| `test_pubsub.py` | Pub/sub | `publish_event`, `publish_events`, `get_state` (to verify delivery) | | `test_secret_store.py` | Secrets | `get_secret`, `get_bulk_secret` | | `test_metadata.py` | Metadata | `get_metadata`, `set_metadata` | | `test_distributed_lock.py` | Distributed lock | `try_lock`, `unlock`, context manager | | `test_configuration.py` | Configuration | `get_configuration`, `subscribe_configuration`, `unsubscribe_configuration` | +| `test_jobs.py` | Jobs scheduler | `schedule_job_alpha1`, `get_job_alpha1`, `delete_job_alpha1` | +| `test_invoke_binding.py` | Output bindings | `invoke_binding` (create/get/delete against `bindings.localstorage`) | +| `test_crypto.py` | Cryptography | `encrypt`, `decrypt` (RSA + AES round-trips against `crypto.dapr.localstorage`) | +| `test_conversation.py` | Conversation | `converse_alpha1`, `converse_alpha2` against `conversation.echo` | +| `test_workflow.py` | Workflow (`dapr-ext-workflow`) | `WorkflowRuntime`, `DaprWorkflowClient.schedule_new_workflow`, `wait_for_workflow_start`, `wait_for_workflow_completion`, `raise_workflow_event`, `pause_workflow`, `resume_workflow`, `terminate_workflow`, `purge_workflow`, `get_workflow_state` | + +### Async client coverage + +Async counterparts exercise `dapr.aio.clients.DaprClient` (the gRPC async client). Each file mirrors its sync sibling with smoke tests β€” the sync suite validates SDK logic end-to-end, the async suite verifies the `aio` transport. + +| File | Covers | +|------|--------| +| `test_state_store_async.py` | `save_state`, `get_state`, `delete_state`, `execute_state_transaction` | +| `test_invoke_async.py` | `invoke_method` | +| `test_invoke_binding_async.py` | `invoke_binding` (create/get) | +| `test_pubsub_async.py` | `publish_event`, `publish_events` | +| `test_secret_store_async.py` | `get_secret`, `get_bulk_secret` | +| `test_configuration_async.py` | `get_configuration` | +| `test_distributed_lock_async.py` | `try_lock`, `unlock` | +| `test_metadata_async.py` | `get_metadata`, `set_metadata` | +| `test_jobs_async.py` | `schedule_job_alpha1`, `get_job_alpha1`, `delete_job_alpha1` | +| `test_crypto_async.py` | `encrypt`, `decrypt` | +| `test_conversation_async.py` | `converse_alpha1`, `converse_alpha2` | + +Async tests use `pytest-asyncio` in auto mode (configured in `pyproject.toml`). Any `async def test_*` is run as a coroutine β€” no decorator required. The sidecar fixture stays sync (it just starts `dapr run`); each test creates a short-lived `async with AsyncDaprClient(address='127.0.0.1:50001') as d:` block. ## Port allocation @@ -90,6 +125,11 @@ Some building blocks (invoke, pubsub) require an app process running alongside t - **Requires `dapr init`** β€” the tests assume a local Dapr runtime with Redis (`dapr_redis` container on `localhost:6379`), which `dapr init` sets up automatically. - **Configuration tests seed Redis directly** via `docker exec dapr_redis redis-cli`. -- **Lock and configuration APIs are alpha** and emit `UserWarning` on every call. Tests suppress these with `pytestmark = pytest.mark.filterwarnings('ignore::UserWarning')`. -- **`localsecretstore.yaml` uses a relative path** (`secrets.json`) resolved against `cwd=INTEGRATION_DIR`. +- **Alpha-API tests suppress `UserWarning`** via +- `pytestmark = pytest.mark.filterwarnings('ignore::UserWarning')`. The SDK's alpha APIs (lock, crypto, jobs) emit a `UserWarning` per call. In production this is dedup'd to one emission per call site by Python's default warning filter (`__warningregistry__`), but pytest resets that registry between tests via its per-test `catch_warnings` context, so the warning re-fires in every test. The suppression is a pytest workaround, not a sign of a bug in the SDK. +- **`localsecretstore.yaml` uses a relative path** (`secrets.json`) resolved against `cwd=INTEGRATION_DIR`. Same pattern applies to `localbinding.yaml` (`./.binding-data`) and `cryptostore.yaml` (`./keys`). +- **`bindings.localstorage` refuses to initialize if `rootPath` does not exist** β€” `conftest.py` creates `.binding-data/` at import time so every sidecar can load the component. +- **`statestore.yaml` has `actorStateStore: "true"`** because workflow uses the actor runtime. The flag is additive β€” regular state tests are unaffected. +- **Workflow tests run the `WorkflowRuntime` in-process** and connect to the sidecar's gRPC port (default 50001). No external app is needed. - **Dapr may normalize response fields** β€” e.g., `content_type` may lose charset parameters when proxied through gRPC. Assert on the media type prefix, not the full string. +- **Error shapes vary** β€” `invoke_binding` surfaces sidecar errors as raw `grpc.RpcError`, while other APIs (jobs, state) wrap them in `DaprGrpcError`. Match what the method actually raises. From 392b4c0e7b8be8779b6ec5c97e1f6cbfa1586d2f Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:47:50 +0200 Subject: [PATCH 22/28] Merge resources/ change from main Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .../components/configurationstore.yaml | 11 ----------- .../components/localsecretstore.yaml | 13 ------------- tests/integration/components/lockstore.yaml | 11 ----------- tests/integration/components/pubsub.yaml | 12 ------------ tests/integration/components/statestore.yaml | 14 -------------- tests/integration/conftest.py | 18 +++++++++--------- .../conversation.yaml | 0 .../{components => resources}/cryptostore.yaml | 2 +- .../localbinding.yaml | 0 tests/integration/resources/statestore.yaml | 2 ++ tests/integration/test_secret_store.py | 4 ++-- 11 files changed, 14 insertions(+), 73 deletions(-) delete mode 100644 tests/integration/components/configurationstore.yaml delete mode 100644 tests/integration/components/localsecretstore.yaml delete mode 100644 tests/integration/components/lockstore.yaml delete mode 100644 tests/integration/components/pubsub.yaml delete mode 100644 tests/integration/components/statestore.yaml rename tests/integration/{components => resources}/conversation.yaml (100%) rename tests/integration/{components => resources}/cryptostore.yaml (88%) rename tests/integration/{components => resources}/localbinding.yaml (100%) diff --git a/tests/integration/components/configurationstore.yaml b/tests/integration/components/configurationstore.yaml deleted file mode 100644 index fcf6569d0..000000000 --- a/tests/integration/components/configurationstore.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: configurationstore -spec: - type: configuration.redis - metadata: - - name: redisHost - value: localhost:6379 - - name: redisPassword - value: "" diff --git a/tests/integration/components/localsecretstore.yaml b/tests/integration/components/localsecretstore.yaml deleted file mode 100644 index fd574a077..000000000 --- a/tests/integration/components/localsecretstore.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: localsecretstore -spec: - type: secretstores.local.file - metadata: - - name: secretsFile - # Relative to the Dapr process CWD (tests/integration/), set by - # DaprTestEnvironment via cwd=INTEGRATION_DIR. - value: secrets.json - - name: nestedSeparator - value: ":" diff --git a/tests/integration/components/lockstore.yaml b/tests/integration/components/lockstore.yaml deleted file mode 100644 index 424caceeb..000000000 --- a/tests/integration/components/lockstore.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: lockstore -spec: - type: lock.redis - metadata: - - name: redisHost - value: localhost:6379 - - name: redisPassword - value: "" diff --git a/tests/integration/components/pubsub.yaml b/tests/integration/components/pubsub.yaml deleted file mode 100644 index 18764d8ce..000000000 --- a/tests/integration/components/pubsub.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: pubsub -spec: - type: pubsub.redis - version: v1 - metadata: - - name: redisHost - value: localhost:6379 - - name: redisPassword - value: "" diff --git a/tests/integration/components/statestore.yaml b/tests/integration/components/statestore.yaml deleted file mode 100644 index 2f676bff8..000000000 --- a/tests/integration/components/statestore.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: statestore -spec: - type: state.redis - version: v1 - metadata: - - name: redisHost - value: localhost:6379 - - name: redisPassword - value: "" - - name: actorStateStore - value: "true" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e83720b13..3a037c6fd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,7 +15,7 @@ from tests.wait_utils import wait_until INTEGRATION_DIR = Path(__file__).resolve().parent -COMPONENTS_DIR = INTEGRATION_DIR / 'components' +RESOURCES_DIR = INTEGRATION_DIR / 'resources' APPS_DIR = INTEGRATION_DIR / 'apps' BINDING_DATA_DIR = INTEGRATION_DIR / '.binding-data' @@ -28,8 +28,8 @@ class DaprTestEnvironment: class returns real DaprClient instances so tests can make assertions against SDK return values. """ - def __init__(self, default_components: Path = COMPONENTS_DIR) -> None: - self._default_components = default_components + def __init__(self, default_resources: Path = RESOURCES_DIR) -> None: + self._default_resources = default_resources self._processes: list[subprocess.Popen[str]] = [] self._log_files: list[IO[str]] = [] self._clients: list[DaprClient] = [] @@ -42,7 +42,7 @@ def start_sidecar( http_port: int = 3500, app_port: int | None = None, app_cmd: str | None = None, - components: Path | None = None, + resources: Path | None = None, ) -> DaprClient: """Start a Dapr sidecar and return a connected DaprClient. @@ -52,10 +52,10 @@ def start_sidecar( http_port: Sidecar HTTP port (also used for the SDK health check). app_port: Port the app listens on (implies ``--app-protocol grpc``). app_cmd: Shell command to start alongside the sidecar. - components: Path to component YAML directory. Defaults to - ``tests/integration/components/``. + resources: Path to resources YAML directory. Defaults to + ``tests/integration/resources/``. """ - resources = components or self._default_components + resources = resources or self._default_resources cmd = [ 'dapr', @@ -207,8 +207,8 @@ def apps_dir() -> Path: @pytest.fixture(scope='module') -def components_dir() -> Path: - return COMPONENTS_DIR +def resources_dir() -> Path: + return RESOURCES_DIR @pytest.fixture(scope='session', autouse=True) diff --git a/tests/integration/components/conversation.yaml b/tests/integration/resources/conversation.yaml similarity index 100% rename from tests/integration/components/conversation.yaml rename to tests/integration/resources/conversation.yaml diff --git a/tests/integration/components/cryptostore.yaml b/tests/integration/resources/cryptostore.yaml similarity index 88% rename from tests/integration/components/cryptostore.yaml rename to tests/integration/resources/cryptostore.yaml index 7eea9b06b..5926ca65d 100644 --- a/tests/integration/components/cryptostore.yaml +++ b/tests/integration/resources/cryptostore.yaml @@ -7,4 +7,4 @@ spec: version: v1 metadata: - name: path - value: ./keys \ No newline at end of file + value: ./keys diff --git a/tests/integration/components/localbinding.yaml b/tests/integration/resources/localbinding.yaml similarity index 100% rename from tests/integration/components/localbinding.yaml rename to tests/integration/resources/localbinding.yaml diff --git a/tests/integration/resources/statestore.yaml b/tests/integration/resources/statestore.yaml index a0c53bc40..2f676bff8 100644 --- a/tests/integration/resources/statestore.yaml +++ b/tests/integration/resources/statestore.yaml @@ -10,3 +10,5 @@ spec: value: localhost:6379 - name: redisPassword value: "" + - name: actorStateStore + value: "true" diff --git a/tests/integration/test_secret_store.py b/tests/integration/test_secret_store.py index b4e8e8679..5cc7597f5 100644 --- a/tests/integration/test_secret_store.py +++ b/tests/integration/test_secret_store.py @@ -4,8 +4,8 @@ @pytest.fixture(scope='module') -def client(dapr_env, components_dir): - return dapr_env.start_sidecar(app_id='test-secret', components=components_dir) +def client(dapr_env, resources_dir): + return dapr_env.start_sidecar(app_id='test-secret', resources=resources_dir) def test_get_secret(client): From 8dd6ffb226b9c41ba5cd1ee0804c8d87e1cd7840 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:03:28 +0200 Subject: [PATCH 23/28] Add Redis to deps for regression test Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 3e7ffe4e0..7d9e001f3 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -11,6 +11,7 @@ opentelemetry-sdk opentelemetry-instrumentation-grpc httpx>=0.28.1 pyOpenSSL>=26.0.0 +redis>=7.4.0 # needed for type checking Flask>=1.1 # needed for auto fix From 23457d15473fae14c10011295f74e4d788a3726d Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:15:17 +0200 Subject: [PATCH 24/28] Create crypto keys at runtime Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .gitignore | 3 ++ dev-requirements.txt | 2 + tests/crypto_utils.py | 36 +++++++++++++++ tests/integration/conftest.py | 12 +++++ tests/integration/keys/rsa-private-key.pem | 52 ---------------------- tests/integration/keys/symmetric-key-256 | 1 - tests/integration/test_crypto_async.py | 2 +- 7 files changed, 54 insertions(+), 54 deletions(-) create mode 100644 tests/crypto_utils.py delete mode 100644 tests/integration/keys/rsa-private-key.pem delete mode 100644 tests/integration/keys/symmetric-key-256 diff --git a/.gitignore b/.gitignore index c16f3a22e..5dadf6d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ coverage.lcov # Integration test scratch dirs tests/integration/.binding-data/ + +# Crypto test material generated at test time (see tests/crypto_utils.py) +tests/integration/keys/ diff --git a/dev-requirements.txt b/dev-requirements.txt index 7d9e001f3..0e2df4c3d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -11,6 +11,8 @@ opentelemetry-sdk opentelemetry-instrumentation-grpc httpx>=0.28.1 pyOpenSSL>=26.0.0 +# used by tests to generate crypto keys at runtime +cryptography>=42.0.0 redis>=7.4.0 # needed for type checking Flask>=1.1 diff --git a/tests/crypto_utils.py b/tests/crypto_utils.py new file mode 100644 index 000000000..d9f674863 --- /dev/null +++ b/tests/crypto_utils.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import secrets +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +RSA_KEY_FILENAME = 'rsa-private-key.pem' +SYMMETRIC_KEY_FILENAME = 'symmetric-key-256' + +_RSA_KEY_SIZE = 4096 +_SYMMETRIC_KEY_BYTES = 32 + + +def write_test_keys(target_dir: Path) -> None: + """Write a fresh 4096-bit RSA private key (PKCS8 PEM) and a 256-bit AES key. + + File names match those expected by ``examples/crypto/crypto.py`` and the + ``cryptostore.yaml`` component used by the integration tests. + """ + target_dir.mkdir(parents=True, exist_ok=True) + + rsa_key = rsa.generate_private_key(public_exponent=65537, key_size=_RSA_KEY_SIZE) + rsa_pem = rsa_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + (target_dir / RSA_KEY_FILENAME).write_bytes(rsa_pem) + (target_dir / SYMMETRIC_KEY_FILENAME).write_bytes(secrets.token_bytes(_SYMMETRIC_KEY_BYTES)) + + +def remove_test_keys(target_dir: Path) -> None: + for name in (RSA_KEY_FILENAME, SYMMETRIC_KEY_FILENAME): + (target_dir / name).unlink(missing_ok=True) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 3a037c6fd..0d1a79643 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,7 @@ from dapr.clients import DaprClient from dapr.conf import settings +from tests.crypto_utils import remove_test_keys, write_test_keys from tests.process_utils import get_kwargs_for_process_group, terminate_process_group from tests.wait_utils import wait_until @@ -19,6 +20,7 @@ APPS_DIR = INTEGRATION_DIR / 'apps' BINDING_DATA_DIR = INTEGRATION_DIR / '.binding-data' +CRYPTO_KEYS_DIR = INTEGRATION_DIR / 'keys' class DaprTestEnvironment: @@ -220,3 +222,13 @@ def _binding_data_dir() -> Generator[None, None, None]: yield finally: shutil.rmtree(BINDING_DATA_DIR, ignore_errors=True) + + +@pytest.fixture(scope='session') +def crypto_keys() -> Generator[Path, None, None]: + """Generate temporary RSA + AES keys for ``cryptostore.yaml``.""" + write_test_keys(CRYPTO_KEYS_DIR) + try: + yield CRYPTO_KEYS_DIR + finally: + remove_test_keys(CRYPTO_KEYS_DIR) diff --git a/tests/integration/keys/rsa-private-key.pem b/tests/integration/keys/rsa-private-key.pem deleted file mode 100644 index a2de6762a..000000000 --- a/tests/integration/keys/rsa-private-key.pem +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDNPVTQbkKW4eja -oCDnsacryzQF/Q4vAMfl7oMjtVm9QHM/k5AJqPDJQhjollQsRzJ5g8FkPdSUnlio -C+yd+3lSmKx2j5LC6F5YIkzFJOjVU5rQwAxJkbpVhoXF2t4b5UXrbZxgoU/tjnAc -FIIPCALbbm02HSb2SZQYPMgDGI+wZ11HI2AyAqAftfCrdmT8PkW0+XmPRz3auSWm -5o0I4v7chx7T1LN6Y7xAXd+3sE7CWhWR5cGpabxAZ2Zz5u48vdBhF/5hh6R+Ebov -jXW6i5ZpU5DEk6z3aU1fbciCZqIkIFHD8rErwMJasnZhNys/4U5R1xM86IVwI1Eb -fa3AnbtE3Vj66TUSwTWM664xoxuNCvK2/v33Mbwg2AM9a8Nd9n6dNIdQ3Ryh1gtF -SK7yyA95E53mHt6l47m6/qt5dXehngBK79vlE0gkJCxLuzf35UgTsrEBl601dXp5 -SQHkjRFnToDzNTeiWzlzLcBu50lDyPMXtBkjop6mSKrtFsb4puXEd/WVgNlcfSZe -TK0ASmCuXdBia8kdVUlDizYEWtz5T+E4r3OmWiflxH4YAFrhxT++fNe1WO3+Y1uu -XAR7RzU7eE68ETDwvLkYCvraDP1iMXGeTMCjIZdu49vLNMdpFsJPVAW5bAz2kJOr -9sLSLoHGwAn+WWE4NhfHeInYstA2YwIDAQABAoICABaUSsJrfvPugpmayEP1LXkJ -7/1Aq9DL+zH2nYLOLsM9VfCGoXAOn/7kQK1F7Ji6dHkd36bRjpOTIBnMxglTYzON -DFw2y2SZ/9ceXufJebwOaJfSqQdm+uLx28G6pHjZLmoKMwwGcy6lXvwX3X8d2IKf -kXBEoMazrZFFDpQYnaZAmOh8odaep1MVxxZ1/gIqL60LTS5QHiPz/opwDtANeRB1 -5RRU8DHkyw8hxL0GroN/OaRFbJrgwQ8s0P6rR0Zzc3tbEmdUbupXtO4KWAtf0/pe -cSzPOlY1xYdcIpUGCYyD6bru9kLj//3OaGulkCKE/QLP8JPg2N1PZVrq5rSsJa/p -YFoJR5uvK02YPJA/+tWbd78WRXt6iPcARkcwB5YDk7hAbuzYGFrU0CNRtY2bPTOX -n4ogkHx8921/nxLlsf0SMzeZWGPb9/rbAUmM/TZJXSHy5XgeiTckI2HA5tyN4QBT -Yues38Aefda46oSTqo135A3D3MbCHeGu401+zlgftV4IuF0XApGyRWK67E3VVmoA -0hvmkzmC/qNgawR5lkk08+ZpyDUnT42RI+KsO9cRE4vRSJiVZdjFAE3rcf8R2gyQ -Xf3liFicV3YlpoxGB3/AO510wVq0yNfbCOhJQ54fA/ZVE97zIC7HhmFCcB6coygm -uXyYGePwDH6bo66/F83RAoIBAQDpmHwI/K9MdZKF7geS3MPQOKAGby6vncgvApeL -rGxM8ScO1QxV8uQ3emvKS0KLvMRDtpyPz4aHzq00DaEI7UYPxCGsN+/pf/PyI+Tm -WrfQXOZUjTL/0CsSXmwcvGQVMruB3cjrmj7B9RPH0jIZv+esNfH6u7gpvrNgbqxs -PneEN1XtFxe08G91R0hN07ggbhqqUChW4hbytl/KqVDlYPCKGZfDIigBTI5vsd4L -KtMGfZ5fW6acj8Dn3A8hzYHnNXI1E1mAl9Zu+TRW/pDaaPBoKqhodSSDAb0RoJGc -y1bZbWSy3QoAYN0wla/kE6P3LQ7diMtmj3d3b3ChSI0Mx5w/AoIBAQDg7J1+zcO4 -rH5a4U9TYnLwQfzooXfokuTv09uxH3bE+Q0vdEyofxCXbh6qUK5upGmCda0dWKdw -OxGEk/TNOl7Qw3J1R1CLJVPPCU4b/d7qi3oPaF523cMdEpxS85KfA3LDOFMgqTZ/ -RyuIQbH3iS1w+gRsFYh8DDJdcSSu8RKjX8JVhkz2UQFPfA8YqRvLNUf1QSRmB53Y -zeNJ367SV4FzJym0VqTsiaVHQPpBXawltGNn0eqXNpv4TOLpQ3Q7Y1S3Y39prLJ1 -g5Ufr87kwh0BwS8dXDOgF43ATyHwwPCOo1ZjudVyqYvJVV+ITZJ1eZ3l/0U2nnsD -PYNcZKVhfKzdAoIBAENFB0srQWw6W4S4JHQ1oSpAdE0GDaLDRFfNXkj50YJi3AWY -cuH5faFAXvQ1sic9qCN73iBH+gz4Bsb7uckxU0DNEYlf3nYWw/CSR6PSsiaN6kKl -Gu+ySgUTLf0kf4nfP0JJ1UeL9tCyPA0KSiVCL3xXWKUFFCbpZQy7MmpFnvNzYApT -4R7ZMq/KZFcNRnQIYSN0y/khSMyCmplpIwO7Y+nRLvQhzPV6z3X4+eGrZnPzDv2V -Dij9+OaMZ8srPGKR8J66QMcYcscoetsmmh5bpAfLaQ4T1fzoLkN6QxStNgiNSTd9 -EhlDy87m/G0o/sn6rtI7R5/0Zsn9TKkVlJD+ls8CggEANPklQrcdcIIXpDnKX/4g -ydsQwI0+22S1TJKd/EJHy65IX7PJVinO84s4563m1yIbw2EJq460qKcQwiPClQ85 -Q3u0mlB4dL0O1wT/A3KwLJc64SQYk3A5QsCeVp8NGixKvBWo5llT/3f4lbe7PWxu -alxH7FjJ80VAG2fJVvZqCFZGQ7RErgJ4B4tVVt6FMD/VObrk4q7Ki0Q6Uqy+1MVN -NJy1osaBQ0BLz9NK3Vg9cgfhHZN/56sx4rHhA0Uiu9XyHtrtKCtHQIwD9BmI5bGd -+UrRWN3dPsgtV2yLttMKFN39O7GJxt6NkJZt0IFMjCRffsq3N1zt5d538Ku3k5U0 -dQKCAQBT5ZpGpuGeG2RI4lzF2iejApZ8Qa7YmTGem7M2T062wlhgyBNogKXxbrl/ -TyvpB5gXSkcCMmdD8727WJNUnnX7EWk+zzqBF1mn6KGoar23YDHLuMKxv6NEF8kI -D4l92SpMJNWkQaoOLKwNz8x8bJ8uYutLLJlDjnUpbdMbUgnw8Nkcflfr3nAKZd5e -BJ46tSNjMV9KyQd5b+pietirVyS3afJaPJNE6Uu8VIPbbxApAW3dfIQznIwgx62E -bWBtDNguJzLLv4zJ+XhcOEIdgAaNBUsT+owfF0ok6EEBzIl51pSo7w4Nh5PkMw4d -VfTYN1T7nfugAi8VqPcL/5ZKQzIz ------END PRIVATE KEY----- diff --git a/tests/integration/keys/symmetric-key-256 b/tests/integration/keys/symmetric-key-256 deleted file mode 100644 index e9a909954..000000000 --- a/tests/integration/keys/symmetric-key-256 +++ /dev/null @@ -1 +0,0 @@ -sΤίsŸΑΖΏkU>A@{0ΥϋZJύlΐ“CQυ \ No newline at end of file diff --git a/tests/integration/test_crypto_async.py b/tests/integration/test_crypto_async.py index f531eac46..fe259a39a 100644 --- a/tests/integration/test_crypto_async.py +++ b/tests/integration/test_crypto_async.py @@ -13,7 +13,7 @@ @pytest.fixture(scope='module') -def sidecar(dapr_env): +def sidecar(dapr_env, crypto_keys): dapr_env.start_sidecar(app_id='test-crypto-async') From 7986977e07e68ad6854b92507bf47da7427232cb Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:15:38 +0200 Subject: [PATCH 25/28] Fix buffering issue on pubsub example test Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- examples/pubsub-streaming-async/publisher.py | 2 +- examples/pubsub-streaming-async/subscriber.py | 2 +- tests/examples/test_pubsub_streaming_async.py | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/pubsub-streaming-async/publisher.py b/examples/pubsub-streaming-async/publisher.py index e4abf3593..5f0445ca2 100644 --- a/examples/pubsub-streaming-async/publisher.py +++ b/examples/pubsub-streaming-async/publisher.py @@ -44,7 +44,7 @@ async def publish_events(): ) # Print the request - print(req_data, flush=True) + print(req_data) await asyncio.sleep(1) diff --git a/examples/pubsub-streaming-async/subscriber.py b/examples/pubsub-streaming-async/subscriber.py index de51a797e..02fd2bd20 100644 --- a/examples/pubsub-streaming-async/subscriber.py +++ b/examples/pubsub-streaming-async/subscriber.py @@ -19,7 +19,7 @@ def process_message(message): global counter counter += 1 # Process the message here - print(f'Processing message: {message.data()} from {message.topic()}...', flush=True) + print(f'Processing message: {message.data()} from {message.topic()}...') return 'success' diff --git a/tests/examples/test_pubsub_streaming_async.py b/tests/examples/test_pubsub_streaming_async.py index f12695a7a..56edda4dc 100644 --- a/tests/examples/test_pubsub_streaming_async.py +++ b/tests/examples/test_pubsub_streaming_async.py @@ -6,6 +6,7 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B1...", + "Closing subscription...", ] EXPECTED_HANDLER_SUBSCRIBER = [ @@ -14,6 +15,7 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B2...", + "Closing subscription...", ] EXPECTED_PUBLISHER = [ @@ -28,12 +30,12 @@ @pytest.mark.example_dir('pubsub-streaming-async') def test_pubsub_streaming_async(dapr): dapr.start( - '--app-id python-subscriber --app-protocol grpc -- python3 subscriber.py --topic=TOPIC_B1', + '--app-id python-subscriber --app-protocol grpc -- python3 -u subscriber.py --topic=TOPIC_B1', wait=5, ) publisher_output = dapr.run( '--app-id python-publisher --app-protocol grpc --dapr-grpc-port=3500 ' - '--enable-app-health-check -- python3 publisher.py --topic=TOPIC_B1', + '--enable-app-health-check -- python3 -u publisher.py --topic=TOPIC_B1', timeout=30, ) for line in EXPECTED_PUBLISHER: @@ -47,12 +49,12 @@ def test_pubsub_streaming_async(dapr): @pytest.mark.example_dir('pubsub-streaming-async') def test_pubsub_streaming_async_handler(dapr): dapr.start( - '--app-id python-subscriber --app-protocol grpc -- python3 subscriber-handler.py --topic=TOPIC_B2', + '--app-id python-subscriber --app-protocol grpc -- python3 -u subscriber-handler.py --topic=TOPIC_B2', wait=5, ) publisher_output = dapr.run( '--app-id python-publisher --app-protocol grpc --dapr-grpc-port=3500 ' - '--enable-app-health-check -- python3 publisher.py --topic=TOPIC_B2', + '--enable-app-health-check -- python3 -u publisher.py --topic=TOPIC_B2', timeout=30, ) for line in EXPECTED_PUBLISHER: From f33796304df1e479b523eb596191fbdf86293eac Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:21:56 +0200 Subject: [PATCH 26/28] Address Copilot comments Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/examples/test_crypto.py | 6 +- tests/integration/AGENTS.md | 27 ++++--- tests/integration/test_conversation.py | 71 ++++++++++++++++++ tests/integration/test_crypto.py | 96 ++++++++++++++++++++++++ tests/integration/test_invoke_binding.py | 92 +++++++++++++++++++++++ tests/integration/test_jobs.py | 84 +++++++++++++++++++++ tests/integration/test_jobs_async.py | 6 +- tests/integration/test_pubsub.py | 14 +++- 8 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 tests/integration/test_conversation.py create mode 100644 tests/integration/test_crypto.py create mode 100644 tests/integration/test_invoke_binding.py create mode 100644 tests/integration/test_jobs.py diff --git a/tests/examples/test_crypto.py b/tests/examples/test_crypto.py index 881159aa2..3de996467 100644 --- a/tests/examples/test_crypto.py +++ b/tests/examples/test_crypto.py @@ -16,7 +16,7 @@ @pytest.fixture() -def crypto_artifacts(): +def cleanup_crypto_outputs(): """Clean up output files written by the crypto example on teardown. Example RSA and AES keys are in ``examples/crypto/keys/``. @@ -27,7 +27,7 @@ def crypto_artifacts(): @pytest.mark.example_dir('crypto') -def test_crypto(dapr, crypto_artifacts): +def test_crypto(dapr, cleanup_crypto_outputs): output = dapr.run( '--app-id crypto --resources-path ./components/ -- python3 crypto.py', timeout=30, @@ -38,7 +38,7 @@ def test_crypto(dapr, crypto_artifacts): @pytest.mark.example_dir('crypto') -def test_crypto_async(dapr, crypto_artifacts): +def test_crypto_async(dapr, cleanup_crypto_outputs): output = dapr.run( '--app-id crypto-async --resources-path ./components/ -- python3 crypto-async.py', timeout=30, diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index 5a7f0695d..862a3b383 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -27,12 +27,12 @@ tox -e integration -- test_state_store.py -k test_save_and_get ``` tests/integration/ -β”œβ”€β”€ conftest.py # DaprTestEnvironment + fixtures (dapr_env, apps_dir, components_dir) +β”œβ”€β”€ conftest.py # DaprTestEnvironment + fixtures (dapr_env, apps_dir, resources_dir, crypto_keys) β”œβ”€β”€ test_*.py # Test files (one per building block) β”œβ”€β”€ apps/ # Helper apps started alongside sidecars β”‚ β”œβ”€β”€ invoke_receiver.py # gRPC method handler for invoke tests β”‚ └── pubsub_subscriber.py # Subscriber that persists messages to state store -β”œβ”€β”€ components/ # Dapr component YAMLs loaded by all sidecars +β”œβ”€β”€ resources/ # Dapr component YAMLs loaded by all sidecars β”‚ β”œβ”€β”€ statestore.yaml # state.redis (also configured as actor state store) β”‚ β”œβ”€β”€ pubsub.yaml # pubsub.redis β”‚ β”œβ”€β”€ lockstore.yaml # lock.redis @@ -41,7 +41,7 @@ tests/integration/ β”‚ β”œβ”€β”€ localbinding.yaml # bindings.localstorage (rootPath=./.binding-data) β”‚ β”œβ”€β”€ cryptostore.yaml # crypto.dapr.localstorage (path=./keys) β”‚ └── conversation.yaml # conversation.echo -β”œβ”€β”€ keys/ # RSA + symmetric keys for cryptostore +β”œβ”€β”€ keys/ # RSA + symmetric keys for cryptostore (generated at test time, gitignored) β”œβ”€β”€ secrets.json # Secrets file for localsecretstore component └── .binding-data/ # Created on demand for localbinding rootPath (gitignored) ``` @@ -54,15 +54,22 @@ Sidecar and client fixtures are **module-scoped** β€” one sidecar per test file. |---------|-------|------|-------------| | `dapr_env` | module | `DaprTestEnvironment` | Manages sidecar lifecycle; call `start_sidecar()` to get a client | | `apps_dir` | module | `Path` | Path to `tests/integration/apps/` | -| `components_dir` | module | `Path` | Path to `tests/integration/components/` | -| `wait_until` | session | `Callable` | Polling helper `(predicate, timeout=10, interval=0.1)` for eventual-consistency assertions | -| `wait_until_async` | session | `Callable` | Async counterpart of `wait_until` for awaitable predicates | +| `resources_dir` | module | `Path` | Path to `tests/integration/resources/` | +| `crypto_keys` | session | `Path` | Generates ephemeral RSA + AES keys under `tests/integration/keys/` for the cryptostore component (see `tests/crypto_utils.py`) | | `flush_redis` | session | `None` | Side-effect fixture that clears the `dapr_redis` container once per session | -| `redis_set` | session | `Callable` | Returns `set(key, value, version=1)` that seeds a Dapr configuration value into Redis (`value||version`) | +| `redis_set_config` | session | `Callable` | Returns `_set(key, value, version=1)` that seeds a Dapr configuration value into Redis (`value||version`) | -All four are session-scoped (defined in `tests/conftest.py`) so that module-scoped fixtures can depend on them. +`flush_redis` and `redis_set_config` are session-scoped (defined in `tests/conftest.py`) so module-scoped fixtures can depend on them. -Each test file defines its own module-scoped `client` fixture that calls `dapr_env.start_sidecar(...)`. +Polling helpers are **plain functions**, not fixtures β€” import them directly: + +```python +from tests.wait_utils import wait_until, wait_until_async +``` + +Both have signature `(condition, timeout=10.0, interval=0.1)` and raise `TimeoutError` if the deadline elapses. `wait_until_async` awaits an awaitable condition. + +Each test file defines its own module-scoped fixture (`client` or `sidecar`) that calls `dapr_env.start_sidecar(...)`. ## Building blocks covered @@ -116,7 +123,7 @@ Some building blocks (invoke, pubsub) require an app process running alongside t 1. Create `test_.py` 2. Add a module-scoped `client` fixture that calls `dapr_env.start_sidecar(app_id='test-')` -3. If the building block needs a new Dapr component, add a YAML to `components/` +3. If the building block needs a new Dapr component, add a YAML to `resources/` 4. If the building block needs a running app, add it to `apps/` and pass `app_cmd` / `app_port` to `start_sidecar()` 5. Use unique keys/resource IDs per test to avoid interference (the sidecar is shared within a module) 6. Assert on SDK return types and gRPC status codes, not on string output diff --git a/tests/integration/test_conversation.py b/tests/integration/test_conversation.py new file mode 100644 index 000000000..b56f3de99 --- /dev/null +++ b/tests/integration/test_conversation.py @@ -0,0 +1,71 @@ +import pytest + +from dapr.clients.grpc.conversation import ( + ConversationInput, + ConversationInputAlpha2, + create_assistant_message, + create_system_message, + create_user_message, +) + +COMPONENT = 'echo' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-conversation') + + +def test_converse_alpha1_echoes_input(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='sync hello', role='user')], + ) + assert response.outputs[0].result == 'sync hello' + + +def test_converse_alpha1_with_multiple_inputs(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ + ConversationInput(content='one', role='user'), + ConversationInput(content='two', role='user'), + ], + ) + results = [out.result for out in response.outputs] + # The echo component concatenates all inputs into a single newline-joined output + # rather than echoing each input individually. + assert results == ['one\ntwo'] + + +def test_converse_alpha1_with_temperature(client): + response = client.converse_alpha1( + name=COMPONENT, + inputs=[ConversationInput(content='warm', role='user')], + temperature=0.7, + ) + assert response.outputs[0].result == 'warm' + + +def test_converse_alpha2_echoes_user_message(client): + response = client.converse_alpha2( + name=COMPONENT, + inputs=[ConversationInputAlpha2(messages=[create_user_message('sync world')])], + ) + assert response.outputs[0].choices[0].message.content == 'sync world' + + +def test_converse_alpha2_with_mixed_messages(client): + response = client.converse_alpha2( + name=COMPONENT, + inputs=[ + ConversationInputAlpha2( + messages=[ + create_system_message('be brief'), + create_user_message('hi'), + create_assistant_message('hello'), + ] + ) + ], + ) + assert response.outputs[0].choices[0].message.content diff --git a/tests/integration/test_crypto.py b/tests/integration/test_crypto.py new file mode 100644 index 000000000..ba42b9262 --- /dev/null +++ b/tests/integration/test_crypto.py @@ -0,0 +1,96 @@ +import pytest + +from dapr.clients.grpc._crypto import DecryptOptions, EncryptOptions + +CRYPTO_COMPONENT = 'cryptostore' +RSA_KEY = 'rsa-private-key.pem' +SYMMETRIC_KEY = 'symmetric-key-256' + +# The crypto API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +@pytest.fixture(scope='module') +def client(dapr_env, crypto_keys): + return dapr_env.start_sidecar(app_id='test-crypto') + + +def test_rsa_round_trip(client): + plaintext = b'sync crypto secret' + + encrypted_stream = client.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ) + encrypted = encrypted_stream.read() + assert encrypted != plaintext + + decrypted_stream = client.decrypt( + data=encrypted, + options=DecryptOptions(component_name=CRYPTO_COMPONENT, key_name=RSA_KEY), + ) + assert decrypted_stream.read() == plaintext + + +def test_aes_round_trip(client): + plaintext = b'A' * (32 * 1024) + + encrypted_stream = client.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=SYMMETRIC_KEY, + key_wrap_algorithm='AES', + ), + ) + encrypted = encrypted_stream.read() + + decrypted_stream = client.decrypt( + data=encrypted, + options=DecryptOptions(component_name=CRYPTO_COMPONENT, key_name=SYMMETRIC_KEY), + ) + assert decrypted_stream.read() == plaintext + + +def test_string_input_round_trip(client): + plaintext = 'hello dapr crypto' + + encrypted_stream = client.encrypt( + data=plaintext, + options=EncryptOptions( + component_name=CRYPTO_COMPONENT, + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ) + encrypted = encrypted_stream.read() + + decrypted_stream = client.decrypt( + data=encrypted, + options=DecryptOptions(component_name=CRYPTO_COMPONENT, key_name=RSA_KEY), + ) + assert decrypted_stream.read().decode() == plaintext + + +def test_encrypt_with_blank_component_raises(client): + with pytest.raises(ValueError): + client.encrypt( + data=b'payload', + options=EncryptOptions( + component_name='', + key_name=RSA_KEY, + key_wrap_algorithm='RSA', + ), + ) + + +def test_decrypt_with_blank_component_raises(client): + with pytest.raises(ValueError): + client.decrypt( + data=b'payload', + options=DecryptOptions(component_name='', key_name=RSA_KEY), + ) diff --git a/tests/integration/test_invoke_binding.py b/tests/integration/test_invoke_binding.py new file mode 100644 index 000000000..44158e407 --- /dev/null +++ b/tests/integration/test_invoke_binding.py @@ -0,0 +1,92 @@ +import uuid +from pathlib import Path + +import pytest + +BINDING = 'localbinding' +BINDING_ROOT = Path(__file__).resolve().parent / '.binding-data' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-invoke-binding') + + +def test_create_writes_file_to_disk(client): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + payload = b'hello from sync invoke_binding' + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + + assert (BINDING_ROOT / file_name).read_bytes() == payload + + +def test_create_then_get_round_trip(client): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + payload = b'sync round-trip payload' + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + response = client.invoke_binding( + binding_name=BINDING, + operation='get', + binding_metadata={'fileName': file_name}, + ) + + assert response.data == payload + + +def test_create_with_string_payload(client): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + payload = 'sync string payload' + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=payload, + binding_metadata={'fileName': file_name}, + ) + + assert (BINDING_ROOT / file_name).read_text() == payload + + +def test_delete_removes_file(client): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + file_path = BINDING_ROOT / file_name + + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=b'to be deleted', + binding_metadata={'fileName': file_name}, + ) + assert file_path.exists() + + client.invoke_binding( + binding_name=BINDING, + operation='delete', + binding_metadata={'fileName': file_name}, + ) + assert not file_path.exists() + + +def test_list_includes_created_file(client): + file_name = f'binding-{uuid.uuid4().hex[:8]}.txt' + client.invoke_binding( + binding_name=BINDING, + operation='create', + data=b'listed', + binding_metadata={'fileName': file_name}, + ) + + response = client.invoke_binding(binding_name=BINDING, operation='list') + assert file_name in response.data.decode() diff --git a/tests/integration/test_jobs.py b/tests/integration/test_jobs.py new file mode 100644 index 000000000..b335498fb --- /dev/null +++ b/tests/integration/test_jobs.py @@ -0,0 +1,84 @@ +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from dapr.clients import Job +from dapr.clients.exceptions import DaprGrpcError + +# The jobs API re-emits the alpha warnings on every test run. +pytestmark = pytest.mark.filterwarnings('ignore::UserWarning') + + +def _future(days: int) -> str: + return (datetime.now(timezone.utc) + timedelta(days=days)).strftime('%Y-%m-%dT%H:%M:%SZ') + + +def _unique_name(prefix: str) -> str: + return f'{prefix}-{uuid.uuid4().hex[:8]}' + + +@pytest.fixture(scope='module') +def client(dapr_env): + return dapr_env.start_sidecar(app_id='test-jobs') + + +def test_schedule_then_get_returns_job(client): + name = _unique_name('sync-job') + due = _future(days=365) + + client.schedule_job_alpha1(Job(name=name, due_time=due)) + try: + retrieved = client.get_job_alpha1(name=name) + assert retrieved.name == name + assert retrieved.due_time == due + finally: + client.delete_job_alpha1(name=name) + + +def test_delete_removes_job(client): + name = _unique_name('sync-job-del') + due = _future(days=365) + + client.schedule_job_alpha1(Job(name=name, due_time=due)) + client.delete_job_alpha1(name=name) + + with pytest.raises(DaprGrpcError): + client.get_job_alpha1(name=name) + + +def test_schedule_with_recurring_schedule(client): + name = _unique_name('sync-job-recurring') + schedule = '@every 1h' + + client.schedule_job_alpha1(Job(name=name, schedule=schedule, repeats=10)) + try: + retrieved = client.get_job_alpha1(name=name) + assert retrieved.schedule == schedule + assert retrieved.repeats == 10 + finally: + client.delete_job_alpha1(name=name) + + +def test_schedule_without_schedule_or_due_time_raises(client): + with pytest.raises(ValueError): + client.schedule_job_alpha1(Job(name=_unique_name('sync-job-bad'))) + + +def test_schedule_with_blank_name_raises(client): + with pytest.raises(ValueError): + client.schedule_job_alpha1(Job(name='', due_time=_future(days=1))) + + +def test_overwrite_replaces_existing_job(client): + name = _unique_name('sync-job-overwrite') + initial_due = _future(days=30) + updated_due = _future(days=60) + + client.schedule_job_alpha1(Job(name=name, due_time=initial_due)) + try: + client.schedule_job_alpha1(Job(name=name, due_time=updated_due), overwrite=True) + retrieved = client.get_job_alpha1(name=name) + assert retrieved.due_time == updated_due + finally: + client.delete_job_alpha1(name=name) diff --git a/tests/integration/test_jobs_async.py b/tests/integration/test_jobs_async.py index 7c8ec1ad0..71ff91db8 100644 --- a/tests/integration/test_jobs_async.py +++ b/tests/integration/test_jobs_async.py @@ -5,6 +5,7 @@ from dapr.aio.clients import DaprClient as AsyncDaprClient from dapr.clients import Job +from dapr.clients.exceptions import DaprGrpcError GRPC_ADDRESS = '127.0.0.1:50001' @@ -37,10 +38,11 @@ async def test_schedule_then_get_returns_job(sidecar): async def test_delete_removes_job(sidecar): name = f'async-job-del-{uuid.uuid4().hex[:8]}' + due = _future(days=365) async with AsyncDaprClient(address=GRPC_ADDRESS) as d: - await d.schedule_job_alpha1(Job(name=name, due_time=_future(days=365))) + await d.schedule_job_alpha1(Job(name=name, due_time=due)) await d.delete_job_alpha1(name=name) - with pytest.raises(Exception): + with pytest.raises(DaprGrpcError): await d.get_job_alpha1(name=name) diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index 8cdb78777..c5ced6636 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -1,6 +1,7 @@ import json import threading import uuid +from concurrent.futures import Future import pytest @@ -100,8 +101,17 @@ def test_streaming_subscribe_receives_published_message(client): data_content_type='application/json', ) - message = subscription.next_message() - assert message is not None + next_message_future: Future = Future() + + def read_next_message() -> None: + try: + next_message_future.set_result(subscription.next_message()) + except Exception as exc: + next_message_future.set_exception(exc) + + threading.Thread(target=read_next_message, daemon=True).start() + + message = next_message_future.result(timeout=10) subscription.respond_success(message) payload = message.data() From f966611dc1b993a693b91988326bb9a3ec7d7d2e Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:51:35 +0200 Subject: [PATCH 27/28] Address Copilot comments (2) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- tests/conftest.py | 2 +- tests/integration/conftest.py | 32 ++++++++++++------------- tests/integration/test_configuration.py | 15 ++++++++---- tests/integration/test_pubsub.py | 8 ++++--- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d4accdfc3..b35559584 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ def flush_redis() -> None: @pytest.fixture(scope='session') -def redis_set_config() -> Callable[..., None]: +def redis_set_config() -> Callable[[str, str, int], None]: """Dapr encodes values in the config store as ``value||version``""" def _set(key: str, value: str, version: int = 1) -> None: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0d1a79643..8a473258d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -31,10 +31,10 @@ class returns real DaprClient instances so tests can make assertions against SDK """ def __init__(self, default_resources: Path = RESOURCES_DIR) -> None: - self._default_resources = default_resources - self._processes: list[subprocess.Popen[str]] = [] - self._log_files: list[IO[str]] = [] - self._clients: list[DaprClient] = [] + self.default_resources = default_resources + self.processes: list[subprocess.Popen[str]] = [] + self.log_files: list[IO[str]] = [] + self.clients: list[DaprClient] = [] def start_sidecar( self, @@ -57,7 +57,7 @@ def start_sidecar( resources: Path to resources YAML directory. Defaults to ``tests/integration/resources/``. """ - resources = resources or self._default_resources + resources = resources or self.default_resources cmd = [ 'dapr', @@ -86,8 +86,8 @@ def start_sidecar( text=True, **get_kwargs_for_process_group(), ) - self._processes.append(proc) - self._log_files.append(log_file) + self.processes.append(proc) + self.log_files.append(log_file) # Point the SDK health check at the actual sidecar HTTP port. # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which @@ -97,7 +97,7 @@ def start_sidecar( settings.DAPR_HTTP_PORT = http_port client = DaprClient(address=f'127.0.0.1:{grpc_port}') - self._clients.append(client) + self.clients.append(client) # /healthz/outbound (polled by DaprClient) only checks sidecar-side # readiness. When we launched an app alongside the sidecar, also wait @@ -108,11 +108,11 @@ def start_sidecar( return client def cleanup(self) -> None: - for client in self._clients: + for client in self.clients: client.close() - self._clients.clear() + self.clients.clear() - for proc in self._processes: + for proc in self.processes: if proc.poll() is None: terminate_process_group(proc) try: @@ -120,11 +120,11 @@ def cleanup(self) -> None: except subprocess.TimeoutExpired: terminate_process_group(proc, force=True) proc.wait() - self._processes.clear() + self.processes.clear() - for log_file in self._log_files: + for log_file in self.log_files: log_file.close() - self._log_files.clear() + self.log_files.clear() def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: @@ -196,7 +196,7 @@ def fail_if_dead_sidecars(dapr_env: DaprTestEnvironment) -> None: Without this, a crashed sidecar produces a cascade of gRPC connection timeouts on every subsequent test in the module. """ - dead = [proc for proc in dapr_env._processes if proc.poll() is not None] + dead = [proc for proc in dapr_env.processes if proc.poll() is not None] if not dead: return details = ', '.join(f'pid={p.pid} exit={p.returncode}' for p in dead) @@ -217,7 +217,7 @@ def resources_dir() -> Path: def _binding_data_dir() -> Generator[None, None, None]: """Provide a fresh ``.binding-data/`` for the localbinding component""" shutil.rmtree(BINDING_DATA_DIR, ignore_errors=True) - BINDING_DATA_DIR.mkdir() + BINDING_DATA_DIR.mkdir(parents=True, exist_ok=True) try: yield finally: diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index f1fe35f92..72b587eee 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -20,9 +20,14 @@ def client(dapr_env, redis_set_config): reason='The sidecar returns the subscription ID before the subscription is active', ) def test_subscribe_first_update_race(client): + # https://github.com/dapr/components-contrib/issues/4361 + # Triggers a race condition where the subscription ID arrives before the subscription is ready. + # A warm, bare connection to Redis is the only reliable way to trigger this race, because routing the `set()` + # through Dapr usually takes long enough for the subscription to become ready. r = redis.Redis(host='127.0.0.1', port=6379) r.ping() event = threading.Event() + sub_id = client.subscribe_configuration( store_name=STORE, keys=['cfg-race-key'], @@ -32,6 +37,8 @@ def test_subscribe_first_update_race(client): r.set('cfg-race-key', 'val||1') assert event.wait(timeout=2) + client.unsubscribe_configuration(store_name=STORE, id=sub_id) + def test_get_single_key(client): resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1']) @@ -68,13 +75,11 @@ def handler(_id: str, resp: ConfigurationResponse) -> None: sub_id = client.subscribe_configuration(store_name=STORE, keys=['cfg-sub-key'], handler=handler) assert sub_id + redis_set_config('cfg-sub-key', 'updated-val', version=2) + # This is necessary because the Dapr runtime returns the subscription ID before the Redis # configuration component finishes registering the subscription - def _set_and_check() -> bool: - redis_set_config('cfg-sub-key', 'updated-val', version=2) - return event.is_set() - - wait_until(_set_and_check, timeout=10, interval=0.2) + wait_until(event.is_set, timeout=10, interval=0.2) assert len(received) >= 1 last = received[-1] diff --git a/tests/integration/test_pubsub.py b/tests/integration/test_pubsub.py index c5ced6636..a6455f60b 100644 --- a/tests/integration/test_pubsub.py +++ b/tests/integration/test_pubsub.py @@ -122,11 +122,14 @@ def read_next_message() -> None: def test_subscribe_with_handler_invokes_callback(client): + run_id = uuid.uuid4().hex received: list[dict] = [] handler_done = threading.Event() def handler(message) -> TopicEventResponse: - received.append(message.data()) + payload = message.data() + if payload.get('run_id') == run_id: + received.append(payload) if len(received) >= 2: handler_done.set() return TopicEventResponse('success') @@ -137,7 +140,6 @@ def handler(message) -> TopicEventResponse: handler_fn=handler, ) try: - run_id = uuid.uuid4().hex for n in range(1, 3): client.publish_event( pubsub_name=PUBSUB, @@ -147,7 +149,7 @@ def handler(message) -> TopicEventResponse: ) assert handler_done.wait(timeout=10), 'handler was not invoked' - ids = sorted(msg['id'] for msg in received if msg['run_id'] == run_id) + ids = sorted(msg['id'] for msg in received) assert ids == [1, 2] finally: close_fn() From 727902b937958f4e9ea1cb6fc18b21a1c1462719 Mon Sep 17 00:00:00 2001 From: Sergio Herrera <627709+seherv@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:25:40 +0200 Subject: [PATCH 28/28] Address Copilot comments (3) Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> --- .github/workflows/run-tests.yaml | 2 +- tests/crypto_utils.py | 2 +- tests/examples/test_pubsub_streaming_async.py | 4 ++-- tests/integration/conftest.py | 13 +------------ tests/integration/test_configuration.py | 8 +++++--- 5 files changed, 10 insertions(+), 19 deletions(-) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index cd69c953c..a1940a50d 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -1,4 +1,4 @@ -name: validate-examples +name: run-tests on: push: diff --git a/tests/crypto_utils.py b/tests/crypto_utils.py index d9f674863..8d77d085f 100644 --- a/tests/crypto_utils.py +++ b/tests/crypto_utils.py @@ -14,7 +14,7 @@ def write_test_keys(target_dir: Path) -> None: - """Write a fresh 4096-bit RSA private key (PKCS8 PEM) and a 256-bit AES key. + """Write a fresh RSA private key (PKCS8 PEM) and a 256-bit AES key. File names match those expected by ``examples/crypto/crypto.py`` and the ``cryptostore.yaml`` component used by the integration tests. diff --git a/tests/examples/test_pubsub_streaming_async.py b/tests/examples/test_pubsub_streaming_async.py index 56edda4dc..64697d962 100644 --- a/tests/examples/test_pubsub_streaming_async.py +++ b/tests/examples/test_pubsub_streaming_async.py @@ -6,7 +6,7 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B1...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B1...", - "Closing subscription...", + 'Closing subscription...', ] EXPECTED_HANDLER_SUBSCRIBER = [ @@ -15,7 +15,7 @@ "Processing message: {'id': 3, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 4, 'message': 'hello world'} from TOPIC_B2...", "Processing message: {'id': 5, 'message': 'hello world'} from TOPIC_B2...", - "Closing subscription...", + 'Closing subscription...', ] EXPECTED_PUBLISHER = [ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8a473258d..a953bd233 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,10 +1,9 @@ import shlex import shutil import subprocess -import tempfile from contextlib import contextmanager from pathlib import Path -from typing import IO, Any, Generator, Iterator +from typing import Any, Generator, Iterator import httpx import pytest @@ -33,7 +32,6 @@ class returns real DaprClient instances so tests can make assertions against SDK def __init__(self, default_resources: Path = RESOURCES_DIR) -> None: self.default_resources = default_resources self.processes: list[subprocess.Popen[str]] = [] - self.log_files: list[IO[str]] = [] self.clients: list[DaprClient] = [] def start_sidecar( @@ -76,18 +74,13 @@ def start_sidecar( if app_cmd is not None: cmd.extend(['--', *shlex.split(app_cmd)]) - # Keep the log file handle alive for the lifetime of the sidecar - log_file = tempfile.NamedTemporaryFile(mode='w', suffix=f'-{app_id}.log') proc = subprocess.Popen( cmd, cwd=INTEGRATION_DIR, - stdout=log_file, - stderr=subprocess.STDOUT, text=True, **get_kwargs_for_process_group(), ) self.processes.append(proc) - self.log_files.append(log_file) # Point the SDK health check at the actual sidecar HTTP port. # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which @@ -122,10 +115,6 @@ def cleanup(self) -> None: proc.wait() self.processes.clear() - for log_file in self.log_files: - log_file.close() - self.log_files.clear() - def _wait_for_app_health(http_port: int, timeout: float = 30.0) -> None: """Poll Dapr's app-facing /v1.0/healthz endpoint until it returns 2xx. diff --git a/tests/integration/test_configuration.py b/tests/integration/test_configuration.py index 72b587eee..1bcbb94d7 100644 --- a/tests/integration/test_configuration.py +++ b/tests/integration/test_configuration.py @@ -19,7 +19,7 @@ def client(dapr_env, redis_set_config): @pytest.mark.xfail( reason='The sidecar returns the subscription ID before the subscription is active', ) -def test_subscribe_first_update_race(client): +def test_subscribe_first_update_race(client, request): # https://github.com/dapr/components-contrib/issues/4361 # Triggers a race condition where the subscription ID arrives before the subscription is ready. # A warm, bare connection to Redis is the only reliable way to trigger this race, because routing the `set()` @@ -34,11 +34,13 @@ def test_subscribe_first_update_race(client): handler=lambda _id, _resp: event.set(), ) assert sub_id + + # Clean up when the fail inevitably ends + request.addfinalizer(lambda: client.unsubscribe_configuration(store_name=STORE, id=sub_id)) + r.set('cfg-race-key', 'val||1') assert event.wait(timeout=2) - client.unsubscribe_configuration(store_name=STORE, id=sub_id) - def test_get_single_key(client): resp = client.get_configuration(store_name=STORE, keys=['cfg-key-1'])