Main coves client
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}