Skip to content

Commit 1e064ea

Browse files
fix: address test review — eliminate mock theater, add edge cases
Critical fixes (tests that could pass with broken production code): - Discord cache eviction test: was replicating eviction logic inline. Now calls _handle_forwarded_reaction which triggers the real code path. - GitHub cache purge test: was replicating purge logic inline. Now calls _get_installation_token which runs the real purge. Missing edge cases added to test_chat_resolver.py: - from_json errors when no singleton and no chat= (lazy RuntimeError) - from_json with mismatched adapter name (adapter resolves to None) - activate() context manager resets ContextVar after exception Other improvements: - test_concurrent_tasks_isolated: use public API (get_adapter) instead of private _user_name attribute Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e81d337 commit 1e064ea

2 files changed

Lines changed: 79 additions & 30 deletions

File tree

tests/test_chat_resolver.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,40 @@ def test_error_when_no_singleton(self):
8484
with pytest.raises(RuntimeError, match="No Chat instance available"):
8585
get_chat_singleton()
8686

87+
def test_from_json_errors_when_no_singleton(self):
88+
"""from_json without chat= or singleton raises RuntimeError on adapter access."""
89+
data = _thread_json("slack")
90+
thread = ThreadImpl.from_json(data)
91+
# Thread is created but adapter resolution fails lazily
92+
with pytest.raises(RuntimeError, match="No Chat instance available"):
93+
_ = thread.adapter
94+
95+
def test_from_json_with_mismatched_adapter_name(self):
96+
"""from_json with chat= but wrong adapter name returns None adapter."""
97+
chat = _make_chat("slack")
98+
data = _thread_json("nonexistent")
99+
thread = ThreadImpl.from_json(data, chat=chat)
100+
# Adapter was resolved via chat.get_adapter("nonexistent") → None
101+
assert thread._adapter is None
102+
103+
104+
class TestActivateExceptionSafety:
105+
"""activate() context manager resets ContextVar even after exceptions."""
106+
107+
def setup_method(self):
108+
clear_chat_singleton()
109+
110+
def teardown_method(self):
111+
clear_chat_singleton()
112+
113+
def test_contextvar_reset_after_exception(self):
114+
chat = _make_chat()
115+
with pytest.raises(ValueError, match="test error"), chat.activate():
116+
assert get_chat_singleton() is chat
117+
raise ValueError("test error")
118+
# ContextVar should be reset after exception
119+
assert not has_chat_singleton()
120+
87121

88122
class TestContextVarActivation:
89123
"""chat.activate() scopes resolution to the current context."""
@@ -181,22 +215,23 @@ def teardown_method(self):
181215
async def test_concurrent_tasks_isolated(self):
182216
chat_a = _make_chat("adapter_a")
183217
chat_b = _make_chat("adapter_b")
184-
results: dict[str, str] = {}
218+
results: dict[str, str | None] = {}
185219

186-
async def task(chat: Chat, name: str) -> None:
220+
async def task(chat: Chat, adapter_name: str) -> None:
187221
with chat.activate():
188-
# Yield to let the other task run
189222
await asyncio.sleep(0.01)
190223
resolved = get_chat_singleton()
191-
results[name] = resolved._user_name
224+
# Verify via public API: check which adapter is registered
225+
a = resolved.get_adapter(adapter_name)
226+
results[adapter_name] = a.name if a else None
192227

193228
await asyncio.gather(
194-
task(chat_a, "a"),
195-
task(chat_b, "b"),
229+
task(chat_a, "adapter_a"),
230+
task(chat_b, "adapter_b"),
196231
)
197232

198-
assert results["a"] == "adapter_a-bot"
199-
assert results["b"] == "adapter_b-bot"
233+
assert results["adapter_a"] == "adapter_a"
234+
assert results["adapter_b"] == "adapter_b"
200235

201236
async def test_concurrent_from_json_isolated(self):
202237
chat_a = _make_chat("aa")

tests/test_production_fixes.py

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -514,9 +514,14 @@ def __init__(self, *, token=""):
514514
class TestDiscordThreadParentCacheEviction:
515515
"""Discord thread parent cache should not grow unboundedly."""
516516

517-
def test_thread_parent_cache_eviction_on_overflow(self):
518-
"""When cache exceeds 1000 entries, expired entries are purged."""
519-
from chat_sdk.adapters.discord.adapter import DiscordAdapter
517+
async def test_thread_parent_cache_eviction_on_overflow(self):
518+
"""When cache exceeds 1000 entries during reaction handling, expired entries are purged."""
519+
from unittest.mock import AsyncMock, MagicMock
520+
521+
from chat_sdk.adapters.discord.adapter import (
522+
CHANNEL_TYPE_PUBLIC_THREAD,
523+
DiscordAdapter,
524+
)
520525
from chat_sdk.adapters.discord.types import DiscordAdapterConfig
521526

522527
adapter = DiscordAdapter(
@@ -526,26 +531,36 @@ def test_thread_parent_cache_eviction_on_overflow(self):
526531
application_id="test-app",
527532
)
528533
)
534+
adapter._chat = MagicMock()
535+
adapter._bot_user_id = "bot-user"
529536

530-
# Fill cache with 1001 entries, all expired
537+
# Fill cache with 1001 expired entries
531538
past = time.time() - 100
532539
for i in range(1001):
533540
adapter._thread_parent_cache[f"channel-{i}"] = {
534541
"parent_id": f"parent-{i}",
535542
"expires_at": past,
536543
}
537-
538544
assert len(adapter._thread_parent_cache) == 1001
539545

540-
# The eviction check in the adapter fires when len > 1000 during
541-
# webhook handling. We can verify the cache structure directly:
542-
# after simulated eviction logic, expired entries should be removed.
543-
now = time.time()
544-
expired = [k for k, v in adapter._thread_parent_cache.items() if v.get("expires_at", 0) <= now]
545-
for k in expired:
546-
del adapter._thread_parent_cache[k]
546+
# Trigger the production code path: _handle_forwarded_reaction fetches
547+
# channel info for threads, which triggers cache insertion + eviction
548+
adapter._discord_fetch = AsyncMock(return_value={"parent_id": "real-parent"})
549+
await adapter._handle_forwarded_reaction(
550+
{
551+
"channel_id": "new-thread-channel",
552+
"message_id": "msg-1",
553+
"user_id": "user-1",
554+
"emoji": {"name": "👍"},
555+
"channel_type": CHANNEL_TYPE_PUBLIC_THREAD,
556+
},
557+
added=True,
558+
)
547559

548-
assert len(adapter._thread_parent_cache) == 0
560+
# After eviction, all 1001 expired entries should be purged,
561+
# leaving only the newly inserted one
562+
assert len(adapter._thread_parent_cache) <= 2
563+
assert "new-thread-channel" in adapter._thread_parent_cache
549564

550565

551566
class TestGChatUserInfoCacheEviction:
@@ -583,28 +598,27 @@ async def test_evicts_oldest_entries_past_max_size(self):
583598
class TestGitHubInstallationTokenCachePurge:
584599
"""GitHub installation token cache should purge expired entries."""
585600

586-
def test_expired_entries_purged_on_access(self):
601+
async def test_expired_entries_purged_on_access(self):
602+
"""Calling _get_installation_token purges expired entries from the cache."""
587603
from chat_sdk.adapters.github.adapter import GitHubAdapter
588604

589605
adapter = GitHubAdapter({"webhook_secret": "test-secret", "token": "pat-123"})
590606

591-
# Manually populate the cache with expired entries
607+
# Populate cache with expired entries
592608
past = time.time() - 100
593609
for i in range(10):
594610
adapter._installation_token_cache[i] = (f"token-{i}", past)
595611

596-
# Add one valid entry
612+
# Add one valid entry (expires in 1 hour, with 60s buffer = still valid)
597613
future = time.time() + 3600
598614
adapter._installation_token_cache[999] = ("valid-token", future)
599615

600616
assert len(adapter._installation_token_cache) == 11
601617

602-
# The purge logic runs at the start of _get_installation_token
603-
# We simulate it directly:
604-
now = time.time()
605-
expired_ids = [iid for iid, (_, exp) in adapter._installation_token_cache.items() if now >= exp]
606-
for iid in expired_ids:
607-
del adapter._installation_token_cache[iid]
618+
# Call the real method — it purges expired entries at the top,
619+
# then finds the valid cached token for installation_id=999
620+
token = await adapter._get_installation_token(999)
608621

622+
assert token == "valid-token"
609623
assert len(adapter._installation_token_cache) == 1
610624
assert 999 in adapter._installation_token_cache

0 commit comments

Comments
 (0)