3921a657e8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m27s
CI/CD Pipeline / Unit Tests (push) Failing after 8m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
388 lines
13 KiB
Python
Executable File
388 lines
13 KiB
Python
Executable File
"""Redis Session Store 单元测试"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from packages.adapters.redis.session_store import (
|
|
NoopSessionStore,
|
|
RedisConfig,
|
|
SessionStore,
|
|
get_session_store,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_redis():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def session_store(mock_redis):
|
|
return SessionStore(redis_client=mock_redis)
|
|
|
|
|
|
class TestRedisConfig:
|
|
"""RedisConfig 默认值测试"""
|
|
|
|
def test_default_values(self):
|
|
"""默认配置"""
|
|
cfg = RedisConfig()
|
|
assert cfg.HOST == "localhost"
|
|
assert cfg.PORT == 6379
|
|
assert cfg.DB == 0
|
|
assert cfg.PASSWORD is None
|
|
assert cfg.DECODE_RESPONSES is True
|
|
|
|
|
|
class TestNoopSessionStore:
|
|
"""NoopSessionStore 测试"""
|
|
|
|
def test_save_session_returns_false(self):
|
|
"""保存返回 False"""
|
|
store = NoopSessionStore()
|
|
assert store.save_session() is False
|
|
|
|
def test_get_session_returns_none(self):
|
|
"""获取返回 None"""
|
|
store = NoopSessionStore()
|
|
assert store.get_session("sess_123") is None
|
|
|
|
def test_get_session_by_refresh_token_returns_none(self):
|
|
"""通过 refresh_token 获取返回 None"""
|
|
store = NoopSessionStore()
|
|
assert store.get_session_by_refresh_token("tok_123") is None
|
|
|
|
def test_get_refresh_token_returns_none(self):
|
|
"""获取 refresh_token 返回 None"""
|
|
store = NoopSessionStore()
|
|
assert store.get_refresh_token("sess_123") is None
|
|
|
|
def test_update_last_active_returns_false(self):
|
|
"""更新活跃时间返回 False"""
|
|
store = NoopSessionStore()
|
|
assert store.update_last_active("sess_123") is False
|
|
|
|
def test_delete_session_returns_false(self):
|
|
"""删除返回 False"""
|
|
store = NoopSessionStore()
|
|
assert store.delete_session("sess_123") is False
|
|
|
|
def test_get_user_sessions_returns_empty(self):
|
|
"""用户 session 列表为空"""
|
|
store = NoopSessionStore()
|
|
assert store.get_user_sessions("user_1") == []
|
|
|
|
def test_delete_all_user_sessions_returns_zero(self):
|
|
"""删除所有返回 0"""
|
|
store = NoopSessionStore()
|
|
assert store.delete_all_user_sessions("user_1") == 0
|
|
|
|
def test_session_exists_returns_false(self):
|
|
"""存在性检查返回 False"""
|
|
store = NoopSessionStore()
|
|
assert store.session_exists("sess_123") is False
|
|
|
|
|
|
class TestSessionStoreInit:
|
|
"""SessionStore 初始化测试"""
|
|
|
|
def test_init_with_redis_client(self, mock_redis):
|
|
"""使用注入的 redis client"""
|
|
store = SessionStore(redis_client=mock_redis)
|
|
assert store.redis is mock_redis
|
|
|
|
def test_init_with_config(self):
|
|
"""使用配置创建 redis client"""
|
|
cfg = RedisConfig()
|
|
cfg.HOST = "redis.example.com"
|
|
cfg.PORT = 6380
|
|
|
|
with patch("packages.adapters.redis.session_store.redis.Redis") as mock_redis_cls:
|
|
store = SessionStore(config=cfg)
|
|
mock_redis_cls.assert_called_once_with(
|
|
host="redis.example.com",
|
|
port=6380,
|
|
db=0,
|
|
password=None,
|
|
decode_responses=True,
|
|
)
|
|
|
|
|
|
class TestSessionStoreSave:
|
|
"""save_session 测试"""
|
|
|
|
def test_save_success(self, session_store, mock_redis):
|
|
"""保存成功"""
|
|
result = session_store.save_session(
|
|
session_id="sess_001",
|
|
user_id="user_001",
|
|
refresh_token="refresh_abc",
|
|
device_info="Chrome/Windows",
|
|
ip_address="192.168.1.1",
|
|
)
|
|
|
|
assert result is True
|
|
# 验证 session 数据保存
|
|
mock_redis.setex.assert_any_call(
|
|
"session:sess_001", 30 * 24 * 60 * 60, mock_redis.setex.call_args_list[0][0][2]
|
|
)
|
|
# 验证 refresh_token 保存
|
|
mock_redis.setex.assert_any_call("refresh_token:sess_001", 30 * 24 * 60 * 60, "refresh_abc")
|
|
# 验证反向映射
|
|
mock_redis.setex.assert_any_call("refresh_token_map:refresh_abc", 30 * 24 * 60 * 60, "sess_001")
|
|
# 验证用户集合
|
|
mock_redis.sadd.assert_called_once_with("user_sessions:user_001", "sess_001")
|
|
mock_redis.expire.assert_called_once()
|
|
|
|
def test_save_custom_expiry(self, session_store, mock_redis):
|
|
"""自定义过期时间"""
|
|
session_store.save_session(
|
|
session_id="sess_001",
|
|
user_id="user_001",
|
|
refresh_token="tok",
|
|
device_info="d",
|
|
ip_address="1.1.1.1",
|
|
expires_in_seconds=3600,
|
|
)
|
|
|
|
# 验证 TTL 为 3600
|
|
call_args = mock_redis.setex.call_args_list[0]
|
|
assert call_args[0][1] == 3600
|
|
|
|
def test_save_returns_false_on_error(self, session_store, mock_redis):
|
|
"""Redis 异常时返回 False"""
|
|
mock_redis.setex.side_effect = Exception("Connection error")
|
|
|
|
result = session_store.save_session(
|
|
session_id="s1",
|
|
user_id="u1",
|
|
refresh_token="t1",
|
|
device_info="d",
|
|
ip_address="1.1.1.1",
|
|
)
|
|
assert result is False
|
|
|
|
|
|
class TestSessionStoreGet:
|
|
"""get_session 测试"""
|
|
|
|
def test_get_existing_session(self, session_store, mock_redis):
|
|
"""获取存在的 session"""
|
|
import json
|
|
|
|
session_data = {
|
|
"session_id": "sess_001",
|
|
"user_id": "user_001",
|
|
"device_info": "Chrome",
|
|
"ip_address": "1.1.1.1",
|
|
}
|
|
mock_redis.get.return_value = json.dumps(session_data)
|
|
|
|
result = session_store.get_session("sess_001")
|
|
assert result is not None
|
|
assert result["user_id"] == "user_001"
|
|
assert result["session_id"] == "sess_001"
|
|
mock_redis.get.assert_called_once_with("session:sess_001")
|
|
|
|
def test_get_nonexistent_session(self, session_store, mock_redis):
|
|
"""获取不存在的 session 返回 None"""
|
|
mock_redis.get.return_value = None
|
|
|
|
result = session_store.get_session("nonexistent")
|
|
assert result is None
|
|
|
|
def test_get_returns_none_on_error(self, session_store, mock_redis):
|
|
"""Redis 异常返回 None"""
|
|
mock_redis.get.side_effect = Exception("error")
|
|
result = session_store.get_session("s1")
|
|
assert result is None
|
|
|
|
|
|
class TestSessionStoreGetByRefreshToken:
|
|
"""get_session_by_refresh_token 测试"""
|
|
|
|
def test_get_by_refresh_token_success(self, session_store, mock_redis):
|
|
"""通过 refresh_token 获取成功"""
|
|
import json
|
|
|
|
session_data = {"session_id": "sess_001", "user_id": "user_001"}
|
|
|
|
# 第一次调用(反向映射)返回 session_id
|
|
# 第二次调用(session数据)返回 json
|
|
mock_redis.get.side_effect = ["sess_001", json.dumps(session_data)]
|
|
|
|
result = session_store.get_session_by_refresh_token("refresh_abc")
|
|
assert result is not None
|
|
assert result["session_id"] == "sess_001"
|
|
|
|
def test_get_by_refresh_token_not_found(self, session_store, mock_redis):
|
|
"""refresh_token 不存在返回 None"""
|
|
mock_redis.get.return_value = None
|
|
|
|
result = session_store.get_session_by_refresh_token("invalid")
|
|
assert result is None
|
|
|
|
|
|
class TestSessionStoreGetRefreshToken:
|
|
"""get_refresh_token 测试"""
|
|
|
|
def test_get_refresh_token_success(self, session_store, mock_redis):
|
|
"""获取 refresh_token 成功"""
|
|
mock_redis.get.return_value = "refresh_abc"
|
|
|
|
result = session_store.get_refresh_token("sess_001")
|
|
assert result == "refresh_abc"
|
|
mock_redis.get.assert_called_once_with("refresh_token:sess_001")
|
|
|
|
def test_get_refresh_token_not_found(self, session_store, mock_redis):
|
|
"""不存在返回 None"""
|
|
mock_redis.get.return_value = None
|
|
assert session_store.get_refresh_token("sess_001") is None
|
|
|
|
|
|
class TestSessionStoreUpdateLastActive:
|
|
"""update_last_active 测试"""
|
|
|
|
def test_update_success(self, session_store, mock_redis):
|
|
"""更新成功"""
|
|
import json
|
|
|
|
session_data = {
|
|
"session_id": "sess_001",
|
|
"user_id": "user_001",
|
|
"last_active_at": "2024-01-01T00:00:00+00:00",
|
|
}
|
|
mock_redis.get.return_value = json.dumps(session_data)
|
|
mock_redis.ttl.return_value = 1800
|
|
|
|
result = session_store.update_last_active("sess_001")
|
|
assert result is True
|
|
mock_redis.setex.assert_called_once()
|
|
|
|
def test_update_session_not_found(self, session_store, mock_redis):
|
|
"""session 不存在返回 False"""
|
|
mock_redis.get.return_value = None
|
|
result = session_store.update_last_active("nonexistent")
|
|
assert result is False
|
|
|
|
def test_update_expired_session(self, session_store, mock_redis):
|
|
"""已过期的 session 返回 False"""
|
|
import json
|
|
|
|
session_data = {"session_id": "s1", "user_id": "u1"}
|
|
mock_redis.get.return_value = json.dumps(session_data)
|
|
mock_redis.ttl.return_value = -2 # 已过期
|
|
|
|
result = session_store.update_last_active("s1")
|
|
assert result is False
|
|
|
|
|
|
class TestSessionStoreDelete:
|
|
"""delete_session 测试"""
|
|
|
|
def test_delete_success(self, session_store, mock_redis):
|
|
"""删除成功"""
|
|
import json
|
|
|
|
session_data = {"session_id": "sess_001", "user_id": "user_001"}
|
|
mock_redis.get.side_effect = [
|
|
json.dumps(session_data), # get_session
|
|
"refresh_abc", # get refresh_token
|
|
]
|
|
|
|
result = session_store.delete_session("sess_001")
|
|
assert result is True
|
|
# 删除 session、refresh_token、反向映射、从用户集合移除
|
|
assert mock_redis.delete.call_count >= 3
|
|
mock_redis.srem.assert_called_once_with("user_sessions:user_001", "sess_001")
|
|
|
|
def test_delete_not_found(self, session_store, mock_redis):
|
|
"""删除不存在的 session 返回 False"""
|
|
mock_redis.get.return_value = None
|
|
result = session_store.delete_session("nonexistent")
|
|
assert result is False
|
|
|
|
|
|
class TestSessionStoreUserSessions:
|
|
"""用户 Session 列表测试"""
|
|
|
|
def test_get_user_sessions(self, session_store, mock_redis):
|
|
"""获取用户所有 session"""
|
|
import json
|
|
|
|
mock_redis.smembers.return_value = {"sess_001", "sess_002"}
|
|
|
|
session1 = json.dumps({"session_id": "sess_001", "user_id": "u1"})
|
|
session2 = json.dumps({"session_id": "sess_002", "user_id": "u1"})
|
|
mock_redis.get.side_effect = [session1, session2]
|
|
|
|
result = session_store.get_user_sessions("user_001")
|
|
assert len(result) == 2
|
|
|
|
def test_get_user_sessions_empty(self, session_store, mock_redis):
|
|
"""用户无 session"""
|
|
mock_redis.smembers.return_value = set()
|
|
result = session_store.get_user_sessions("user_001")
|
|
assert result == []
|
|
|
|
def test_delete_all_user_sessions(self, session_store, mock_redis):
|
|
"""删除用户所有 session"""
|
|
import json
|
|
|
|
mock_redis.smembers.return_value = {"sess_001", "sess_002"}
|
|
|
|
session1 = json.dumps({"session_id": "sess_001", "user_id": "u1"})
|
|
session2 = json.dumps({"session_id": "sess_002", "user_id": "u1"})
|
|
# get 调用顺序:
|
|
# 1-2: get_user_sessions 中两个 session 的 get
|
|
# 3-4: delete sess_001 (get_session + get refresh_token)
|
|
# 5-6: delete sess_002 (get_session + get refresh_token)
|
|
mock_redis.get.side_effect = [
|
|
session1,
|
|
session2, # get_user_sessions
|
|
session1,
|
|
"tok1", # delete sess_001
|
|
session2,
|
|
"tok2", # delete sess_002
|
|
]
|
|
|
|
count = session_store.delete_all_user_sessions("user_001")
|
|
assert count == 2
|
|
|
|
def test_delete_all_empty_user(self, session_store, mock_redis):
|
|
"""删除无 session 的用户"""
|
|
mock_redis.smembers.return_value = set()
|
|
count = session_store.delete_all_user_sessions("user_001")
|
|
assert count == 0
|
|
|
|
|
|
class TestSessionStoreExists:
|
|
"""session_exists 测试"""
|
|
|
|
def test_exists_true(self, session_store, mock_redis):
|
|
"""存在返回 True"""
|
|
mock_redis.exists.return_value = 1
|
|
assert session_store.session_exists("sess_001") is True
|
|
|
|
def test_exists_false(self, session_store, mock_redis):
|
|
"""不存在返回 False"""
|
|
mock_redis.exists.return_value = 0
|
|
assert session_store.session_exists("sess_001") is False
|
|
|
|
def test_exists_error_returns_false(self, session_store, mock_redis):
|
|
"""异常返回 False"""
|
|
mock_redis.exists.side_effect = Exception("error")
|
|
assert session_store.session_exists("sess_001") is False
|
|
|
|
|
|
class TestGetSessionStore:
|
|
"""工厂函数测试"""
|
|
|
|
def test_disabled_returns_noop(self):
|
|
"""禁用返回 NoopSessionStore"""
|
|
store = get_session_store(enabled=False)
|
|
assert isinstance(store, NoopSessionStore)
|