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}