Files
dify/api/tests/unit_tests/services/test_api_token_service.py
T
非法操作andautofix-ci[bot] 092526a504 refactor(api): migrate Service API annotation management (#43809)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-27 06:20:08 +00:00

733 lines
31 KiB
Python

from __future__ import annotations
import json
from collections.abc import Iterator
from datetime import datetime
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from redis import Redis
from sqlalchemy import Connection, ExecutionContext, event
from sqlalchemy.orm import Session
import services.api_token_service as api_token_service_module
from extensions.ext_redis import RedisClientWrapper
from models.engine import db
from models.enums import ApiTokenType
from models.model import ApiToken
from services.api_token_service import (
CACHE_KEY_PREFIX,
CACHE_NULL_TTL_SECONDS,
CACHE_TTL_SECONDS,
ApiTokenCache,
ApiTokenInvalidError,
CachedApiToken,
)
"""
Unit tests for API Token Cache module.
"""
class TestApiTokenCacheCases:
class TestApiTokenCache:
"""Test cases for ApiTokenCache class."""
def setup_method(self) -> None:
"""Setup test fixtures."""
self.mock_token = ApiToken()
self.mock_token.id = "test-token-id-123"
self.mock_token.app_id = "test-app-id-456"
self.mock_token.tenant_id = "test-tenant-id-789"
self.mock_token.type = ApiTokenType.APP
self.mock_token.token = "test-token-value-abc"
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
self.mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
def test_make_cache_key(self) -> None:
"""Test cache key generation."""
# Test with scope
key = ApiTokenCache._make_cache_key("my-token", "app")
assert key == f"{CACHE_KEY_PREFIX}:app:my-token"
# Test without scope
key = ApiTokenCache._make_cache_key("my-token", None)
assert key == f"{CACHE_KEY_PREFIX}:any:my-token"
def test_serialize_token(self) -> None:
"""Test token serialization."""
serialized = ApiTokenCache._serialize_token(self.mock_token)
data = json.loads(serialized)
assert data["id"] == "test-token-id-123"
assert data["app_id"] == "test-app-id-456"
assert data["tenant_id"] == "test-tenant-id-789"
assert data["type"] == "app"
assert data["token"] == "test-token-value-abc"
assert data["last_used_at"] == "2026-02-03T10:00:00"
assert data["created_at"] == "2026-01-01T00:00:00"
def test_serialize_token_with_nulls(self) -> None:
"""Test token serialization with None values."""
mock_token = ApiToken()
mock_token.id = "test-id"
mock_token.app_id = None
mock_token.tenant_id = None
mock_token.type = ApiTokenType.DATASET
mock_token.token = "test-token"
mock_token.last_used_at = None
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
serialized = ApiTokenCache._serialize_token(mock_token)
data = json.loads(serialized)
assert data["app_id"] is None
assert data["tenant_id"] is None
assert data["last_used_at"] is None
def test_deserialize_token(self) -> None:
"""Test token deserialization."""
cached_data = json.dumps(
{
"id": "test-id",
"app_id": "test-app",
"tenant_id": "test-tenant",
"type": "app",
"token": "test-token",
"last_used_at": "2026-02-03T10:00:00",
"created_at": "2026-01-01T00:00:00",
}
)
result = ApiTokenCache._deserialize_token(cached_data)
assert isinstance(result, CachedApiToken)
assert result.id == "test-id"
assert result.app_id == "test-app"
assert result.tenant_id == "test-tenant"
assert result.type == "app"
assert result.token == "test-token"
assert result.last_used_at == datetime(2026, 2, 3, 10, 0, 0)
assert result.created_at == datetime(2026, 1, 1, 0, 0, 0)
def test_deserialize_null_token(self) -> None:
"""Test deserialization of null token (cached miss)."""
result = ApiTokenCache._deserialize_token("null")
assert result is None
def test_deserialize_invalid_json(self) -> None:
"""Test deserialization with invalid JSON."""
result = ApiTokenCache._deserialize_token("invalid-json{")
assert result is None
@patch("services.api_token_service.redis_client")
def test_get_cache_hit(self, mock_redis: MagicMock) -> None:
"""Test cache hit scenario."""
cached_data = json.dumps(
{
"id": "test-id",
"app_id": "test-app",
"tenant_id": "test-tenant",
"type": "app",
"token": "test-token",
"last_used_at": "2026-02-03T10:00:00",
"created_at": "2026-01-01T00:00:00",
}
).encode("utf-8")
mock_redis.get.return_value = cached_data
result = ApiTokenCache.get("test-token", "app")
assert result is not None
assert isinstance(result, CachedApiToken)
assert result.app_id == "test-app"
mock_redis.get.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("services.api_token_service.redis_client")
def test_get_cache_miss(self, mock_redis: MagicMock) -> None:
"""Test cache miss scenario."""
mock_redis.get.return_value = None
result = ApiTokenCache.get("test-token", "app")
assert result is None
mock_redis.get.assert_called_once()
@patch("services.api_token_service.redis_client")
def test_set_valid_token(self, mock_redis: MagicMock) -> None:
"""Test setting a valid token in cache."""
result = ApiTokenCache.set("test-token", "app", self.mock_token)
assert result is True
mock_redis.setex.assert_called_once()
args = mock_redis.setex.call_args[0]
assert args[0] == f"{CACHE_KEY_PREFIX}:app:test-token"
assert args[1] == CACHE_TTL_SECONDS
@patch("services.api_token_service.redis_client")
def test_set_null_token(self, mock_redis: MagicMock) -> None:
"""Test setting a null token (cache penetration prevention)."""
result = ApiTokenCache.set("invalid-token", "app", None)
assert result is True
mock_redis.setex.assert_called_once()
args = mock_redis.setex.call_args[0]
assert args[0] == f"{CACHE_KEY_PREFIX}:app:invalid-token"
assert args[1] == CACHE_NULL_TTL_SECONDS
assert args[2] == b"null"
@patch("services.api_token_service.redis_client")
def test_delete_with_scope(self, mock_redis: MagicMock) -> None:
"""Test deleting token cache with specific scope."""
result = ApiTokenCache.delete("test-token", "app")
assert result is True
mock_redis.delete.assert_called_once_with(f"{CACHE_KEY_PREFIX}:app:test-token")
@patch("services.api_token_service.redis_client")
def test_delete_without_scope(self, mock_redis: MagicMock) -> None:
"""Test deleting token cache without scope (delete all)."""
# Mock scan_iter to return an iterator of keys
mock_redis.scan_iter.return_value = iter(
[
b"api_token:app:test-token",
b"api_token:dataset:test-token",
]
)
result = ApiTokenCache.delete("test-token", None)
assert result is True
# Verify scan_iter was called with the correct pattern
mock_redis.scan_iter.assert_called_once()
call_args = mock_redis.scan_iter.call_args
assert call_args[1]["match"] == f"{CACHE_KEY_PREFIX}:*:test-token"
# Verify delete was called with all matched keys
mock_redis.delete.assert_called_once_with(
b"api_token:app:test-token",
b"api_token:dataset:test-token",
)
@patch("services.api_token_service.redis_client")
def test_redis_fallback_on_exception(self, mock_redis: MagicMock) -> None:
"""Test Redis fallback when Redis is unavailable."""
from redis import RedisError
mock_redis.get.side_effect = RedisError("Connection failed")
result = ApiTokenCache.get("test-token", "app")
# Should return None (fallback) instead of raising exception
assert result is None
class TestApiTokenCacheIntegration:
"""Integration test scenarios."""
@patch("services.api_token_service.redis_client")
def test_full_cache_lifecycle(self, mock_redis: MagicMock) -> None:
"""Test complete cache lifecycle: set -> get -> delete."""
# Use the persisted model's fields for the cache lifecycle.
mock_token = ApiToken()
mock_token.id = "id-123"
mock_token.app_id = "app-456"
mock_token.tenant_id = "tenant-789"
mock_token.type = ApiTokenType.APP
mock_token.token = "token-abc"
mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
# 1. Set token in cache
ApiTokenCache.set("token-abc", "app", mock_token)
assert mock_redis.setex.called
# 2. Simulate cache hit
cached_data = ApiTokenCache._serialize_token(mock_token)
mock_redis.get.return_value = cached_data # bytes from model_dump_json().encode()
retrieved = ApiTokenCache.get("token-abc", "app")
assert retrieved is not None
assert isinstance(retrieved, CachedApiToken)
# 3. Delete from cache
ApiTokenCache.delete("token-abc", "app")
assert mock_redis.delete.called
@patch("services.api_token_service.redis_client")
def test_cache_penetration_prevention(self, mock_redis: MagicMock) -> None:
"""Test that non-existent tokens are cached as null."""
# Set null token (cache miss)
ApiTokenCache.set("non-existent-token", "app", None)
args = mock_redis.setex.call_args[0]
assert args[2] == b"null"
assert args[1] == CACHE_NULL_TTL_SECONDS # Shorter TTL for null values
"""Unit tests for API token caching and SQLite-backed token lookup."""
def _api_token(
*,
token_id: str = "id-123",
app_id: str = "app-123",
tenant_id: str = "tenant-123",
token: str = "token-123",
) -> ApiToken:
"""Create a mapped API token for cache and single-flight behavior tests."""
return ApiToken(
id=token_id,
app_id=app_id,
tenant_id=tenant_id,
type=ApiTokenType.APP,
token=token,
last_used_at=None,
)
def _assert_lock_released(commands: list[tuple[object, ...]]) -> None:
assert len(commands) == 2
acquire, release = commands
assert acquire[:2] == ("SET", "api_token_query_lock:app:token-123")
assert acquire[3:] == ("NX", "PX", 10000)
assert release[0] == "EVALSHA"
assert release[2:] == (1, acquire[1], acquire[2]) # Release uses the acquired ownership token.
class TestApiTokenServiceCases:
@staticmethod
@pytest.fixture
def api_token_db() -> Iterator[Session]:
"""Provide the production database extension with an isolated SQLite token table."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
db.init_app(app)
with app.app_context():
ApiToken.__table__.create(db.engine)
with Session(db.engine, expire_on_commit=False) as session:
yield session
class TestQueryTokenFromDb:
def test_should_return_api_token_and_cache_when_token_exists(self, api_token_db: Session) -> None:
tenant_id = str(uuid4())
app_id = str(uuid4())
token_value = f"app-test-{uuid4()}"
api_token = ApiToken()
api_token.id = str(uuid4())
api_token.app_id = app_id
api_token.tenant_id = tenant_id
api_token.type = "app"
api_token.token = token_value
api_token_db.add(api_token)
api_token_db.commit()
with (
patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set,
patch.object(api_token_service_module, "record_token_usage") as mock_record_usage,
):
result = api_token_service_module.query_token_from_db(token_value, "app")
assert result.id == api_token.id
assert result.token == token_value
mock_cache_set.assert_called_once()
mock_record_usage.assert_called_once_with(token_value, "app")
def test_should_cache_null_and_raise_domain_error_when_token_not_found(
self, api_token_db: Session, single_flight_redis: tuple[Redis, list[tuple[object, ...]]]
) -> None:
_client, commands = single_flight_redis
token_value = f"missing-{uuid4()}"
with pytest.raises(ApiTokenInvalidError, match="Access token is invalid"):
api_token_service_module.query_token_from_db(token_value, "app")
assert commands == [("SETEX", f"api_token:app:{token_value}", CACHE_NULL_TTL_SECONDS, b"null")]
class TestRecordTokenUsage:
def test_should_write_active_key_with_iso_timestamp_and_ttl(self):
auth_token = "token-123"
scope = "dataset"
fixed_time = datetime(2026, 2, 24, 12, 0, 0)
expected_key = ApiTokenCache.make_active_key(auth_token, scope)
with (
patch.object(api_token_service_module, "naive_utc_now", return_value=fixed_time),
patch.object(api_token_service_module, "redis_client") as mock_redis,
):
api_token_service_module.record_token_usage(auth_token, scope)
mock_redis.set.assert_called_once_with(expected_key, fixed_time.isoformat(), ex=3600)
def test_should_not_raise_when_redis_write_fails(self):
with patch.object(api_token_service_module, "redis_client") as mock_redis:
mock_redis.set.side_effect = Exception("redis unavailable")
api_token_service_module.record_token_usage("token-123", "app")
class TestGetEffectiveTokenLastUsedAt:
def test_should_surface_pending_usage_before_the_batch_flush(self):
pending = datetime(2026, 8, 11, 12, 30, 0)
with patch.object(api_token_service_module, "redis_client") as mock_redis:
mock_redis.get.return_value = pending.isoformat().encode()
result = api_token_service_module.get_effective_token_last_used_at(
"token-123",
"dataset",
None,
)
assert result == pending
mock_redis.get.assert_called_once_with(ApiTokenCache.make_active_key("token-123", "dataset"))
def test_should_keep_a_newer_persisted_usage_timestamp(self):
pending = datetime(2026, 8, 11, 12, 30, 0)
persisted = datetime(2026, 8, 11, 12, 31, 0)
with patch.object(api_token_service_module, "redis_client") as mock_redis:
mock_redis.get.return_value = pending.isoformat()
result = api_token_service_module.get_effective_token_last_used_at(
"token-123",
"dataset",
persisted,
)
assert result == persisted
@pytest.mark.parametrize("value", [None, b"not-a-timestamp"])
def test_should_fall_back_to_the_persisted_timestamp(self, value):
persisted = datetime(2026, 8, 11, 12, 30, 0)
with patch.object(api_token_service_module, "redis_client") as mock_redis:
mock_redis.get.return_value = value
result = api_token_service_module.get_effective_token_last_used_at(
"token-123",
"dataset",
persisted,
)
assert result == persisted
@staticmethod
@pytest.fixture
def single_flight_redis(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[Redis, list[tuple[object, ...]]]]:
"""Use the real Redis client, wrapper and lock; isolate only wire commands."""
commands: list[tuple[object, ...]] = []
def execute(*args: object, **_kwargs: object) -> object:
commands.append(args)
if args[0] == "GET":
return None
assert args[0] in {"SET", "SETEX", "EVALSHA"}
return True
with Redis() as client:
monkeypatch.setattr(client, "execute_command", execute)
wrapper = RedisClientWrapper()
wrapper.initialize(client)
monkeypatch.setattr(api_token_service_module, "redis_client", wrapper)
yield client, commands
class TestFetchTokenWithSingleFlight:
def test_should_return_cached_token_when_lock_acquired_and_cache_filled(
self, single_flight_redis: tuple[Redis, list[tuple[object, ...]]]
) -> None:
auth_token = "token-123"
scope = "app"
cached_token = CachedApiToken(
id="id-1",
app_id="app-1",
tenant_id="tenant-1",
type="app",
token=auth_token,
last_used_at=None,
created_at=None,
)
_client, commands = single_flight_redis
with (
patch.object(api_token_service_module.ApiTokenCache, "get", return_value=cached_token),
patch.object(api_token_service_module, "query_token_from_db") as mock_query_db,
):
result = api_token_service_module.fetch_token_with_single_flight(auth_token, scope)
assert result == cached_token
_assert_lock_released(commands)
mock_query_db.assert_not_called()
def test_should_query_db_when_lock_acquired_and_cache_missed(
self, single_flight_redis: tuple[Redis, list[tuple[object, ...]]]
) -> None:
auth_token = "token-123"
scope = "app"
db_token = _api_token()
_client, commands = single_flight_redis
with (
patch.object(api_token_service_module.ApiTokenCache, "get", return_value=None),
patch.object(api_token_service_module, "query_token_from_db", return_value=db_token) as mock_query_db,
):
result = api_token_service_module.fetch_token_with_single_flight(auth_token, scope)
assert result == db_token
mock_query_db.assert_called_once_with(auth_token, scope)
_assert_lock_released(commands)
def test_should_query_db_directly_when_lock_not_acquired(
self, single_flight_redis: tuple[Redis, list[tuple[object, ...]]], monkeypatch: pytest.MonkeyPatch
) -> None:
auth_token = "token-123"
scope = "app"
db_token = _api_token()
client, commands = single_flight_redis
def contended(*args: object, **_kwargs: object) -> bool:
commands.append(args)
assert args[0] == "SET"
return False
monkeypatch.setattr(client, "execute_command", contended)
with (
patch.object(api_token_service_module.ApiTokenCache, "get") as mock_cache_get,
patch.object(api_token_service_module, "query_token_from_db", return_value=db_token) as mock_query_db,
):
result = api_token_service_module.fetch_token_with_single_flight(auth_token, scope)
assert result == db_token
mock_cache_get.assert_not_called()
mock_query_db.assert_called_once_with(auth_token, scope)
assert commands
assert all(command[0] == "SET" for command in commands)
@pytest.mark.parametrize("stored_scope", [None, ApiTokenType.DATASET])
def test_invalid_token_should_release_lock_without_fallback_or_usage(
self,
api_token_db: Session,
single_flight_redis: tuple[Redis, list[tuple[object, ...]]],
stored_scope: ApiTokenType | None,
) -> None:
auth_token = "token-123"
_client, commands = single_flight_redis
if stored_scope is not None:
token = _api_token(token_id=str(uuid4()), app_id=str(uuid4()), tenant_id=str(uuid4()), token=auth_token)
token.type = stored_scope
api_token_db.add(token)
api_token_db.commit()
statements: list[str] = []
def record_statement(
_connection: Connection,
_cursor: object,
statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
statements.append(statement)
engine = db.engine
event.listen(engine, "before_cursor_execute", record_statement)
try:
with pytest.raises(ApiTokenInvalidError, match="Access token is invalid"):
api_token_service_module.fetch_token_with_single_flight(auth_token, "app")
finally:
event.remove(engine, "before_cursor_execute", record_statement)
assert len(statements) == 1 # An invalid credential must not trigger the Redis failure DB fallback.
assert "FROM api_tokens" in statements[0]
assert [command[0] for command in commands] == ["SET", "GET", "SETEX", "EVALSHA"]
assert commands[2] == ("SETEX", f"api_token:app:{auth_token}", CACHE_NULL_TTL_SECONDS, b"null")
_assert_lock_released([commands[0], commands[-1]])
active_key = ApiTokenCache.make_active_key(auth_token, "app")
assert all(active_key not in command for command in commands)
def test_should_fallback_to_db_query_when_lock_raises_exception(
self, single_flight_redis: tuple[Redis, list[tuple[object, ...]]], monkeypatch: pytest.MonkeyPatch
) -> None:
auth_token = "token-123"
scope = "app"
db_token = _api_token()
client, commands = single_flight_redis
def unavailable(*args: object, **_kwargs: object) -> bool:
commands.append(args)
raise RuntimeError("redis lock error")
monkeypatch.setattr(client, "execute_command", unavailable)
with (
patch.object(api_token_service_module, "query_token_from_db", return_value=db_token) as mock_query_db,
):
result = api_token_service_module.fetch_token_with_single_flight(auth_token, scope)
assert result == db_token
mock_query_db.assert_called_once_with(auth_token, scope)
assert len(commands) == 1
assert commands[0][:2] == ("SET", "api_token_query_lock:app:token-123")
class TestApiTokenCacheTenantBranches:
@patch("services.api_token_service.redis_client")
def test_delete_with_scope_should_remove_from_tenant_index_when_tenant_found(self, mock_redis):
token = "token-123"
scope = "app"
cache_key = ApiTokenCache._make_cache_key(token, scope)
cached_token = CachedApiToken(
id="id-1",
app_id="app-1",
tenant_id="tenant-1",
type="app",
token=token,
last_used_at=None,
created_at=None,
)
mock_redis.get.return_value = cached_token.model_dump_json().encode("utf-8")
with patch.object(ApiTokenCache, "_remove_from_tenant_index") as mock_remove_index:
result = ApiTokenCache.delete(token, scope)
assert result is True
mock_redis.delete.assert_called_once_with(cache_key)
mock_remove_index.assert_called_once_with("tenant-1", cache_key)
@patch("services.api_token_service.redis_client")
def test_invalidate_by_tenant_should_delete_all_indexed_cache_keys(self, mock_redis):
tenant_id = "tenant-1"
index_key = ApiTokenCache._make_tenant_index_key(tenant_id)
mock_redis.smembers.return_value = {
b"api_token:app:token-1",
b"api_token:any:token-2",
}
result = ApiTokenCache.invalidate_by_tenant(tenant_id)
assert result is True
mock_redis.smembers.assert_called_once_with(index_key)
mock_redis.delete.assert_any_call("api_token:app:token-1")
mock_redis.delete.assert_any_call("api_token:any:token-2")
mock_redis.delete.assert_any_call(index_key)
class TestApiTokenCacheCoreBranches:
def test_cached_api_token_repr_should_include_id_and_type(self):
token = CachedApiToken(
id="id-123",
app_id="app-123",
tenant_id="tenant-123",
type="app",
token="token-123",
last_used_at=None,
created_at=None,
)
assert repr(token) == "<CachedApiToken id=id-123 type=app>"
def test_serialize_token_should_handle_cached_api_token_instances(self):
token = CachedApiToken(
id="id-123",
app_id="app-123",
tenant_id="tenant-123",
type="app",
token="token-123",
last_used_at=None,
created_at=None,
)
serialized = ApiTokenCache._serialize_token(token)
assert isinstance(serialized, bytes)
assert b'"id":"id-123"' in serialized
def test_deserialize_token_should_return_none_for_null_markers(self):
assert ApiTokenCache._deserialize_token("null") is None
assert ApiTokenCache._deserialize_token(b"null") is None
def test_deserialize_token_should_return_none_for_invalid_payload(self):
assert ApiTokenCache._deserialize_token("not-json") is None
@patch("services.api_token_service.redis_client")
def test_get_should_return_none_on_cache_miss(self, mock_redis):
mock_redis.get.return_value = None
result = ApiTokenCache.get("token-123", "app")
assert result is None
@patch("services.api_token_service.redis_client")
def test_get_should_deserialize_cached_payload_on_cache_hit(self, mock_redis):
token = CachedApiToken(
id="id-123",
app_id="app-123",
tenant_id="tenant-123",
type="app",
token="token-123",
last_used_at=None,
created_at=None,
)
mock_redis.get.return_value = token.model_dump_json().encode("utf-8")
result = ApiTokenCache.get("token-123", "app")
assert isinstance(result, CachedApiToken)
assert result.id == "id-123"
@patch("services.api_token_service.redis_client")
def test_add_to_tenant_index_should_skip_when_tenant_id_missing(self, mock_redis):
ApiTokenCache._add_to_tenant_index(None, "api_token:app:token-123")
mock_redis.sadd.assert_not_called()
@patch("services.api_token_service.redis_client")
def test_add_to_tenant_index_should_swallow_index_update_errors(self, mock_redis):
mock_redis.sadd.side_effect = Exception("redis down")
ApiTokenCache._add_to_tenant_index("tenant-123", "api_token:app:token-123")
mock_redis.sadd.assert_called_once()
@patch("services.api_token_service.redis_client")
def test_remove_from_tenant_index_should_skip_when_tenant_id_missing(self, mock_redis):
ApiTokenCache._remove_from_tenant_index(None, "api_token:app:token-123")
mock_redis.srem.assert_not_called()
@patch("services.api_token_service.redis_client")
def test_remove_from_tenant_index_should_swallow_redis_errors(self, mock_redis):
mock_redis.srem.side_effect = Exception("redis down")
ApiTokenCache._remove_from_tenant_index("tenant-123", "api_token:app:token-123")
mock_redis.srem.assert_called_once()
@patch("services.api_token_service.redis_client")
def test_set_should_return_false_when_cache_write_raises_exception(self, mock_redis):
mock_redis.setex.side_effect = Exception("redis write failed")
api_token = _api_token()
result = ApiTokenCache.set("token-123", "app", api_token)
assert result is False
@patch("services.api_token_service.redis_client")
def test_delete_without_scope_should_return_false_when_scan_fails(self, mock_redis):
mock_redis.scan_iter.side_effect = Exception("scan failed")
result = ApiTokenCache.delete("token-123", None)
assert result is False
@patch("services.api_token_service.redis_client")
def test_delete_with_scope_should_continue_when_tenant_lookup_raises(self, mock_redis):
token = "token-123"
scope = "app"
cache_key = ApiTokenCache._make_cache_key(token, scope)
mock_redis.get.side_effect = Exception("get failed")
result = ApiTokenCache.delete(token, scope)
assert result is True
mock_redis.delete.assert_called_once_with(cache_key)
@patch("services.api_token_service.redis_client")
def test_delete_with_scope_should_return_false_when_delete_raises(self, mock_redis):
mock_redis.get.return_value = None
mock_redis.delete.side_effect = Exception("delete failed")
result = ApiTokenCache.delete("token-123", "app")
assert result is False
@patch("services.api_token_service.redis_client")
def test_invalidate_by_tenant_should_return_true_when_index_not_found(self, mock_redis):
mock_redis.smembers.return_value = set()
result = ApiTokenCache.invalidate_by_tenant("tenant-123")
assert result is True
mock_redis.delete.assert_not_called()
@patch("services.api_token_service.redis_client")
def test_invalidate_by_tenant_should_return_false_when_redis_raises(self, mock_redis):
mock_redis.smembers.side_effect = Exception("redis failed")
result = ApiTokenCache.invalidate_by_tenant("tenant-123")
assert result is False