mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:flutter/material.dart';
2import 'package:flutter_riverpod/flutter_riverpod.dart';
3import 'package:flutter_test/flutter_test.dart';
4import 'package:lazurite/src/core/auth/session_model.dart';
5import 'package:lazurite/src/core/providers/app_lifecycle_provider.dart';
6import 'package:lazurite/src/features/auth/application/auth_providers.dart';
7import 'package:lazurite/src/features/auth/domain/auth_state.dart';
8import 'package:lazurite/src/features/settings/application/preference_sync_controller.dart';
9import 'package:lazurite/src/features/settings/application/settings_providers.dart';
10import 'package:mocktail/mocktail.dart';
11
12import '../../../../helpers/mocks.dart';
13
14/// Mock notifier for auth state
15class MockAuthNotifier extends Notifier<AuthState> with Mock implements AuthNotifier {
16 MockAuthNotifier(this._state);
17
18 AuthState _state;
19
20 @override
21 AuthState build() => _state;
22
23 void setState(AuthState newState) {
24 _state = newState;
25 state = newState;
26 }
27}
28
29/// Mock notifier for app lifecycle
30class MockAppLifecycle extends Notifier<AppLifecycleState> with Mock implements AppLifecycle {
31 MockAppLifecycle(this._state);
32
33 AppLifecycleState _state;
34
35 @override
36 AppLifecycleState build() => _state;
37
38 void setState(AppLifecycleState newState) {
39 _state = newState;
40 state = newState;
41 }
42}
43
44void main() {
45 late MockBlueskyPreferencesRepository mockRepository;
46
47 setUp(() {
48 mockRepository = MockBlueskyPreferencesRepository();
49
50 when(() => mockRepository.syncPreferencesFromRemote(any())).thenAnswer((_) async {});
51 when(() => mockRepository.processSyncQueue(any())).thenAnswer((_) async {});
52 when(() => mockRepository.clearAll(any())).thenAnswer((_) async {});
53 });
54
55 group('PreferenceSyncController', () {
56 group('initial sync on authenticated state', () {
57 test('syncs preferences when user is authenticated', () async {
58 final fakeSession = _createFakeSession();
59 final authState = AuthStateAuthenticated(fakeSession);
60
61 final container = ProviderContainer(
62 overrides: [
63 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
64 authProvider.overrideWith(() => MockAuthNotifier(authState)),
65 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.resumed)),
66 ],
67 );
68 addTearDown(container.dispose);
69
70 container.read(preferenceSyncControllerProvider);
71 await Future<void>.delayed(Duration.zero);
72
73 verify(() => mockRepository.syncPreferencesFromRemote(any())).called(1);
74 verify(() => mockRepository.processSyncQueue(any())).called(1);
75 });
76
77 test('does not sync when user is not authenticated', () async {
78 const authState = AuthStateUnauthenticated();
79
80 final container = ProviderContainer(
81 overrides: [
82 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
83 authProvider.overrideWith(() => MockAuthNotifier(authState)),
84 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.resumed)),
85 ],
86 );
87 addTearDown(container.dispose);
88
89 container.read(preferenceSyncControllerProvider);
90 await Future<void>.delayed(Duration.zero);
91
92 verifyNever(() => mockRepository.syncPreferencesFromRemote(any()));
93 verifyNever(() => mockRepository.processSyncQueue(any()));
94 });
95 });
96
97 group('error handling', () {
98 test('handles sync errors gracefully', () async {
99 final fakeSession = _createFakeSession();
100 final authState = AuthStateAuthenticated(fakeSession);
101
102 when(
103 () => mockRepository.syncPreferencesFromRemote(any()),
104 ).thenThrow(Exception('Network error'));
105
106 final container = ProviderContainer(
107 overrides: [
108 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
109 authProvider.overrideWith(() => MockAuthNotifier(authState)),
110 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.resumed)),
111 ],
112 );
113 addTearDown(container.dispose);
114
115 expect(() => container.read(preferenceSyncControllerProvider), returnsNormally);
116 await Future<void>.delayed(Duration.zero);
117
118 verify(() => mockRepository.syncPreferencesFromRemote(any())).called(1);
119 });
120
121 test('handles queue processing errors gracefully', () async {
122 final fakeSession = _createFakeSession();
123 final authState = AuthStateAuthenticated(fakeSession);
124
125 when(() => mockRepository.processSyncQueue(any())).thenThrow(Exception('Queue error'));
126
127 final container = ProviderContainer(
128 overrides: [
129 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
130 authProvider.overrideWith(() => MockAuthNotifier(authState)),
131 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.resumed)),
132 ],
133 );
134 addTearDown(container.dispose);
135
136 expect(() => container.read(preferenceSyncControllerProvider), returnsNormally);
137 await Future<void>.delayed(Duration.zero);
138 });
139
140 test('does not crash when clearAll fails on logout', () async {
141 when(() => mockRepository.clearAll(any())).thenThrow(Exception('Clear failed'));
142
143 const authState = AuthStateUnauthenticated();
144
145 final container = ProviderContainer(
146 overrides: [
147 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
148 authProvider.overrideWith(() => MockAuthNotifier(authState)),
149 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.resumed)),
150 ],
151 );
152 addTearDown(container.dispose);
153
154 expect(() => container.read(preferenceSyncControllerProvider), returnsNormally);
155 await Future<void>.delayed(Duration.zero);
156 });
157 });
158
159 group('lifecycle state', () {
160 test('does not sync on initial paused state', () async {
161 final fakeSession = _createFakeSession();
162 final authState = AuthStateAuthenticated(fakeSession);
163
164 final container = ProviderContainer(
165 overrides: [
166 blueskyPreferencesRepositoryProvider.overrideWithValue(mockRepository),
167 authProvider.overrideWith(() => MockAuthNotifier(authState)),
168 appLifecycleProvider.overrideWith(() => MockAppLifecycle(AppLifecycleState.paused)),
169 ],
170 );
171 addTearDown(container.dispose);
172
173 container.read(preferenceSyncControllerProvider);
174 await Future<void>.delayed(Duration.zero);
175 verify(() => mockRepository.syncPreferencesFromRemote(any())).called(1);
176 });
177 });
178 });
179}
180
181Session _createFakeSession() {
182 return Session(
183 did: 'did:plc:testuser123',
184 handle: 'test.bsky.social',
185 accessJwt: 'test-access-jwt',
186 refreshJwt: 'test-refresh-jwt',
187 pdsUrl: 'https://bsky.social',
188 scope: 'atproto transition:generic',
189 expiresAt: DateTime.now().add(const Duration(hours: 1)),
190 dpopKey: const <String, dynamic>{},
191 );
192}