From b4a9acbcb93ca0cf434308ea892bd0aa14ae642f Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 19 Aug 2026 16:48:30 -0700 Subject: [PATCH] test(sessions): run the shared contract tests against more backends Co-authored-by: George Weale PiperOrigin-RevId: 967478023 --- tests/unittests/integrations/__init__.py | 15 ++ .../integrations/redis/_fake_redis.py | 78 ++++++++ .../redis/test_redis_session_service.py | 59 +----- tests/unittests/sessions/_conformance.py | 173 ++++++++++++++++++ .../sessions/test_session_service.py | 47 ++--- 5 files changed, 287 insertions(+), 85 deletions(-) create mode 100644 tests/unittests/integrations/__init__.py create mode 100644 tests/unittests/integrations/redis/_fake_redis.py create mode 100644 tests/unittests/sessions/_conformance.py diff --git a/tests/unittests/integrations/__init__.py b/tests/unittests/integrations/__init__.py new file mode 100644 index 00000000..25143eb6 --- /dev/null +++ b/tests/unittests/integrations/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the integrations package.""" diff --git a/tests/unittests/integrations/redis/_fake_redis.py b/tests/unittests/integrations/redis/_fake_redis.py new file mode 100644 index 00000000..140bbfa6 --- /dev/null +++ b/tests/unittests/integrations/redis/_fake_redis.py @@ -0,0 +1,78 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""In-memory stand-in for the part of redis.asyncio that ADK calls.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + + +class FakeRedisAsync: + """In-memory asynchronous Redis mock for testing.""" + + def __init__(self) -> None: + self._store: dict[str, str] = {} + self._ex_store: dict[str, int | None] = {} + self._created_at: dict[str, float] = {} + self._current_time: float = 0.0 + + def advance_time(self, seconds: float) -> None: + self._current_time += seconds + + def _is_expired(self, key: str) -> bool: + if key not in self._store: + return True + ttl = self._ex_store.get(key) + if ttl is not None and ttl > 0: + created = self._created_at.get(key, 0.0) + if self._current_time - created >= ttl: + self._store.pop(key, None) + self._ex_store.pop(key, None) + self._created_at.pop(key, None) + return True + return False + + async def get(self, key: str) -> str | None: + if self._is_expired(key): + return None + return self._store.get(key) + + async def set( + self, + key: str, + value: str, + ex: int | None = None, + nx: bool = False, + ) -> bool | None: + if nx and not self._is_expired(key): + return None + self._store[key] = value + self._ex_store[key] = ex + self._created_at[key] = self._current_time + return True + + async def delete(self, key: str) -> int: + self._ex_store.pop(key, None) + self._created_at.pop(key, None) + if key in self._store: + del self._store[key] + return 1 + return 0 + + async def scan_iter(self, match: str) -> AsyncIterator[str]: + prefix = match.rstrip("*") + for k in list(self._store): + if not self._is_expired(k) and k.startswith(prefix): + yield k diff --git a/tests/unittests/integrations/redis/test_redis_session_service.py b/tests/unittests/integrations/redis/test_redis_session_service.py index 2baf6dcb..18611528 100644 --- a/tests/unittests/integrations/redis/test_redis_session_service.py +++ b/tests/unittests/integrations/redis/test_redis_session_service.py @@ -26,64 +26,7 @@ from google.adk.integrations.redis._redis_session_service import RedisSessionSer from google.adk.sessions.base_session_service import GetSessionConfig import pytest - -class FakeRedisAsync: - """In-memory asynchronous Redis mock for testing.""" - - def __init__(self): - self._store: dict[str, str] = {} - self._ex_store: dict[str, int | None] = {} - self._created_at: dict[str, float] = {} - self._current_time: float = 0.0 - - def advance_time(self, seconds: float) -> None: - self._current_time += seconds - - def _is_expired(self, key: str) -> bool: - if key not in self._store: - return True - ttl = self._ex_store.get(key) - if ttl is not None and ttl > 0: - created = self._created_at.get(key, 0.0) - if self._current_time - created >= ttl: - self._store.pop(key, None) - self._ex_store.pop(key, None) - self._created_at.pop(key, None) - return True - return False - - async def get(self, key: str) -> str | None: - if self._is_expired(key): - return None - return self._store.get(key) - - async def set( - self, - key: str, - value: str, - ex: int | None = None, - nx: bool = False, - ) -> bool | None: - if nx and not self._is_expired(key): - return None - self._store[key] = value - self._ex_store[key] = ex - self._created_at[key] = self._current_time - return True - - async def delete(self, key: str) -> int: - self._ex_store.pop(key, None) - self._created_at.pop(key, None) - if key in self._store: - del self._store[key] - return 1 - return 0 - - async def scan_iter(self, match: str): - prefix = match.rstrip("*") - for k in list(self._store): - if not self._is_expired(k) and k.startswith(prefix): - yield k +from ._fake_redis import FakeRedisAsync @pytest.fixture diff --git a/tests/unittests/sessions/_conformance.py b/tests/unittests/sessions/_conformance.py new file mode 100644 index 00000000..3cccf40e --- /dev/null +++ b/tests/unittests/sessions/_conformance.py @@ -0,0 +1,173 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Backend registry behind the shared session service contract tests. + +Every test that takes the ``session_service`` fixture states a behavior all +``BaseSessionService`` implementations owe their callers. A backend is only +held to those behaviors once it is registered here, so one left out of this +list can drift from the contract with no test disagreeing. + +A backend that fails a contract test has to record it in ``divergences`` with +a written reason. The test is then marked ``xfail(strict=True)``, so the entry +becomes a defect anyone can pick up, and whoever fixes the backend has to +delete the entry in the same change. + +The Vertex AI and Firestore backends are still missing from the list. Each +needs a stateful in-memory stand-in for its storage API first: the Firestore +tests drive a call-by-call mock that holds no state, and the Agent Engine fake +keys sessions by id alone rather than by app and user. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from collections.abc import Callable +from collections.abc import Mapping +import contextlib +import dataclasses +import pathlib + +from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService +from google.adk.features import FeatureName +from google.adk.features import override_feature_enabled +from google.adk.integrations.redis._config import RedisSessionServiceConfig +from google.adk.integrations.redis._redis_session_service import RedisSessionService +from google.adk.sessions.base_session_service import BaseSessionService +from google.adk.sessions.database_session_service import DatabaseSessionService +from google.adk.sessions.in_memory_session_service import InMemorySessionService +from google.adk.sessions.sqlite_session_service import SqliteSessionService +import pytest + +from ..integrations.redis._fake_redis import FakeRedisAsync + +_MakeService = Callable[ + [pathlib.Path], contextlib.AbstractAsyncContextManager[BaseSessionService] +] + + +@dataclasses.dataclass(frozen=True) +class _Backend: + """A session service implementation held to the shared contract.""" + + name: str + make: _MakeService + divergences: Mapping[str, str] = dataclasses.field(default_factory=dict) + """Contract test name -> the written reason this backend fails it today.""" + + +@contextlib.asynccontextmanager +async def _make_in_memory( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + del tmp_path + yield InMemorySessionService() + + +@contextlib.asynccontextmanager +async def _make_in_memory_light_copy( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + del tmp_path + override_feature_enabled( + FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, True + ) + try: + yield InMemorySessionService() + finally: + override_feature_enabled( + FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, False + ) + + +@contextlib.asynccontextmanager +async def _make_database( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + del tmp_path + service = DatabaseSessionService('sqlite+aiosqlite:///:memory:') + try: + yield service + finally: + await service.close() + + +@contextlib.asynccontextmanager +async def _make_sqlite( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + yield SqliteSessionService(str(tmp_path / 'sqlite.db')) + + +@contextlib.asynccontextmanager +async def _make_redis( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + del tmp_path + yield RedisSessionService( + config=RedisSessionServiceConfig(key_prefix='conformance:session:'), + redis_client=FakeRedisAsync(), + ) + + +@contextlib.asynccontextmanager +async def _make_per_agent_database( + tmp_path: pathlib.Path, +) -> AsyncIterator[BaseSessionService]: + service = PerAgentDatabaseSessionService(agents_root=tmp_path) + try: + yield service + finally: + await service.close() + + +BACKENDS = [ + _Backend('in_memory', _make_in_memory), + _Backend('in_memory_light_copy', _make_in_memory_light_copy), + _Backend('database', _make_database), + _Backend('sqlite', _make_sqlite), + # Two more Redis divergences have no contract test to hang an xfail on + # yet: it builds its key scan pattern from a truthiness check on the user + # id, so an empty one lists every user's sessions, and it writes the + # session key unconditionally on append, so appending to a session it has + # never stored creates one instead of raising. + _Backend( + 'redis', + _make_redis, + divergences={ + 'test_list_sessions_ordered_by_last_update_time': ( + 'Redis sorts sessions newest first, while the base class' + ' documents oldest first.' + ), + 'test_session_last_update_time_updates_on_event': ( + 'Redis stamps the session with the wall clock instead of the' + " appended event's timestamp." + ), + }, + ), + _Backend('per_agent_database', _make_per_agent_database), +] + + +@pytest.fixture(params=BACKENDS, ids=lambda backend: backend.name) +async def session_service( + request: pytest.FixtureRequest, tmp_path: pathlib.Path +) -> AsyncIterator[BaseSessionService]: + """Yields each registered backend in turn, xfailing its known divergences.""" + backend: _Backend = request.param + divergence = backend.divergences.get(request.node.originalname) + if divergence is not None: + request.node.add_marker(pytest.mark.xfail(strict=True, reason=divergence)) + async with backend.make(tmp_path) as service: + yield service diff --git a/tests/unittests/sessions/test_session_service.py b/tests/unittests/sessions/test_session_service.py index 4a767d1a..fd658f62 100644 --- a/tests/unittests/sessions/test_session_service.py +++ b/tests/unittests/sessions/test_session_service.py @@ -17,6 +17,7 @@ from contextlib import asynccontextmanager from datetime import datetime from datetime import timezone import enum +import inspect import os import sqlite3 import time @@ -28,8 +29,6 @@ from google.adk.errors.already_exists_error import AlreadyExistsError from google.adk.errors.session_not_found_error import SessionNotFoundError from google.adk.events.event import Event from google.adk.events.event_actions import EventActions -from google.adk.features import FeatureName -from google.adk.features import override_feature_enabled from google.adk.sessions import database_session_service from google.adk.sessions.base_session_service import GetSessionConfig from google.adk.sessions.database_session_service import DatabaseSessionService @@ -50,10 +49,14 @@ from sqlalchemy.exc import ArgumentError from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import StaticPool +# Tests below that take `session_service` run once per backend registered in +# _conformance; each states a behavior every backend owes its callers. +from . import _conformance +from ._conformance import session_service # noqa: F401 + class SessionServiceType(enum.Enum): IN_MEMORY = 'IN_MEMORY' - IN_MEMORY_WITH_LIGHT_COPY_ENABLED = 'IN_MEMORY_WITH_LIGHT_COPY_ENABLED' DATABASE = 'DATABASE' SQLITE = 'SQLITE' @@ -67,33 +70,23 @@ def get_session_service( return DatabaseSessionService('sqlite+aiosqlite:///:memory:') if service_type == SessionServiceType.SQLITE: return SqliteSessionService(str(tmp_path / 'sqlite.db')) - if service_type == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED: - return InMemorySessionService() return InMemorySessionService() -@pytest.fixture( - params=[ - SessionServiceType.IN_MEMORY, - SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED, - SessionServiceType.DATABASE, - SessionServiceType.SQLITE, - ] -) -async def session_service(request, tmp_path): - """Provides a session service and closes database backends on teardown.""" - if request.param == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED: - override_feature_enabled( - FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, True - ) - service = get_session_service(request.param, tmp_path) - yield service - if isinstance(service, DatabaseSessionService): - await service.close() - if request.param == SessionServiceType.IN_MEMORY_WITH_LIGHT_COPY_ENABLED: - override_feature_enabled( - FeatureName.IN_MEMORY_SESSION_SERVICE_LIGHT_COPY, False - ) +def test_recorded_divergences_name_a_contract_test(): + """A divergence keyed on anything else silently excuses no backend.""" + for backend in _conformance.BACKENDS: + for test_name in backend.divergences: + test_function = globals().get(test_name) + assert test_function is not None, ( + f'{backend.name} records a divergence for {test_name}, which is not' + ' a test in this module' + ) + parameters = inspect.signature(test_function).parameters + assert 'session_service' in parameters, ( + f'{backend.name} records a divergence for {test_name}, which does' + ' not take the shared contract fixture' + ) def test_database_session_service_enables_pool_pre_ping_by_default():