Skip to content

Commit 9bb9076

Browse files
committed
adds cache tests
1 parent 2b3490b commit 9bb9076

File tree

3 files changed

+168
-39
lines changed

3 files changed

+168
-39
lines changed

services/api-server/src/simcore_service_api_server/repository/api_keys.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import NamedTuple
33

44
import sqlalchemy as sa
5+
from aiocache import cached
56
from models_library.products import ProductName
67
from pydantic.types import PositiveInt
78
from simcore_postgres_database.models.api_keys import api_keys as auth_api_keys_table
@@ -21,13 +22,24 @@ class UserAndProductTuple(NamedTuple):
2122
class ApiKeysRepository(BaseRepository):
2223
"""Auth access"""
2324

25+
@cached(
26+
ttl=120,
27+
key_builder=lambda *_args, **kwargs: f"api_auth:{kwargs['api_key']}",
28+
namespace=__name__,
29+
noself=True,
30+
)
2431
async def get_user(
2532
self,
2633
connection: AsyncConnection | None = None,
2734
*,
2835
api_key: str,
29-
api_secret: str
36+
api_secret: str,
3037
) -> UserAndProductTuple | None:
38+
"""Validates API key and secret, returning user info if valid otherwise None.
39+
40+
WARNING: Cached for 120s TTL - secret validation occurs every 2 minutes.
41+
NOTE: to disable caching set AIOCACHE_DISABLE=1
42+
"""
3143

3244
stmt = sa.select(
3345
auth_api_keys_table.c.user_id,

services/api-server/src/simcore_service_api_server/repository/users.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sqlalchemy as sa
2+
from aiocache import Cache, cached # type: ignore[import-untyped]
23
from common_library.users_enums import UserStatus
34
from models_library.emails import LowerCaseEmailStr
45
from models_library.users import UserID
@@ -11,12 +12,30 @@
1112

1213

1314
class UsersRepository(BaseRepository):
15+
@cached(
16+
ttl=120,
17+
key_builder=lambda *_args, **kwargs: f"user_email:{kwargs['user_id']}",
18+
cache=Cache.MEMORY,
19+
namespace=__name__,
20+
noself=True,
21+
)
1422
async def get_active_user_email(
1523
self,
1624
connection: AsyncConnection | None = None,
1725
*,
1826
user_id: UserID,
1927
) -> LowerCaseEmailStr | None:
28+
"""Retrieves the email address of an active user.
29+
30+
Arguments:
31+
user_id -- The ID of the user whose email is to be retrieved.
32+
33+
Returns:
34+
The email address of the user if found, otherwise None.
35+
36+
WARNING: Cached for 120s TTL - email changes will not be seen for 2 minutes.
37+
NOTE: to disable caching set AIOCACHE_DISABLE=1
38+
"""
2039
async with pass_or_acquire_connection(self.db_engine, connection) as conn:
2140
email = await conn.scalar(
2241
sa.select(users.c.email).where(

services/api-server/tests/unit/_with_db/test_repository_api_keys.py

Lines changed: 136 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
# pylint: disable=unused-variable
44
# pylint: disable=too-many-arguments
55

6+
import time
67
from collections.abc import AsyncGenerator, AsyncIterator, Callable
78

89
import pytest
10+
from aiocache import Cache
911
from asgi_lifespan import LifespanManager
1012
from fastapi import FastAPI
1113
from fastapi.security import HTTPBasicCredentials
1214
from models_library.api_schemas_api_server.api_keys import ApiKeyInDB
1315
from pydantic import PositiveInt
16+
from pytest_mock import MockerFixture
1417
from simcore_service_api_server.api.dependencies.authentication import (
1518
get_current_identity,
1619
)
@@ -54,61 +57,156 @@ def users_repo(
5457
return UsersRepository(db_engine=async_engine)
5558

5659

57-
async def test_get_user_with_valid_credentials(
60+
@pytest.fixture
61+
async def api_key_in_db(
5862
create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]],
63+
) -> ApiKeyInDB:
64+
"""Creates a single API key in DB for testing purposes"""
65+
return await anext(create_fake_api_keys(1))
66+
67+
68+
async def test_get_user_with_valid_credentials(
69+
api_key_in_db: ApiKeyInDB,
5970
api_key_repo: ApiKeysRepository,
6071
):
72+
# Act
73+
result = await api_key_repo.get_user(
74+
api_key=api_key_in_db.api_key, api_secret=api_key_in_db.api_secret
75+
)
6176

62-
# Generate a fake API key
63-
async for api_key_in_db in create_fake_api_keys(1):
64-
# Act
65-
result = await api_key_repo.get_user(
66-
api_key=api_key_in_db.api_key, api_secret=api_key_in_db.api_secret
67-
)
68-
69-
# Assert
70-
assert result is not None
71-
assert result.user_id == api_key_in_db.user_id
72-
assert result.product_name == api_key_in_db.product_name
73-
break
77+
# Assert
78+
assert result is not None
79+
assert result.user_id == api_key_in_db.user_id
80+
assert result.product_name == api_key_in_db.product_name
7481

7582

7683
async def test_get_user_with_invalid_credentials(
84+
api_key_in_db: ApiKeyInDB,
7785
api_key_repo: ApiKeysRepository,
78-
create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]],
7986
):
8087

8188
# Generate a fake API key
82-
async for api_key_in_db in create_fake_api_keys(1):
83-
# Act - use wrong secret
84-
result = await api_key_repo.get_user(
85-
api_key=api_key_in_db.api_key, api_secret="wrong_secret"
86-
)
8789

88-
# Assert
89-
assert result is None
90-
break
90+
# Act - use wrong secret
91+
result = await api_key_repo.get_user(
92+
api_key=api_key_in_db.api_key, api_secret="wrong_secret"
93+
)
94+
95+
# Assert
96+
assert result is None
9197

9298

9399
async def test_rest_dependency_authentication(
100+
api_key_in_db: ApiKeyInDB,
94101
api_key_repo: ApiKeysRepository,
95102
users_repo: UsersRepository,
96-
create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]],
97103
):
98104

99105
# Generate a fake API key
100-
async for api_key_in_db in create_fake_api_keys(1):
101-
# Act
102-
result = await get_current_identity(
103-
apikeys_repo=api_key_repo,
104-
users_repo=users_repo,
105-
credentials=HTTPBasicCredentials(
106-
username=api_key_in_db.api_key, password=api_key_in_db.api_secret
107-
),
108-
)
109-
110-
# Assert
111-
assert result is not None
112-
assert result.user_id == api_key_in_db.user_id
113-
assert result.product_name == api_key_in_db.product_name
114-
break
106+
# Act
107+
result = await get_current_identity(
108+
apikeys_repo=api_key_repo,
109+
users_repo=users_repo,
110+
credentials=HTTPBasicCredentials(
111+
username=api_key_in_db.api_key, password=api_key_in_db.api_secret
112+
),
113+
)
114+
115+
# Assert
116+
assert result is not None
117+
assert result.user_id == api_key_in_db.user_id
118+
assert result.product_name == api_key_in_db.product_name
119+
120+
121+
async def test_cache_effectiveness_in_rest_authentication_dependencies(
122+
api_key_in_db: ApiKeyInDB,
123+
api_key_repo: ApiKeysRepository,
124+
users_repo: UsersRepository,
125+
monkeypatch: pytest.MonkeyPatch,
126+
mocker: MockerFixture,
127+
):
128+
"""Test that caching reduces database calls and improves performance."""
129+
130+
# Generate a fake API key
131+
credentials = HTTPBasicCredentials(
132+
username=api_key_in_db.api_key, password=api_key_in_db.api_secret
133+
)
134+
135+
# Test with cache enabled (default)
136+
monkeypatch.delenv("AIOCACHE_DISABLE", raising=False)
137+
await Cache().clear() # Clear any existing cache
138+
139+
# Spy on the connection's execute method by patching AsyncConnection.execute
140+
from sqlalchemy.ext.asyncio import AsyncConnection # noqa: PLC0415
141+
142+
execute_spy = mocker.spy(AsyncConnection, "execute")
143+
144+
# First call - should hit database
145+
start_time = time.time()
146+
result1 = await get_current_identity(
147+
apikeys_repo=api_key_repo,
148+
users_repo=users_repo,
149+
credentials=credentials,
150+
)
151+
first_call_time = time.time() - start_time
152+
153+
# Second call - should use cache
154+
start_time = time.time()
155+
result2 = await get_current_identity(
156+
apikeys_repo=api_key_repo,
157+
users_repo=users_repo,
158+
credentials=credentials,
159+
)
160+
second_call_time = time.time() - start_time
161+
162+
cached_db_calls = execute_spy.call_count
163+
execute_spy.reset_mock()
164+
165+
# Test with cache disabled
166+
monkeypatch.setenv("AIOCACHE_DISABLE", "1")
167+
168+
# First call without cache
169+
start_time = time.time()
170+
result3 = await get_current_identity(
171+
apikeys_repo=api_key_repo,
172+
users_repo=users_repo,
173+
credentials=credentials,
174+
)
175+
no_cache_first_time = time.time() - start_time
176+
177+
# Second call without cache
178+
start_time = time.time()
179+
result4 = await get_current_identity(
180+
apikeys_repo=api_key_repo,
181+
users_repo=users_repo,
182+
credentials=credentials,
183+
)
184+
no_cache_second_time = time.time() - start_time
185+
186+
no_cache_db_calls = execute_spy.call_count
187+
188+
# ASSERTIONS
189+
# All results should be identical
190+
assert result1.user_id == result2.user_id == result3.user_id == result4.user_id
191+
assert (
192+
result1.product_name
193+
== result2.product_name
194+
== result3.product_name
195+
== result4.product_name
196+
)
197+
assert result1.email == result2.email == result3.email == result4.email
198+
199+
# With cache: second call should be significantly faster
200+
assert (
201+
second_call_time < first_call_time * 0.5
202+
), "Cache should make subsequent calls faster"
203+
204+
# Without cache: both calls should take similar time
205+
time_ratio = abs(no_cache_second_time - no_cache_first_time) / max(
206+
no_cache_first_time, no_cache_second_time
207+
)
208+
assert time_ratio < 0.5, "Without cache, call times should be similar"
209+
210+
# With cache: fewer total database calls (due to caching)
211+
# Without cache: more database calls (no caching)
212+
assert no_cache_db_calls > cached_db_calls, "Cache should reduce database calls"

0 commit comments

Comments
 (0)