From de9f826d7c2ab719f0d53824cac899716db951c2 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 23 Mar 2026 20:14:13 -0700 Subject: [PATCH 1/5] feat: add RedisStore, SQLiteStore, and wire store into Mpp.create() - RedisStore: production-ready store using redis-py (SET NX EX for atomic put_if_absent, configurable TTL and key prefix) - SQLiteStore: zero-infra production store using aiosqlite (INSERT OR IGNORE for atomic put_if_absent, lazy TTL expiry) - Mpp.create(store=...) and Mpp(store=...) automatically inject the store into intents that have a _store attribute (e.g. ChargeIntent) - New optional deps: pympp[redis], pympp[sqlite] - 27 new tests covering all store backends and wiring behavior --- pyproject.toml | 3 + src/mpp/__init__.py | 1 + src/mpp/server/mpp.py | 21 +++++++ src/mpp/stores/__init__.py | 24 ++++++++ src/mpp/stores/redis.py | 62 +++++++++++++++++++ src/mpp/stores/sqlite.py | 120 ++++++++++++++++++++++++++++++++++++ tests/test_stores_redis.py | 70 +++++++++++++++++++++ tests/test_stores_sqlite.py | 113 +++++++++++++++++++++++++++++++++ tests/test_stores_wiring.py | 118 +++++++++++++++++++++++++++++++++++ 9 files changed, 532 insertions(+) create mode 100644 src/mpp/stores/__init__.py create mode 100644 src/mpp/stores/redis.py create mode 100644 src/mpp/stores/sqlite.py create mode 100644 tests/test_stores_redis.py create mode 100644 tests/test_stores_sqlite.py create mode 100644 tests/test_stores_wiring.py diff --git a/pyproject.toml b/pyproject.toml index 275151f..438d253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ tempo = [ "pydantic>=2.0", ] server = ["pydantic>=2.0", "python-dotenv>=1.0"] +redis = ["redis>=5.0"] +sqlite = ["aiosqlite>=0.20"] mcp = ["mcp>=1.1.0"] dev = [ "pytest>=8.0", @@ -47,6 +49,7 @@ dev = [ "pyright>=1.1", "build>=1.0", "twine>=6.0", + "aiosqlite>=0.20", ] [build-system] diff --git a/src/mpp/__init__.py b/src/mpp/__init__.py index e330047..902261d 100644 --- a/src/mpp/__init__.py +++ b/src/mpp/__init__.py @@ -399,4 +399,5 @@ def success( from . import _body_digest as BodyDigest # noqa: E402 from . import _expires as Expires # noqa: E402 +from . import stores # noqa: E402 from .store import MemoryStore, Store # noqa: E402 diff --git a/src/mpp/server/mpp.py b/src/mpp/server/mpp.py index 87f695f..a4915b8 100644 --- a/src/mpp/server/mpp.py +++ b/src/mpp/server/mpp.py @@ -12,6 +12,7 @@ from mpp.server.decorator import wrap_payment_handler from mpp.server.method import transform_request from mpp.server.verify import verify_or_challenge +from mpp.store import Store if TYPE_CHECKING: from mpp.server.method import Method @@ -58,6 +59,7 @@ def __init__( realm: str, secret_key: str, defaults: dict[str, Any] | None = None, + store: Store | None = None, ) -> None: """Initialize the payment handler. @@ -67,18 +69,34 @@ def __init__( secret_key: Server secret for HMAC-bound challenge IDs. Enables stateless challenge verification. defaults: Default request values merged with per-call request params. + store: Optional key-value store for replay protection. + When provided, automatically wired into intents that + accept a ``store`` (e.g., ``ChargeIntent``). """ self.method = method self.realm = realm self.secret_key = secret_key self.defaults = defaults or {} + if store is not None: + self._wire_store(store) + + def _wire_store(self, store: Store) -> None: + """Inject *store* into intents that have a ``_store`` attribute set to None.""" + intents = getattr(self.method, "intents", None) + if not isinstance(intents, dict): + return + for intent_obj in intents.values(): + if hasattr(intent_obj, "_store") and intent_obj._store is None: + intent_obj._store = store + @classmethod def create( cls, method: Method, realm: str | None = None, secret_key: str | None = None, + store: Store | None = None, ) -> Mpp: """Create an Mpp instance with smart defaults. @@ -86,11 +104,14 @@ def create( method: Payment method (e.g., tempo(currency=..., recipient=...)). realm: Server realm. Auto-detected from environment if omitted. secret_key: HMAC secret. Required unless `MPP_SECRET_KEY` is set. + store: Optional key-value store for replay protection. + Automatically wired into intents that accept a store. """ return cls( method=method, realm=detect_realm() if realm is None else realm, secret_key=detect_secret_key() if secret_key is None else secret_key, + store=store, ) async def charge( diff --git a/src/mpp/stores/__init__.py b/src/mpp/stores/__init__.py new file mode 100644 index 0000000..41dd0f6 --- /dev/null +++ b/src/mpp/stores/__init__.py @@ -0,0 +1,24 @@ +"""Concrete store backends for replay protection. + +Available backends: + +- ``MemoryStore`` – in-memory ``dict``, for development/testing. +- ``RedisStore`` – Redis/Valkey, for multi-instance production deployments. +- ``SQLiteStore`` – local SQLite file, for single-instance production deployments. +""" + +from mpp.store import MemoryStore + +__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"] + + +def __getattr__(name: str): # type: ignore[reportReturnType] + if name == "RedisStore": + from mpp.stores.redis import RedisStore + + return RedisStore + if name == "SQLiteStore": + from mpp.stores.sqlite import SQLiteStore + + return SQLiteStore + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/stores/redis.py b/src/mpp/stores/redis.py new file mode 100644 index 0000000..245940a --- /dev/null +++ b/src/mpp/stores/redis.py @@ -0,0 +1,62 @@ +"""Redis-backed store for multi-instance deployments. + +Uses ``redis-py`` (``redis.asyncio``) as the async driver. Install with:: + + pip install pympp[redis] + +Example:: + + from redis.asyncio import from_url + from mpp.stores import RedisStore + + store = RedisStore(await from_url("redis://localhost:6379")) +""" + +from __future__ import annotations + +from typing import Any + + +class RedisStore: + """Async key-value store backed by Redis. + + Each key is prefixed with ``key_prefix`` (default ``"mpp:"``) and + automatically expires after ``ttl_seconds`` (default 300 — 5 minutes). + + ``put_if_absent`` maps to ``SET key value NX EX ttl`` — a single atomic + Redis command with no TOCTOU race. + """ + + def __init__( + self, + client: Any, + *, + key_prefix: str = "mpp:", + ttl_seconds: int = 300, + ) -> None: + self._redis = client + self._prefix = key_prefix + self._ttl = ttl_seconds + + def _key(self, key: str) -> str: + return f"{self._prefix}{key}" + + async def get(self, key: str) -> Any | None: + return await self._redis.get(self._key(key)) + + async def put(self, key: str, value: Any) -> None: + await self._redis.set(self._key(key), value, ex=self._ttl) + + async def delete(self, key: str) -> None: + await self._redis.delete(self._key(key)) + + async def put_if_absent(self, key: str, value: Any) -> bool: + """Atomic ``SETNX`` with TTL. + + Returns ``True`` when the key was new and the write succeeded, + ``False`` when the key already existed (duplicate). + """ + result = await self._redis.set( + self._key(key), value, nx=True, ex=self._ttl + ) + return result is not None diff --git a/src/mpp/stores/sqlite.py b/src/mpp/stores/sqlite.py new file mode 100644 index 0000000..6e98b83 --- /dev/null +++ b/src/mpp/stores/sqlite.py @@ -0,0 +1,120 @@ +"""SQLite-backed store for single-instance production deployments. + +Uses ``aiosqlite`` for async access to Python's built-in ``sqlite3``. +Install with:: + + pip install pympp[sqlite] + +Example:: + + from mpp.stores import SQLiteStore + + store = await SQLiteStore.create("mpp.db") +""" + +from __future__ import annotations + +import time +from typing import Any + + +class SQLiteStore: + """Async key-value store backed by a local SQLite file. + + Keys are stored in a ``kv`` table with optional TTL. Expired rows + are lazily pruned on ``get`` and ``put_if_absent``. + + ``put_if_absent`` uses ``INSERT OR IGNORE`` — a single atomic SQL + statement with no TOCTOU race. + """ + + def __init__( + self, + db: Any, + *, + ttl_seconds: int = 300, + ) -> None: + self._db = db + self._ttl = ttl_seconds + + @classmethod + async def create( + cls, + path: str = "mpp.db", + *, + ttl_seconds: int = 300, + ) -> SQLiteStore: + """Open (or create) a SQLite database and initialize the schema. + + Args: + path: Filesystem path for the database file. + Use ``":memory:"`` for an ephemeral in-memory database. + ttl_seconds: Seconds before a key expires (default 300). + """ + import aiosqlite + + db = await aiosqlite.connect(path) + await db.execute( + "CREATE TABLE IF NOT EXISTS kv (" + " key TEXT PRIMARY KEY," + " value TEXT NOT NULL," + " expires_at REAL NOT NULL" + ")" + ) + await db.commit() + return cls(db, ttl_seconds=ttl_seconds) + + async def close(self) -> None: + """Close the underlying database connection.""" + await self._db.close() + + async def __aenter__(self) -> SQLiteStore: + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + def _expires_at(self) -> float: + return time.time() + self._ttl + + async def get(self, key: str) -> Any | None: + now = time.time() + cursor = await self._db.execute( + "SELECT value FROM kv WHERE key = ? AND expires_at > ?", + (key, now), + ) + row = await cursor.fetchone() + return row[0] if row else None + + async def put(self, key: str, value: Any) -> None: + await self._db.execute( + "INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value," + " expires_at = excluded.expires_at", + (key, value, self._expires_at()), + ) + await self._db.commit() + + async def delete(self, key: str) -> None: + await self._db.execute("DELETE FROM kv WHERE key = ?", (key,)) + await self._db.commit() + + async def put_if_absent(self, key: str, value: Any) -> bool: + """Atomic conditional insert. + + Deletes any expired row for *key* first, then uses + ``INSERT OR IGNORE`` so the write only succeeds when the + key does not already exist. + + Returns ``True`` if the key was new, ``False`` if it existed. + """ + now = time.time() + await self._db.execute( + "DELETE FROM kv WHERE key = ? AND expires_at <= ?", (key, now) + ) + cursor = await self._db.execute( + "INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", + (key, value, self._expires_at()), + ) + await self._db.commit() + return cursor.rowcount > 0 diff --git a/tests/test_stores_redis.py b/tests/test_stores_redis.py new file mode 100644 index 0000000..feefdb3 --- /dev/null +++ b/tests/test_stores_redis.py @@ -0,0 +1,70 @@ +"""Tests for RedisStore.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from mpp.stores.redis import RedisStore + + +@pytest.fixture +def mock_redis(): + return AsyncMock() + + +@pytest.fixture +def store(mock_redis): + return RedisStore(mock_redis, ttl_seconds=300) + + +class TestRedisStore: + @pytest.mark.asyncio + async def test_get_returns_value(self, store, mock_redis) -> None: + mock_redis.get.return_value = b"some-value" + result = await store.get("foo") + assert result == b"some-value" + mock_redis.get.assert_awaited_once_with("mpp:foo") + + @pytest.mark.asyncio + async def test_get_returns_none_when_missing(self, store, mock_redis) -> None: + mock_redis.get.return_value = None + result = await store.get("missing") + assert result is None + + @pytest.mark.asyncio + async def test_put(self, store, mock_redis) -> None: + await store.put("key1", "val1") + mock_redis.set.assert_awaited_once_with("mpp:key1", "val1", ex=300) + + @pytest.mark.asyncio + async def test_delete(self, store, mock_redis) -> None: + await store.delete("key1") + mock_redis.delete.assert_awaited_once_with("mpp:key1") + + @pytest.mark.asyncio + async def test_put_if_absent_returns_true_when_key_absent(self, store, mock_redis) -> None: + mock_redis.set.return_value = True # Redis SET NX returns True on success + result = await store.put_if_absent("new-key", "val") + assert result is True + mock_redis.set.assert_awaited_once_with("mpp:new-key", "val", nx=True, ex=300) + + @pytest.mark.asyncio + async def test_put_if_absent_returns_false_when_key_exists(self, store, mock_redis) -> None: + mock_redis.set.return_value = None # Redis SET NX returns None on conflict + result = await store.put_if_absent("existing", "val") + assert result is False + + @pytest.mark.asyncio + async def test_key_prefix(self, mock_redis) -> None: + store = RedisStore(mock_redis, key_prefix="custom:") + mock_redis.get.return_value = b"x" + await store.get("abc") + mock_redis.get.assert_awaited_once_with("custom:abc") + + @pytest.mark.asyncio + async def test_custom_ttl(self, mock_redis) -> None: + store = RedisStore(mock_redis, ttl_seconds=60) + await store.put("k", "v") + mock_redis.set.assert_awaited_once_with("mpp:k", "v", ex=60) diff --git a/tests/test_stores_sqlite.py b/tests/test_stores_sqlite.py new file mode 100644 index 0000000..e583d8e --- /dev/null +++ b/tests/test_stores_sqlite.py @@ -0,0 +1,113 @@ +"""Tests for SQLiteStore.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from mpp.stores.sqlite import SQLiteStore + + +@pytest.fixture +async def store(): + s = await SQLiteStore.create(":memory:", ttl_seconds=300) + yield s + await s.close() + + +class TestSQLiteStore: + @pytest.mark.asyncio + async def test_put_and_get(self, store) -> None: + await store.put("key1", "value1") + result = await store.get("key1") + assert result == "value1" + + @pytest.mark.asyncio + async def test_get_returns_none_when_missing(self, store) -> None: + result = await store.get("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_delete(self, store) -> None: + await store.put("key1", "value1") + await store.delete("key1") + result = await store.get("key1") + assert result is None + + @pytest.mark.asyncio + async def test_put_overwrites(self, store) -> None: + await store.put("key1", "old") + await store.put("key1", "new") + result = await store.get("key1") + assert result == "new" + + @pytest.mark.asyncio + async def test_put_if_absent_returns_true_when_key_absent(self, store) -> None: + result = await store.put_if_absent("new-key", "val") + assert result is True + assert await store.get("new-key") == "val" + + @pytest.mark.asyncio + async def test_put_if_absent_returns_false_when_key_exists(self, store) -> None: + await store.put("existing", "original") + result = await store.put_if_absent("existing", "new-val") + assert result is False + assert await store.get("existing") == "original" + + @pytest.mark.asyncio + async def test_expired_key_returns_none(self, store) -> None: + """Keys past their TTL should not be returned by get().""" + far_past = time.time() - 1000 + await store._db.execute( + "INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)", + ("expired", "old", far_past), + ) + await store._db.commit() + assert await store.get("expired") is None + + @pytest.mark.asyncio + async def test_put_if_absent_reclaims_expired_key(self, store) -> None: + """An expired key should be cleaned up, allowing a new insert.""" + far_past = time.time() - 1000 + await store._db.execute( + "INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)", + ("reclaim", "old", far_past), + ) + await store._db.commit() + + result = await store.put_if_absent("reclaim", "new") + assert result is True + assert await store.get("reclaim") == "new" + + @pytest.mark.asyncio + async def test_context_manager(self) -> None: + async with await SQLiteStore.create(":memory:") as store: + await store.put("ctx", "val") + assert await store.get("ctx") == "val" + + @pytest.mark.asyncio + async def test_custom_ttl(self) -> None: + store = await SQLiteStore.create(":memory:", ttl_seconds=1) + await store.put("short", "val") + assert await store.get("short") == "val" + + with patch("mpp.stores.sqlite.time") as mock_time: + mock_time.time.return_value = time.time() + 2 + assert await store.get("short") is None + + await store.close() + + @pytest.mark.asyncio + async def test_delete_nonexistent_key_is_noop(self, store) -> None: + await store.delete("nope") # should not raise + + @pytest.mark.asyncio + async def test_multiple_keys(self, store) -> None: + await store.put("a", "1") + await store.put("b", "2") + await store.put("c", "3") + assert await store.get("a") == "1" + assert await store.get("b") == "2" + assert await store.get("c") == "3" diff --git a/tests/test_stores_wiring.py b/tests/test_stores_wiring.py new file mode 100644 index 0000000..10b6659 --- /dev/null +++ b/tests/test_stores_wiring.py @@ -0,0 +1,118 @@ +"""Tests for Mpp store wiring.""" + +from __future__ import annotations + +import pytest + +from mpp import Challenge +from mpp.methods.tempo import tempo +from mpp.methods.tempo.intents import ChargeIntent +from mpp.server import Mpp +from mpp.store import MemoryStore + + +class TestMppStoreWiring: + def test_store_wired_into_charge_intent(self) -> None: + """Mpp.create(store=...) should inject the store into ChargeIntent.""" + store = MemoryStore() + intent = ChargeIntent() + assert intent._store is None + + Mpp.create( + method=tempo( + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + intents={"charge": intent}, + ), + realm="test.com", + secret_key="test-secret", + store=store, + ) + + assert intent._store is store + + def test_store_not_wired_when_none(self) -> None: + """Mpp.create() without store should leave intent._store as None.""" + intent = ChargeIntent() + Mpp.create( + method=tempo( + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + intents={"charge": intent}, + ), + realm="test.com", + secret_key="test-secret", + ) + assert intent._store is None + + def test_store_does_not_overwrite_existing_intent_store(self) -> None: + """If an intent already has a store, Mpp should not overwrite it.""" + existing_store = MemoryStore() + new_store = MemoryStore() + intent = ChargeIntent(store=existing_store) + + Mpp.create( + method=tempo( + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + intents={"charge": intent}, + ), + realm="test.com", + secret_key="test-secret", + store=new_store, + ) + + assert intent._store is existing_store + + def test_constructor_also_wires_store(self) -> None: + """Direct Mpp() constructor should also wire the store.""" + store = MemoryStore() + intent = ChargeIntent() + Mpp( + method=tempo( + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + intents={"charge": intent}, + ), + realm="test.com", + secret_key="test-secret", + store=store, + ) + assert intent._store is store + + @pytest.mark.asyncio + async def test_charge_with_store_returns_challenge(self) -> None: + """End-to-end: Mpp with store still returns challenges correctly.""" + store = MemoryStore() + srv = Mpp.create( + method=tempo( + currency="0x20c0000000000000000000000000000000000000", + recipient="0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + intents={"charge": ChargeIntent()}, + ), + realm="test.com", + secret_key="test-secret", + store=store, + ) + result = await srv.charge(authorization=None, amount="0.50") + assert isinstance(result, Challenge) + + +class TestStoreProtocolConformance: + """Verify MemoryStore conforms to the Store protocol.""" + + @pytest.mark.asyncio + async def test_memory_store_get_put_delete(self) -> None: + store = MemoryStore() + assert await store.get("k") is None + await store.put("k", "v") + assert await store.get("k") == "v" + await store.delete("k") + assert await store.get("k") is None + + @pytest.mark.asyncio + async def test_memory_store_put_if_absent(self) -> None: + store = MemoryStore() + assert await store.put_if_absent("k", "v1") is True + assert await store.put_if_absent("k", "v2") is False + assert await store.get("k") == "v1" From b9cf62d2fd286e125324521cedf2e0362883d96c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 03:14:56 +0000 Subject: [PATCH 2/5] chore: add changelog --- .changelog/merry-dogs-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/merry-dogs-run.md diff --git a/.changelog/merry-dogs-run.md b/.changelog/merry-dogs-run.md new file mode 100644 index 0000000..f38ba49 --- /dev/null +++ b/.changelog/merry-dogs-run.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added `RedisStore` and `SQLiteStore` backends to `mpp.stores` for replay protection, with optional extras (`pympp[redis]`, `pympp[sqlite]`). Added `store` parameter to `Mpp.__init__` and `Mpp.create()` that automatically wires the store into intents supporting replay protection. From 602d6ea39df579114a4fa504fc8463df41f333db Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 23 Mar 2026 20:26:20 -0700 Subject: [PATCH 3/5] style: fix ruff formatting in store backends --- src/mpp/stores/redis.py | 4 +--- src/mpp/stores/sqlite.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/mpp/stores/redis.py b/src/mpp/stores/redis.py index 245940a..6b11299 100644 --- a/src/mpp/stores/redis.py +++ b/src/mpp/stores/redis.py @@ -56,7 +56,5 @@ async def put_if_absent(self, key: str, value: Any) -> bool: Returns ``True`` when the key was new and the write succeeded, ``False`` when the key already existed (duplicate). """ - result = await self._redis.set( - self._key(key), value, nx=True, ex=self._ttl - ) + result = await self._redis.set(self._key(key), value, nx=True, ex=self._ttl) return result is not None diff --git a/src/mpp/stores/sqlite.py b/src/mpp/stores/sqlite.py index 6e98b83..56ae41f 100644 --- a/src/mpp/stores/sqlite.py +++ b/src/mpp/stores/sqlite.py @@ -109,9 +109,7 @@ async def put_if_absent(self, key: str, value: Any) -> bool: Returns ``True`` if the key was new, ``False`` if it existed. """ now = time.time() - await self._db.execute( - "DELETE FROM kv WHERE key = ? AND expires_at <= ?", (key, now) - ) + await self._db.execute("DELETE FROM kv WHERE key = ? AND expires_at <= ?", (key, now)) cursor = await self._db.execute( "INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", (key, value, self._expires_at()), From 4d5adb7a87abed8f721b84f786df5f3c6c04a6a6 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 23 Mar 2026 20:28:54 -0700 Subject: [PATCH 4/5] test: add Redis integration tests against real instance - Add redis service to docker-compose.yml - 10 integration tests: CRUD, TTL verification, atomicity of put_if_absent under concurrent access, key prefix isolation - Skipped automatically when REDIS_URL is not set - Run with: docker compose up -d redis && REDIS_URL=redis://localhost:6379 uv run pytest -m redis -v --- docker-compose.yml | 10 ++ pyproject.toml | 6 +- tests/test_stores_redis_integration.py | 131 +++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/test_stores_redis_integration.py diff --git a/docker-compose.yml b/docker-compose.yml index bb1a009..d296e38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,14 @@ services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 10 + tempo: image: ghcr.io/tempoxyz/tempo:latest ports: diff --git a/pyproject.toml b/pyproject.toml index 438d253..1449738 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dev = [ "build>=1.0", "twine>=6.0", "aiosqlite>=0.20", + "redis>=5.0", ] [build-system] @@ -82,4 +83,7 @@ include = ["src", "tests"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" -markers = ["integration: requires TEMPO_RPC_URL (real Tempo node)"] +markers = [ + "integration: requires TEMPO_RPC_URL (real Tempo node)", + "redis: requires REDIS_URL (real Redis instance)", +] diff --git a/tests/test_stores_redis_integration.py b/tests/test_stores_redis_integration.py new file mode 100644 index 0000000..d7afca1 --- /dev/null +++ b/tests/test_stores_redis_integration.py @@ -0,0 +1,131 @@ +"""Integration tests for RedisStore against a real Redis instance. + +Run with: + docker compose up -d redis + REDIS_URL=redis://localhost:6379 uv run pytest -m redis -v +""" + +from __future__ import annotations + +import os +import uuid + +import pytest + +REDIS_URL = os.environ.get("REDIS_URL") + +pytestmark = [ + pytest.mark.redis, + pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set (no Redis instance)"), +] + + +@pytest.fixture +async def redis_client(): + from redis.asyncio import from_url + + client = from_url(REDIS_URL) + yield client + await client.aclose() + + +@pytest.fixture +def store_prefix(): + """Unique prefix per test to avoid key collisions across parallel runs.""" + return f"test:{uuid.uuid4().hex[:8]}:" + + +@pytest.fixture +async def store(redis_client, store_prefix): + from mpp.stores.redis import RedisStore + + return RedisStore(redis_client, key_prefix=store_prefix, ttl_seconds=10) + + +class TestRedisStoreIntegration: + @pytest.mark.asyncio + async def test_put_and_get(self, store) -> None: + await store.put("key1", "value1") + result = await store.get("key1") + assert result == b"value1" + + @pytest.mark.asyncio + async def test_get_returns_none_when_missing(self, store) -> None: + result = await store.get("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_delete(self, store) -> None: + await store.put("key1", "value1") + await store.delete("key1") + result = await store.get("key1") + assert result is None + + @pytest.mark.asyncio + async def test_put_overwrites(self, store) -> None: + await store.put("key1", "old") + await store.put("key1", "new") + result = await store.get("key1") + assert result == b"new" + + @pytest.mark.asyncio + async def test_put_if_absent_returns_true_when_absent(self, store) -> None: + result = await store.put_if_absent("new-key", "val") + assert result is True + assert await store.get("new-key") == b"val" + + @pytest.mark.asyncio + async def test_put_if_absent_returns_false_when_exists(self, store) -> None: + await store.put("existing", "original") + result = await store.put_if_absent("existing", "new") + assert result is False + assert await store.get("existing") == b"original" + + @pytest.mark.asyncio + async def test_ttl_is_set(self, redis_client, store_prefix) -> None: + """Verify that keys have a TTL set in Redis.""" + from mpp.stores.redis import RedisStore + + store = RedisStore(redis_client, key_prefix=store_prefix, ttl_seconds=60) + await store.put("ttl-key", "val") + + ttl = await redis_client.ttl(f"{store_prefix}ttl-key") + assert 0 < ttl <= 60 + + @pytest.mark.asyncio + async def test_put_if_absent_is_atomic(self, store) -> None: + """Two concurrent put_if_absent calls — exactly one wins.""" + import asyncio + + key = "race-key" + results = await asyncio.gather( + store.put_if_absent(key, "a"), + store.put_if_absent(key, "b"), + ) + assert sorted(results) == [False, True] + + @pytest.mark.asyncio + async def test_multiple_keys(self, store) -> None: + await store.put("a", "1") + await store.put("b", "2") + await store.put("c", "3") + assert await store.get("a") == b"1" + assert await store.get("b") == b"2" + assert await store.get("c") == b"3" + + @pytest.mark.asyncio + async def test_key_isolation(self, redis_client) -> None: + """Two stores with different prefixes don't see each other's keys.""" + from mpp.stores.redis import RedisStore + + store_a = RedisStore(redis_client, key_prefix="ns-a:", ttl_seconds=10) + store_b = RedisStore(redis_client, key_prefix="ns-b:", ttl_seconds=10) + + await store_a.put("shared-name", "from-a") + await store_b.put("shared-name", "from-b") + + assert await store_a.get("shared-name") == b"from-a" + assert await store_b.get("shared-name") == b"from-b" + + await store_a.delete("shared-name") + await store_b.delete("shared-name") From 50c2248fff1a7c247c52b519b541fe2a80298ef0 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 23 Mar 2026 20:31:53 -0700 Subject: [PATCH 5/5] fix: narrow REDIS_URL type for pyright --- tests/test_stores_redis_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_stores_redis_integration.py b/tests/test_stores_redis_integration.py index d7afca1..9db26b4 100644 --- a/tests/test_stores_redis_integration.py +++ b/tests/test_stores_redis_integration.py @@ -24,6 +24,7 @@ async def redis_client(): from redis.asyncio import from_url + assert REDIS_URL is not None client = from_url(REDIS_URL) yield client await client.aclose()