a digital entity named phi that roams bsky
phi.zzstoatzz.io
1"""Tests for the graze.social REST client."""
2
3from unittest.mock import AsyncMock, MagicMock, patch
4
5import httpx
6import pytest
7
8from bot.core.graze_client import BASE_URL, GrazeClient
9
10
11@pytest.fixture
12def graze():
13 return GrazeClient(handle="test.bsky.social", password="test-pass")
14
15
16def _login_response():
17 """Fake successful login response."""
18 resp = httpx.Response(
19 200,
20 json={"user": {"id": 42}},
21 request=httpx.Request("POST", f"{BASE_URL}/app/login"),
22 )
23 return resp
24
25
26def _ok_response(json=None):
27 resp = httpx.Response(
28 200,
29 json=json or {},
30 request=httpx.Request("GET", BASE_URL),
31 )
32 return resp
33
34
35class TestLogin:
36 async def test_login_caches_session(self, graze):
37 with patch("bot.core.graze_client.httpx.AsyncClient") as mock_cls:
38 mock_client = AsyncMock()
39 mock_client.post.return_value = _login_response()
40 mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
41 mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
42
43 await graze._login()
44 assert graze._user_id == 42
45 assert graze._cookies is not None
46
47 async def test_ensure_session_skips_if_cached(self, graze):
48 graze._cookies = httpx.Cookies()
49 graze._user_id = 42
50 # should not attempt login
51 with patch.object(graze, "_login") as mock_login:
52 await graze._ensure_session()
53 mock_login.assert_not_called()
54
55 async def test_ensure_session_logs_in_if_no_cookies(self, graze):
56 with patch.object(graze, "_login") as mock_login:
57 await graze._ensure_session()
58 mock_login.assert_called_once()
59
60
61class TestCreateFeed:
62 async def test_full_create_flow(self, graze):
63 """Test the 5-step create flow: putRecord → migrate → complete → publish → set-publicity."""
64 graze._cookies = httpx.Cookies()
65 graze._user_id = 42
66
67 # mock bot_client for PDS putRecord
68 mock_bot = MagicMock()
69 mock_bot.authenticate = AsyncMock()
70 mock_bot.client.me.did = "did:plc:testdid"
71 mock_bot.client.com.atproto.repo.put_record = MagicMock()
72
73 call_log = []
74
75 async def fake_request(method, path, **kwargs):
76 call_log.append((method, path))
77 if path == "/app/migrate_algo":
78 return _ok_response(json={"id": 99})
79 return _ok_response()
80
81 with (
82 patch("bot.core.graze_client.bot_client", mock_bot),
83 patch.object(graze, "_request", side_effect=fake_request),
84 ):
85 result = await graze.create_feed(
86 rkey="jazz-feed",
87 display_name="Jazz Music",
88 description="posts about jazz",
89 filter_manifest={
90 "filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]}
91 },
92 )
93
94 assert result["uri"] == "at://did:plc:testdid/app.bsky.feed.generator/jazz-feed"
95 assert result["algo_id"] == 99
96
97 # verify PDS record was created
98 mock_bot.client.com.atproto.repo.put_record.assert_called_once()
99 put_data = mock_bot.client.com.atproto.repo.put_record.call_args
100 record = put_data.kwargs["data"]["record"]
101 assert record["displayName"] == "Jazz Music"
102 assert record["did"] == "did:web:api.graze.social"
103
104 # verify all 5 graze API calls in order
105 assert call_log == [
106 ("POST", "/app/migrate_algo"),
107 ("POST", "/app/complete_migration"),
108 ("GET", "/app/publish_algo/99"),
109 ("GET", "/app/api/v1/algorithm-management/set-publicity/99/true"),
110 ("POST", "/app/api/v1/algorithm-management/backfill/99"),
111 ]
112
113 async def test_create_feed_propagates_errors(self, graze):
114 graze._cookies = httpx.Cookies()
115 graze._user_id = 42
116
117 mock_bot = MagicMock()
118 mock_bot.authenticate = AsyncMock()
119 mock_bot.client.me.did = "did:plc:testdid"
120 mock_bot.client.com.atproto.repo.put_record = MagicMock()
121
122 async def fail_migrate(method, path, **kwargs):
123 raise httpx.HTTPStatusError(
124 "bad request",
125 request=httpx.Request("POST", f"{BASE_URL}/app/migrate_algo"),
126 response=httpx.Response(400),
127 )
128
129 with (
130 patch("bot.core.graze_client.bot_client", mock_bot),
131 patch.object(graze, "_request", side_effect=fail_migrate),
132 ):
133 with pytest.raises(httpx.HTTPStatusError):
134 await graze.create_feed("test", "Test", "test", {"filter": {}})
135
136
137class TestListFeeds:
138 async def test_list_feeds(self, graze):
139 feeds_data = [
140 {"id": 1, "display_name": "Jazz", "feed_uri": "at://did/gen/jazz"},
141 {"id": 2, "display_name": "Blues", "feed_uri": "at://did/gen/blues"},
142 ]
143
144 async def fake_request(method, path, **kwargs):
145 return _ok_response(json=feeds_data)
146
147 with patch.object(graze, "_request", side_effect=fake_request):
148 result = await graze.list_feeds()
149
150 assert len(result) == 2
151 assert result[0]["display_name"] == "Jazz"
152
153
154class TestDeleteFeed:
155 async def test_delete_feed(self, graze):
156 graze._user_id = 42
157
158 async def fake_request(method, path, **kwargs):
159 assert method == "POST"
160 assert path == "/app/delete_algo"
161 assert kwargs["json"] == {"id": 99, "user_id": 42}
162 return _ok_response()
163
164 with patch.object(graze, "_request", side_effect=fake_request):
165 await graze.delete_feed(99)
166
167
168class TestReloginOn401:
169 async def test_request_retries_on_401(self, graze):
170 graze._cookies = httpx.Cookies()
171 graze._user_id = 42
172
173 call_count = 0
174
175 async def mock_request(method, path, **kwargs):
176 nonlocal call_count
177 call_count += 1
178 if call_count == 1:
179 return httpx.Response(
180 401,
181 request=httpx.Request("GET", f"{BASE_URL}{path}"),
182 )
183 return httpx.Response(
184 200,
185 json={"ok": True},
186 request=httpx.Request("GET", f"{BASE_URL}{path}"),
187 )
188
189 with (
190 patch("bot.core.graze_client.httpx.AsyncClient") as mock_cls,
191 patch.object(graze, "_login", new_callable=AsyncMock) as mock_login,
192 ):
193 mock_client = AsyncMock()
194 mock_client.request = mock_request
195 mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
196 mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
197
198 r = await graze._request("GET", "/app/my_feeds")
199 assert r.status_code == 200
200 mock_login.assert_called_once()