at main 7.8 kB view raw
1import 'package:coves_flutter/services/coves_auth_service.dart'; 2import 'package:dio/dio.dart'; 3import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 4import 'package:flutter_test/flutter_test.dart'; 5import 'package:mockito/annotations.dart'; 6import 'package:mockito/mockito.dart'; 7 8import 'coves_auth_service_test.mocks.dart'; 9 10@GenerateMocks([Dio, FlutterSecureStorage]) 11void main() { 12 late MockDio mockDio; 13 late MockFlutterSecureStorage mockStorage; 14 15 // Storage key is environment-specific to prevent token reuse across dev/prod 16 // Tests run in production environment by default 17 const storageKey = 'coves_session_production'; 18 19 setUp(() { 20 CovesAuthService.resetInstance(); 21 mockDio = MockDio(); 22 mockStorage = MockFlutterSecureStorage(); 23 }); 24 25 tearDown(() { 26 CovesAuthService.resetInstance(); 27 }); 28 29 group('CovesAuthService - Singleton Pattern', () { 30 test('should return the same instance on multiple factory calls', () { 31 // Act - Create multiple instances using the factory 32 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 33 final instance2 = CovesAuthService(); 34 final instance3 = CovesAuthService(); 35 36 // Assert - All should be the exact same instance 37 expect( 38 identical(instance1, instance2), 39 isTrue, 40 reason: 'instance1 and instance2 should be identical', 41 ); 42 expect( 43 identical(instance2, instance3), 44 isTrue, 45 reason: 'instance2 and instance3 should be identical', 46 ); 47 expect( 48 identical(instance1, instance3), 49 isTrue, 50 reason: 'instance1 and instance3 should be identical', 51 ); 52 }); 53 54 test('should share in-memory session across singleton instances', () async { 55 // Arrange 56 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 57 58 // Mock storage to return a valid session 59 const sessionJson = 60 '{' 61 '"token": "test-token", ' 62 '"did": "did:plc:test123", ' 63 '"session_id": "session-123", ' 64 '"handle": "alice.bsky.social"' 65 '}'; 66 67 when( 68 mockStorage.read(key: storageKey), 69 ).thenAnswer((_) async => sessionJson); 70 71 // Act - Restore session using first instance 72 await instance1.restoreSession(); 73 74 // Get a second "instance" (should be the same singleton) 75 final instance2 = CovesAuthService(); 76 77 // Assert - Both instances should have the same in-memory session 78 expect(instance2.session?.token, 'test-token'); 79 expect(instance2.session?.did, 'did:plc:test123'); 80 expect(instance2.isAuthenticated, isTrue); 81 82 // Verify storage was only read once (by instance1) 83 verify(mockStorage.read(key: storageKey)).called(1); 84 }); 85 86 test('should share refresh mutex across singleton instances', () async { 87 // Arrange 88 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 89 90 // Mock storage to return a valid session 91 const sessionJson = 92 '{' 93 '"token": "old-token", ' 94 '"did": "did:plc:test123", ' 95 '"session_id": "session-123", ' 96 '"handle": "alice.bsky.social"' 97 '}'; 98 99 when( 100 mockStorage.read(key: storageKey), 101 ).thenAnswer((_) async => sessionJson); 102 103 await instance1.restoreSession(); 104 105 // Mock refresh with delay 106 const newToken = 'refreshed-token'; 107 when( 108 mockDio.post<Map<String, dynamic>>( 109 '/oauth/refresh', 110 data: anyNamed('data'), 111 ), 112 ).thenAnswer((_) async { 113 await Future.delayed(const Duration(milliseconds: 100)); 114 return Response( 115 requestOptions: RequestOptions(path: '/oauth/refresh'), 116 statusCode: 200, 117 data: {'sealed_token': newToken, 'access_token': 'access-token'}, 118 ); 119 }); 120 121 when( 122 mockStorage.write(key: storageKey, value: anyNamed('value')), 123 ).thenAnswer((_) async => {}); 124 125 // Act - Start refresh from first instance 126 final refreshFuture1 = instance1.refreshToken(); 127 128 // Get second instance and immediately try to refresh 129 final instance2 = CovesAuthService(); 130 final refreshFuture2 = instance2.refreshToken(); 131 132 // Wait for both 133 final results = await Future.wait([refreshFuture1, refreshFuture2]); 134 135 // Assert - Both should get the same result from a single API call 136 expect(results[0].token, newToken); 137 expect(results[1].token, newToken); 138 139 // Verify only one API call was made (mutex protected) 140 verify( 141 mockDio.post<Map<String, dynamic>>( 142 '/oauth/refresh', 143 data: anyNamed('data'), 144 ), 145 ).called(1); 146 }); 147 148 test('resetInstance() should clear the singleton', () { 149 // Arrange 150 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 151 expect(instance1, isNotNull); // Verify initial singleton exists 152 153 // Act 154 CovesAuthService.resetInstance(); 155 156 // Create new instance with different dependencies 157 final mockDio2 = MockDio(); 158 final mockStorage2 = MockFlutterSecureStorage(); 159 final instance2 = CovesAuthService(dio: mockDio2, storage: mockStorage2); 160 161 // Assert - Should be different instances (new singleton created) 162 // Note: We can't directly test if they're different objects easily, 163 // but we can verify that resetInstance() allows a fresh start 164 expect(instance2, isNotNull); 165 expect(instance2.isAuthenticated, isFalse); 166 }); 167 168 test('createTestInstance() should bypass singleton', () { 169 // Arrange 170 final singletonInstance = CovesAuthService( 171 dio: mockDio, 172 storage: mockStorage, 173 ); 174 175 // Act - Create a test instance with different dependencies 176 final mockDio2 = MockDio(); 177 final mockStorage2 = MockFlutterSecureStorage(); 178 final testInstance = CovesAuthService.createTestInstance( 179 dio: mockDio2, 180 storage: mockStorage2, 181 ); 182 183 // Assert - Test instance should be different from singleton 184 expect( 185 identical(singletonInstance, testInstance), 186 isFalse, 187 reason: 'Test instance should not be the singleton', 188 ); 189 190 // Test instance should not affect singleton 191 final singletonCheck = CovesAuthService(); 192 expect( 193 identical(singletonInstance, singletonCheck), 194 isTrue, 195 reason: 'Singleton should remain unchanged', 196 ); 197 }); 198 199 test( 200 'should avoid state loss when service is requested from multiple entry points', 201 () async { 202 // Arrange 203 final authProvider = CovesAuthService( 204 dio: mockDio, 205 storage: mockStorage, 206 ); 207 208 const sessionJson = 209 '{' 210 '"token": "test-token", ' 211 '"did": "did:plc:test123", ' 212 '"session_id": "session-123"' 213 '}'; 214 215 when( 216 mockStorage.read(key: storageKey), 217 ).thenAnswer((_) async => sessionJson); 218 219 // Act - Simulate different parts of the app requesting the service 220 await authProvider.restoreSession(); 221 222 final apiService = CovesAuthService(); 223 final voteService = CovesAuthService(); 224 final feedService = CovesAuthService(); 225 226 // Assert - All should have access to the same session state 227 expect(apiService.isAuthenticated, isTrue); 228 expect(voteService.isAuthenticated, isTrue); 229 expect(feedService.isAuthenticated, isTrue); 230 expect(apiService.getToken(), 'test-token'); 231 expect(voteService.getToken(), 'test-token'); 232 expect(feedService.getToken(), 'test-token'); 233 234 // Storage should only be read once 235 verify(mockStorage.read(key: storageKey)).called(1); 236 }, 237 ); 238 }); 239}