Main coves client
1import 'package:coves_flutter/models/coves_session.dart';
2import 'package:coves_flutter/services/vote_service.dart';
3import 'package:dio/dio.dart';
4import 'package:flutter_test/flutter_test.dart';
5import 'package:http_mock_adapter/http_mock_adapter.dart';
6
7void main() {
8 TestWidgetsFlutterBinding.ensureInitialized();
9
10 group('VoteService - Token Refresh on 401', () {
11 late Dio dio;
12 late DioAdapter dioAdapter;
13 late VoteService voteService;
14
15 // Track token refresh and sign-out calls
16 int tokenRefreshCallCount = 0;
17 int signOutCallCount = 0;
18 CovesSession currentSession = const CovesSession(
19 token: 'initial-token',
20 did: 'did:plc:test123',
21 sessionId: 'session123',
22 );
23 bool shouldRefreshSucceed = true;
24
25 // Mock session getter
26 Future<CovesSession?> mockSessionGetter() async {
27 return currentSession;
28 }
29
30 // Mock DID getter
31 String? mockDidGetter() {
32 return currentSession.did;
33 }
34
35 // Mock token refresher
36 Future<bool> mockTokenRefresher() async {
37 tokenRefreshCallCount++;
38 if (shouldRefreshSucceed) {
39 // Simulate successful refresh by updating the session
40 currentSession = const CovesSession(
41 token: 'refreshed-token',
42 did: 'did:plc:test123',
43 sessionId: 'session123',
44 );
45 return true;
46 }
47 return false;
48 }
49
50 // Mock sign-out handler
51 Future<void> mockSignOutHandler() async {
52 signOutCallCount++;
53 }
54
55 setUp(() {
56 dio = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social'));
57 dioAdapter = DioAdapter(dio: dio);
58
59 // Reset counters and state
60 tokenRefreshCallCount = 0;
61 signOutCallCount = 0;
62 currentSession = const CovesSession(
63 token: 'initial-token',
64 did: 'did:plc:test123',
65 sessionId: 'session123',
66 );
67 shouldRefreshSucceed = true;
68
69 voteService = VoteService(
70 dio: dio,
71 sessionGetter: mockSessionGetter,
72 didGetter: mockDidGetter,
73 tokenRefresher: mockTokenRefresher,
74 signOutHandler: mockSignOutHandler,
75 );
76 });
77
78 test('should call token refresher on 401 response and retry once', () async {
79 // This test verifies the interceptor detects 401, calls the refresher,
80 // and only retries ONCE to prevent infinite loops.
81
82 const postUri = 'at://did:plc:test/social.coves.post.record/123';
83 const postCid = 'bafy123';
84
85 // Mock will always return 401 (simulates scenario where even refresh doesn't help)
86 dioAdapter.onPost(
87 '/xrpc/social.coves.feed.vote.create',
88 (server) => server.reply(401, {
89 'error': 'Unauthorized',
90 'message': 'Token expired',
91 }),
92 data: {
93 'subject': {'uri': postUri, 'cid': postCid},
94 'direction': 'up',
95 },
96 );
97
98 // Make the request and expect it to fail (mock keeps returning 401)
99 expect(
100 () => voteService.createVote(
101 postUri: postUri,
102 postCid: postCid,
103 direction: 'up',
104 ),
105 throwsA(isA<Exception>()),
106 );
107
108 // Wait for async operations
109 await Future.delayed(const Duration(milliseconds: 100));
110
111 // Verify token refresh was called exactly once (proves interceptor works)
112 expect(tokenRefreshCallCount, 1);
113
114 // Verify token was updated by refresher
115 expect(currentSession.token, 'refreshed-token');
116
117 // Verify user was signed out after retry failed (proves retry limit works)
118 expect(signOutCallCount, 1);
119 });
120
121 test('should sign out user if token refresh fails', () async {
122 const postUri = 'at://did:plc:test/social.coves.post.record/123';
123 const postCid = 'bafy123';
124
125 // Set refresh to fail
126 shouldRefreshSucceed = false;
127
128 // First request with expired token returns 401
129 dioAdapter.onPost(
130 '/xrpc/social.coves.feed.vote.create',
131 (server) => server.reply(401, {
132 'error': 'Unauthorized',
133 'message': 'Token expired',
134 }),
135 data: {
136 'subject': {'uri': postUri, 'cid': postCid},
137 'direction': 'up',
138 },
139 );
140
141 // Make the request and expect it to fail
142 expect(
143 () => voteService.createVote(
144 postUri: postUri,
145 postCid: postCid,
146 direction: 'up',
147 ),
148 throwsA(isA<Exception>()),
149 );
150
151 // Wait for async operations to complete
152 await Future.delayed(const Duration(milliseconds: 100));
153
154 // Verify token refresh was attempted
155 expect(tokenRefreshCallCount, 1);
156
157 // Verify user was signed out after refresh failure
158 expect(signOutCallCount, 1);
159 });
160
161 test(
162 'should handle 401 gracefully when no refresher is provided',
163 () async {
164 // Create a NEW dio instance to avoid sharing interceptors
165 final dioNoRefresh = Dio(
166 BaseOptions(baseUrl: 'https://api.test.coves.social'),
167 );
168 final dioAdapterNoRefresh = DioAdapter(dio: dioNoRefresh);
169
170 // Create vote service without refresh capability
171 final voteServiceNoRefresh = VoteService(
172 dio: dioNoRefresh,
173 sessionGetter: mockSessionGetter,
174 didGetter: mockDidGetter,
175 // No tokenRefresher provided
176 // No signOutHandler provided
177 );
178
179 const postUri = 'at://did:plc:test/social.coves.post.record/123';
180 const postCid = 'bafy123';
181
182 // Request returns 401
183 dioAdapterNoRefresh.onPost(
184 '/xrpc/social.coves.feed.vote.create',
185 (server) => server.reply(401, {
186 'error': 'Unauthorized',
187 'message': 'Token expired',
188 }),
189 data: {
190 'subject': {'uri': postUri, 'cid': postCid},
191 'direction': 'up',
192 },
193 );
194
195 // Make the request and expect it to fail
196 expect(
197 () => voteServiceNoRefresh.createVote(
198 postUri: postUri,
199 postCid: postCid,
200 direction: 'up',
201 ),
202 throwsA(isA<Exception>()),
203 );
204
205 // Wait for async operations
206 await Future.delayed(const Duration(milliseconds: 100));
207
208 // Verify refresh was NOT called (no refresher provided)
209 expect(tokenRefreshCallCount, 0);
210
211 // Verify sign-out was NOT called (no handler provided)
212 expect(signOutCallCount, 0);
213 },
214 );
215
216 test('should handle non-401 errors normally without refresh', () async {
217 const postUri = 'at://did:plc:test/social.coves.post.record/123';
218 const postCid = 'bafy123';
219
220 // Request returns 500 server error
221 dioAdapter.onPost(
222 '/xrpc/social.coves.feed.vote.create',
223 (server) => server.reply(500, {
224 'error': 'InternalServerError',
225 'message': 'Database connection failed',
226 }),
227 data: {
228 'subject': {'uri': postUri, 'cid': postCid},
229 'direction': 'up',
230 },
231 );
232
233 // Make the request and expect it to fail
234 expect(
235 () => voteService.createVote(
236 postUri: postUri,
237 postCid: postCid,
238 direction: 'up',
239 ),
240 throwsA(isA<Exception>()),
241 );
242
243 // Wait for async operations
244 await Future.delayed(const Duration(milliseconds: 100));
245
246 // Verify refresh was NOT called (not a 401)
247 expect(tokenRefreshCallCount, 0);
248
249 // Verify sign-out was NOT called
250 expect(signOutCallCount, 0);
251 });
252
253 // Note: delete method was removed - backend handles toggle via create endpoint
254
255 test('should throw ApiException when session is null', () async {
256 // Create service that returns null session
257 final voteServiceNoSession = VoteService(
258 dio: dio,
259 sessionGetter: () async => null,
260 didGetter: () => null,
261 tokenRefresher: mockTokenRefresher,
262 signOutHandler: mockSignOutHandler,
263 );
264
265 const postUri = 'at://did:plc:test/social.coves.post.record/123';
266 const postCid = 'bafy123';
267
268 // Make the request and expect it to fail before even calling the API
269 expect(
270 () => voteServiceNoSession.createVote(
271 postUri: postUri,
272 postCid: postCid,
273 direction: 'up',
274 ),
275 throwsA(isA<Exception>()),
276 );
277
278 // Wait for async operations
279 await Future.delayed(const Duration(milliseconds: 100));
280
281 // Token refresh should NOT be attempted (request never made it to the API)
282 expect(tokenRefreshCallCount, 0);
283 expect(signOutCallCount, 0);
284 });
285
286 test('should use fresh token from session on each request', () async {
287 const postUri = 'at://did:plc:test/social.coves.post.record/123';
288 const postCid = 'bafy123';
289
290 // First request succeeds
291 dioAdapter.onPost(
292 '/xrpc/social.coves.feed.vote.create',
293 (server) => server.reply(200, {
294 'uri': 'at://did:plc:test/social.coves.feed.vote/xyz',
295 'cid': 'bafy456',
296 }),
297 data: {
298 'subject': {'uri': postUri, 'cid': postCid},
299 'direction': 'up',
300 },
301 );
302
303 // Make first request
304 await voteService.createVote(
305 postUri: postUri,
306 postCid: postCid,
307 direction: 'up',
308 );
309
310 // Update session (simulate token rotation)
311 currentSession = const CovesSession(
312 token: 'rotated-token',
313 did: 'did:plc:test123',
314 sessionId: 'session123',
315 );
316
317 // Second request uses a different post
318 const postUri2 = 'at://did:plc:test/social.coves.post.record/456';
319 dioAdapter.onPost(
320 '/xrpc/social.coves.feed.vote.create',
321 (server) => server.reply(200, {
322 'uri': 'at://did:plc:test/social.coves.feed.vote/abc',
323 'cid': 'bafy789',
324 }),
325 data: {
326 'subject': {'uri': postUri2, 'cid': postCid},
327 'direction': 'up',
328 },
329 );
330
331 // Make second request
332 await voteService.createVote(
333 postUri: postUri2,
334 postCid: postCid,
335 direction: 'up',
336 );
337
338 // Verify no refresh was needed (tokens were valid)
339 expect(tokenRefreshCallCount, 0);
340 });
341 });
342}