"""SessionStore 单元测试(FakeRedis 与内存降级,不连真实 Redis) 覆盖: - create/get/delete 生命周期;伪造 token 返回 None - TTL=43200 传入 Redis(setex) - delete_by_username 清除指定用户全部会话 - 内存降级(构造传 None)全功能可用 + 过期惰性清理 - Redis 异常包装为 SessionStoreError """ import fnmatch import time import pytest from app.core.sessions import SessionStore, SessionStoreError class FakeRedis: """最小内存版 redis.asyncio 客户端:get/set/setex/delete/scan_iter/keys + TTL 记录 fail=True 时所有操作抛 ConnectionError,用于验证异常包装语义。 """ def __init__(self, fail: bool = False) -> None: self.fail = fail self.store: dict[str, str] = {} self.ttls: dict[str, int] = {} def _check(self) -> None: if self.fail: raise ConnectionError("redis down") async def get(self, key: str) -> str | None: self._check() return self.store.get(key) async def set(self, key: str, value: str) -> bool: self._check() self.store[key] = value return True async def setex(self, key: str, ttl: int, value: str) -> bool: self._check() self.store[key] = value self.ttls[key] = ttl return True async def delete(self, *keys: str) -> int: self._check() deleted = 0 for key in keys: if key in self.store: del self.store[key] deleted += 1 return deleted async def scan_iter(self, match: str = "*"): self._check() for key in list(self.store): if fnmatch.fnmatch(key, match): yield key async def keys(self, pattern: str = "*") -> list[str]: self._check() return [key for key in self.store if fnmatch.fnmatch(key, pattern)] class TestLifecycle: """会话签发 / 校验 / 删除""" async def test_create_get_delete(self): store = SessionStore(FakeRedis()) token = await store.create("alice", "admin") assert isinstance(token, str) and len(token) == 64 # token_hex(32) assert await store.get(token) == {"username": "alice", "role": "admin"} await store.delete(token) assert await store.get(token) is None async def test_forged_token_returns_none(self): assert await SessionStore(FakeRedis()).get("f" * 64) is None async def test_tokens_unique(self): store = SessionStore(FakeRedis()) assert await store.create("alice", "user") != await store.create("alice", "user") async def test_delete_is_idempotent(self): store = SessionStore(FakeRedis()) token = await store.create("alice", "user") await store.delete(token) await store.delete(token) # 二次删除不报错 assert await store.get(token) is None class TestTtl: """TTL 透传 Redis""" async def test_ttl_passed_to_setex(self): redis = FakeRedis() store = SessionStore(redis) token = await store.create("alice", "user") assert SessionStore.TTL == 43200 assert redis.ttls[f"session:{token}"] == 43200 class TestDeleteByUsername: """按用户清除全部会话""" async def test_clears_only_target_user(self): store = SessionStore(FakeRedis()) t1 = await store.create("alice", "user") t2 = await store.create("alice", "user") t3 = await store.create("bob", "user") await store.delete_by_username("alice") assert await store.get(t1) is None assert await store.get(t2) is None assert await store.get(t3) is not None async def test_unknown_username_noop(self): store = SessionStore(FakeRedis()) token = await store.create("alice", "user") await store.delete_by_username("nobody") assert await store.get(token) is not None class TestMemoryFallback: """构造传 None:内存 dict 降级 + 过期惰性清理""" async def test_full_lifecycle(self): store = SessionStore(None) token = await store.create("alice", "admin") assert await store.get(token) == {"username": "alice", "role": "admin"} await store.delete_by_username("alice") assert await store.get(token) is None async def test_expired_session_returns_none_and_purged(self): store = SessionStore(None) token = await store.create("alice", "user") # 手动把过期时间戳拨到过去,模拟 TTL 到期 payload, _ = store._memory[token] store._memory[token] = (payload, time.time() - 1) assert await store.get(token) is None assert token not in store._memory # 惰性清理 async def test_create_purges_expired_entries(self): store = SessionStore(None) expired_token = await store.create("old", "user") payload, _ = store._memory[expired_token] store._memory[expired_token] = (payload, time.time() - 1) await store.create("new", "user") # 签发时顺手清理过期项 assert expired_token not in store._memory class TestRedisErrors: """Redis 读写异常统一包装为 SessionStoreError(不静默)""" async def test_create_raises_store_error(self): with pytest.raises(SessionStoreError): await SessionStore(FakeRedis(fail=True)).create("alice", "user") async def test_get_raises_store_error(self): with pytest.raises(SessionStoreError): await SessionStore(FakeRedis(fail=True)).get("t" * 64) async def test_delete_raises_store_error(self): with pytest.raises(SessionStoreError): await SessionStore(FakeRedis(fail=True)).delete("t" * 64) async def test_delete_by_username_raises_store_error(self): with pytest.raises(SessionStoreError): await SessionStore(FakeRedis(fail=True)).delete_by_username("alice")