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(
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}