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(identical(instance1, instance2), isTrue, 38 reason: 'instance1 and instance2 should be identical'); 39 expect(identical(instance2, instance3), isTrue, 40 reason: 'instance2 and instance3 should be identical'); 41 expect(identical(instance1, instance3), isTrue, 42 reason: 'instance1 and instance3 should be identical'); 43 }); 44 45 test('should share in-memory session across singleton instances', () async { 46 // Arrange 47 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 48 49 // Mock storage to return a valid session 50 const sessionJson = '{' 51 '"token": "test-token", ' 52 '"did": "did:plc:test123", ' 53 '"session_id": "session-123", ' 54 '"handle": "alice.bsky.social"' 55 '}'; 56 57 when(mockStorage.read(key: storageKey)) 58 .thenAnswer((_) async => sessionJson); 59 60 // Act - Restore session using first instance 61 await instance1.restoreSession(); 62 63 // Get a second "instance" (should be the same singleton) 64 final instance2 = CovesAuthService(); 65 66 // Assert - Both instances should have the same in-memory session 67 expect(instance2.session?.token, 'test-token'); 68 expect(instance2.session?.did, 'did:plc:test123'); 69 expect(instance2.isAuthenticated, isTrue); 70 71 // Verify storage was only read once (by instance1) 72 verify(mockStorage.read(key: storageKey)).called(1); 73 }); 74 75 test('should share refresh mutex across singleton instances', () async { 76 // Arrange 77 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 78 79 // Mock storage to return a valid session 80 const sessionJson = '{' 81 '"token": "old-token", ' 82 '"did": "did:plc:test123", ' 83 '"session_id": "session-123", ' 84 '"handle": "alice.bsky.social"' 85 '}'; 86 87 when(mockStorage.read(key: storageKey)) 88 .thenAnswer((_) async => sessionJson); 89 90 await instance1.restoreSession(); 91 92 // Mock refresh with delay 93 const newToken = 'refreshed-token'; 94 when(mockDio.post<Map<String, dynamic>>( 95 '/oauth/refresh', 96 data: anyNamed('data'), 97 )).thenAnswer((_) async { 98 await Future.delayed(const Duration(milliseconds: 100)); 99 return Response( 100 requestOptions: RequestOptions(path: '/oauth/refresh'), 101 statusCode: 200, 102 data: {'sealed_token': newToken, 'access_token': 'access-token'}, 103 ); 104 }); 105 106 when(mockStorage.write(key: storageKey, value: anyNamed('value'))) 107 .thenAnswer((_) async => {}); 108 109 // Act - Start refresh from first instance 110 final refreshFuture1 = instance1.refreshToken(); 111 112 // Get second instance and immediately try to refresh 113 final instance2 = CovesAuthService(); 114 final refreshFuture2 = instance2.refreshToken(); 115 116 // Wait for both 117 final results = await Future.wait([refreshFuture1, refreshFuture2]); 118 119 // Assert - Both should get the same result from a single API call 120 expect(results[0].token, newToken); 121 expect(results[1].token, newToken); 122 123 // Verify only one API call was made (mutex protected) 124 verify(mockDio.post<Map<String, dynamic>>( 125 '/oauth/refresh', 126 data: anyNamed('data'), 127 )).called(1); 128 }); 129 130 test('resetInstance() should clear the singleton', () { 131 // Arrange 132 final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); 133 134 // Act 135 CovesAuthService.resetInstance(); 136 137 // Create new instance with different dependencies 138 final mockDio2 = MockDio(); 139 final mockStorage2 = MockFlutterSecureStorage(); 140 final instance2 = CovesAuthService(dio: mockDio2, storage: mockStorage2); 141 142 // Assert - Should be different instances (new singleton created) 143 // Note: We can't directly test if they're different objects easily, 144 // but we can verify that resetInstance() allows a fresh start 145 expect(instance2, isNotNull); 146 expect(instance2.isAuthenticated, isFalse); 147 }); 148 149 test('createTestInstance() should bypass singleton', () { 150 // Arrange 151 final singletonInstance = CovesAuthService(dio: mockDio, storage: mockStorage); 152 153 // Act - Create a test instance with different dependencies 154 final mockDio2 = MockDio(); 155 final mockStorage2 = MockFlutterSecureStorage(); 156 final testInstance = CovesAuthService.createTestInstance( 157 dio: mockDio2, 158 storage: mockStorage2, 159 ); 160 161 // Assert - Test instance should be different from singleton 162 expect(identical(singletonInstance, testInstance), isFalse, 163 reason: 'Test instance should not be the singleton'); 164 165 // Test instance should not affect singleton 166 final singletonCheck = CovesAuthService(); 167 expect(identical(singletonInstance, singletonCheck), isTrue, 168 reason: 'Singleton should remain unchanged'); 169 }); 170 171 test('should avoid state loss when service is requested from multiple entry points', () async { 172 // Arrange 173 final authProvider = CovesAuthService(dio: mockDio, storage: mockStorage); 174 175 const sessionJson = '{' 176 '"token": "test-token", ' 177 '"did": "did:plc:test123", ' 178 '"session_id": "session-123"' 179 '}'; 180 181 when(mockStorage.read(key: storageKey)) 182 .thenAnswer((_) async => sessionJson); 183 184 // Act - Simulate different parts of the app requesting the service 185 await authProvider.restoreSession(); 186 187 final apiService = CovesAuthService(); 188 final voteService = CovesAuthService(); 189 final feedService = CovesAuthService(); 190 191 // Assert - All should have access to the same session state 192 expect(apiService.isAuthenticated, isTrue); 193 expect(voteService.isAuthenticated, isTrue); 194 expect(feedService.isAuthenticated, isTrue); 195 expect(apiService.getToken(), 'test-token'); 196 expect(voteService.getToken(), 'test-token'); 197 expect(feedService.getToken(), 'test-token'); 198 199 // Storage should only be read once 200 verify(mockStorage.read(key: storageKey)).called(1); 201 }); 202 }); 203}