Skip to content

Commit 345fff9

Browse files
bsboddenclaude
andcommitted
test(checkpoint): add regression tests for Issue #116 async blob access
Adds comprehensive regression tests for Issue #116 where AsyncRedisSaver would raise AttributeError when accessing blob attributes in _abatch_load_pending_sends. Tests verify: - Correct handling of $.blob JSON path attribute syntax - Graceful fallback when blob attributes are missing - Proper mocking of Redis Document responses These tests would have caught the bug fixed in PR #117. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fe48e7f commit 345fff9

File tree

1 file changed

+179
-0
lines changed

1 file changed

+179
-0
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""
2+
Regression test for Issue #116: AsyncRedisSaver AttributeError when calling aget_state_history()
3+
4+
This test verifies that the async implementation correctly handles blob access
5+
when using _abatch_load_pending_sends with the JSON path syntax ($.blob).
6+
7+
The bug manifested as:
8+
AttributeError: 'Document' object has no attribute 'blob'
9+
10+
This was caused by a mismatch between:
11+
1. The return_fields specification ("blob" instead of "$.blob")
12+
2. The attribute access pattern (direct access d.blob instead of getattr(d, "$.blob", ...))
13+
14+
The fix aligns the async implementation with the sync version by:
15+
1. Using "$.blob" in return_fields
16+
2. Using getattr(doc, "$.blob", getattr(doc, "blob", b"")) for access
17+
"""
18+
19+
from typing import Any, Dict
20+
from unittest.mock import MagicMock
21+
22+
import pytest
23+
24+
from langgraph.checkpoint.redis.aio import AsyncRedisSaver
25+
26+
27+
class MockDocument:
28+
"""Mock document that simulates Redis JSON path attribute behavior."""
29+
30+
def __init__(self, data: Dict[str, Any]):
31+
self.checkpoint_id = data.get("checkpoint_id", "")
32+
self.type = data.get("type", "")
33+
self.task_path = data.get("task_path", "")
34+
self.task_id = data.get("task_id", "")
35+
self.idx = data.get("idx", 0)
36+
# When using "$.blob" in return_fields, Redis returns it as "$.blob" attribute
37+
if "json_blob" in data:
38+
setattr(self, "$.blob", data["json_blob"])
39+
40+
41+
@pytest.mark.asyncio
42+
async def test_abatch_load_pending_sends_with_json_path_blob(redis_url: str) -> None:
43+
"""
44+
Test that _abatch_load_pending_sends correctly handles $.blob JSON path attribute.
45+
46+
This is a unit test with mocked Redis responses that directly tests the bug fix.
47+
Before the fix, accessing d.blob would raise AttributeError because Redis returns
48+
the attribute as "$.blob" (not "blob") when you specify "$.blob" in return_fields.
49+
"""
50+
async with AsyncRedisSaver.from_conn_string(redis_url) as saver:
51+
await saver.asetup()
52+
53+
# Create mock search result with documents using $.blob (JSON path syntax)
54+
mock_search_result = MagicMock()
55+
mock_search_result.docs = [
56+
MockDocument(
57+
{
58+
"checkpoint_id": "checkpoint_1",
59+
"type": "test_type1",
60+
"task_path": "path1",
61+
"task_id": "task1",
62+
"idx": 0,
63+
"json_blob": b"data1", # This becomes $.blob attribute
64+
}
65+
),
66+
MockDocument(
67+
{
68+
"checkpoint_id": "checkpoint_1",
69+
"type": "test_type2",
70+
"task_path": "path2",
71+
"task_id": "task2",
72+
"idx": 1,
73+
"json_blob": b"data2", # This becomes $.blob attribute
74+
}
75+
),
76+
MockDocument(
77+
{
78+
"checkpoint_id": "checkpoint_2",
79+
"type": "test_type3",
80+
"task_path": "path3",
81+
"task_id": "task3",
82+
"idx": 0,
83+
"json_blob": b"data3", # This becomes $.blob attribute
84+
}
85+
),
86+
]
87+
88+
# Mock the search method to return our mock documents
89+
original_search = saver.checkpoint_writes_index.search
90+
91+
async def mock_search(_: Any) -> MagicMock:
92+
return mock_search_result
93+
94+
saver.checkpoint_writes_index.search = mock_search
95+
96+
try:
97+
# Call the method that was failing before the fix
98+
# This internally tries to access d.blob which would fail without the fix
99+
result = await saver._abatch_load_pending_sends(
100+
[
101+
("test_thread", "test_ns", "checkpoint_1"),
102+
("test_thread", "test_ns", "checkpoint_2"),
103+
]
104+
)
105+
106+
# Verify results are correctly extracted
107+
assert ("test_thread", "test_ns", "checkpoint_1") in result
108+
assert ("test_thread", "test_ns", "checkpoint_2") in result
109+
110+
# Verify the blob data was correctly accessed via $.blob
111+
checkpoint_1_data = result[("test_thread", "test_ns", "checkpoint_1")]
112+
assert len(checkpoint_1_data) == 2
113+
assert checkpoint_1_data[0] == ("test_type1", b"data1")
114+
assert checkpoint_1_data[1] == ("test_type2", b"data2")
115+
116+
checkpoint_2_data = result[("test_thread", "test_ns", "checkpoint_2")]
117+
assert len(checkpoint_2_data) == 1
118+
assert checkpoint_2_data[0] == ("test_type3", b"data3")
119+
120+
finally:
121+
# Restore original search method
122+
saver.checkpoint_writes_index.search = original_search
123+
124+
125+
@pytest.mark.asyncio
126+
async def test_abatch_load_pending_sends_handles_missing_blob(redis_url: str) -> None:
127+
"""
128+
Test that _abatch_load_pending_sends gracefully handles missing blob attributes.
129+
130+
This tests the fallback logic: getattr(doc, "$.blob", getattr(doc, "blob", b""))
131+
"""
132+
async with AsyncRedisSaver.from_conn_string(redis_url) as saver:
133+
await saver.asetup()
134+
135+
# Create mock documents - some with $.blob, some without
136+
mock_search_result = MagicMock()
137+
mock_search_result.docs = [
138+
MockDocument(
139+
{
140+
"checkpoint_id": "checkpoint_1",
141+
"type": "test_type1",
142+
"task_path": "p1",
143+
"task_id": "t1",
144+
"idx": 0,
145+
"json_blob": b"data1",
146+
}
147+
),
148+
MockDocument(
149+
{
150+
"checkpoint_id": "checkpoint_1",
151+
"type": "test_type2",
152+
"task_path": "p2",
153+
"task_id": "t2",
154+
"idx": 1,
155+
# No json_blob - this simulates missing $.blob attribute
156+
}
157+
),
158+
]
159+
160+
original_search = saver.checkpoint_writes_index.search
161+
162+
async def mock_search(_: Any) -> MagicMock:
163+
return mock_search_result
164+
165+
saver.checkpoint_writes_index.search = mock_search
166+
167+
try:
168+
result = await saver._abatch_load_pending_sends(
169+
[("test_thread", "test_ns", "checkpoint_1")]
170+
)
171+
172+
# Should handle the missing blob gracefully with empty bytes fallback
173+
checkpoint_data = result[("test_thread", "test_ns", "checkpoint_1")]
174+
assert len(checkpoint_data) == 2
175+
assert checkpoint_data[0] == ("test_type1", b"data1")
176+
assert checkpoint_data[1] == ("test_type2", b"") # Fallback to b""
177+
178+
finally:
179+
saver.checkpoint_writes_index.search = original_search

0 commit comments

Comments
 (0)