""" Unit tests for Redis key namespacing. Tests TNNT-03: Per-tenant Redis namespace isolation for cache and session state. Every key function must: 1. Prepend {tenant_id}: to every key 2. Never be callable without a tenant_id argument 3. Produce predictable, stable key formats """ from __future__ import annotations import inspect import pytest from shared.redis_keys import ( engaged_thread_key, idempotency_key, rate_limit_key, session_key, ) class TestRedisKeyFormats: """Tests for correct key format from all key constructor functions.""" def test_rate_limit_key_format(self) -> None: """rate_limit_key must return '{tenant_id}:ratelimit:{channel}'.""" key = rate_limit_key("tenant-a", "slack") assert key == "tenant-a:ratelimit:slack" def test_rate_limit_key_different_channel(self) -> None: """rate_limit_key must work for any channel type string.""" key = rate_limit_key("tenant-a", "whatsapp") assert key == "tenant-a:ratelimit:whatsapp" def test_idempotency_key_format(self) -> None: """idempotency_key must return '{tenant_id}:dedup:{message_id}'.""" key = idempotency_key("tenant-a", "msg-123") assert key == "tenant-a:dedup:msg-123" def test_session_key_format(self) -> None: """session_key must return '{tenant_id}:session:{thread_id}'.""" key = session_key("tenant-a", "thread-456") assert key == "tenant-a:session:thread-456" def test_engaged_thread_key_format(self) -> None: """engaged_thread_key must return '{tenant_id}:engaged:{thread_id}'.""" key = engaged_thread_key("tenant-a", "T12345") assert key == "tenant-a:engaged:T12345" class TestTenantIsolation: """Tests that all key functions produce distinct namespaces per tenant.""" def test_rate_limit_keys_are_tenant_scoped(self) -> None: """Two tenants with the same channel must produce different keys.""" key_a = rate_limit_key("tenant-a", "slack") key_b = rate_limit_key("tenant-b", "slack") assert key_a != key_b assert key_a.startswith("tenant-a:") assert key_b.startswith("tenant-b:") def test_idempotency_keys_are_tenant_scoped(self) -> None: """Two tenants with the same message_id must produce different keys.""" key_a = idempotency_key("tenant-a", "msg-999") key_b = idempotency_key("tenant-b", "msg-999") assert key_a != key_b assert key_a.startswith("tenant-a:") assert key_b.startswith("tenant-b:") def test_session_keys_are_tenant_scoped(self) -> None: """Two tenants with the same thread_id must produce different keys.""" key_a = session_key("tenant-a", "thread-1") key_b = session_key("tenant-b", "thread-1") assert key_a != key_b def test_engaged_thread_keys_are_tenant_scoped(self) -> None: """Two tenants with same thread must produce different engaged keys.""" key_a = engaged_thread_key("tenant-a", "thread-1") key_b = engaged_thread_key("tenant-b", "thread-1") assert key_a != key_b def test_all_keys_include_tenant_id_prefix(self) -> None: """Every key function must produce a key starting with the tenant_id.""" tenant_id = "my-tenant-uuid" keys = [ rate_limit_key(tenant_id, "slack"), idempotency_key(tenant_id, "msg-1"), session_key(tenant_id, "thread-1"), engaged_thread_key(tenant_id, "thread-1"), ] for key in keys: assert key.startswith(f"{tenant_id}:"), ( f"Key {key!r} does not start with tenant_id prefix '{tenant_id}:'" ) class TestNoBarKeysIsPossible: """Tests that prove no key function can be called without tenant_id.""" def test_rate_limit_key_requires_tenant_id(self) -> None: """rate_limit_key signature requires tenant_id as first argument.""" sig = inspect.signature(rate_limit_key) params = list(sig.parameters.keys()) assert params[0] == "tenant_id" def test_idempotency_key_requires_tenant_id(self) -> None: """idempotency_key signature requires tenant_id as first argument.""" sig = inspect.signature(idempotency_key) params = list(sig.parameters.keys()) assert params[0] == "tenant_id" def test_session_key_requires_tenant_id(self) -> None: """session_key signature requires tenant_id as first argument.""" sig = inspect.signature(session_key) params = list(sig.parameters.keys()) assert params[0] == "tenant_id" def test_engaged_thread_key_requires_tenant_id(self) -> None: """engaged_thread_key signature requires tenant_id as first argument.""" sig = inspect.signature(engaged_thread_key) params = list(sig.parameters.keys()) assert params[0] == "tenant_id" def test_calling_without_tenant_id_raises_type_error(self) -> None: """Calling any key function with zero args must raise TypeError.""" with pytest.raises(TypeError): rate_limit_key() # type: ignore[call-arg] with pytest.raises(TypeError): idempotency_key() # type: ignore[call-arg] with pytest.raises(TypeError): session_key() # type: ignore[call-arg] with pytest.raises(TypeError): engaged_thread_key() # type: ignore[call-arg]