mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

feat: add pull to refresh for threads

* update feed syncing

Changed files
+395 -52
lib
src
features
feeds
application
infrastructure
presentation
screens
profile
domain
presentation
thread
infrastructure
presentation
infrastructure
test
+2 -2
justfile
··· 8 8 9 9 # Test with failures only to focus on failures and hanging tests 10 10 test-quiet *paths='': 11 - flutter test {{ paths }} --reporter=failures-only --timeout=120s 11 + flutter test {{ paths }} --reporter=failures-only --fail-fast --timeout=120s 12 12 13 13 # Run all tests 14 14 test *paths='': 15 - flutter test {{ paths }} --timeout=120s 15 + flutter test {{ paths }} --fail-fast --timeout=120s 16 16 17 17 # Run code gen 18 18 gen:
-1
lib/src/features/feeds/application/feed_sync_controller.dart
··· 81 81 } else { 82 82 logger.debug('Not initialized yet, skipping auth change handling'); 83 83 } 84 - unawaited(seedDefaults()); 85 84 }); 86 85 87 86 Future.microtask(() async {
+25 -9
lib/src/features/feeds/infrastructure/feed_repository.dart
··· 143 143 'Pinned URIs: ${pinnedStr.length > 500 ? pinnedStr.substring(0, 500) : pinnedStr}', 144 144 ); 145 145 146 - final localFeeds = await _dao.getAllFeeds(ownerDid); 147 146 final now = DateTime.now(); 148 147 final remoteSavedSet = remoteSavedUris.toSet(); 149 148 final feedsToInsert = <SavedFeedsCompanion>[]; ··· 152 151 final feedsToSyncToRemote = <String>[]; 153 152 154 153 final newRemoteFeeds = <String>[]; 154 + 155 155 for (final uri in remoteSavedUris) { 156 - if (uri.startsWith('at://') && 157 - !uri.contains('/app.bsky.graph.list/') && 158 - !localFeeds.any((f) => f.uri == uri)) { 156 + if (uri.startsWith('at://') && !uri.contains('/app.bsky.graph.list/')) { 159 157 newRemoteFeeds.add(uri); 160 158 } 161 159 } ··· 183 181 } else { 184 182 _logger.debug('No new feeds need metadata fetching'); 185 183 } 184 + 185 + final localFeeds = await _dao.getAllFeeds(ownerDid); 186 186 187 187 for (var i = 0; i < remoteSavedUris.length; i++) { 188 188 final remoteUri = remoteSavedUris[i]; ··· 277 277 'isPinned': remoteIsPinned, 278 278 }); 279 279 } else { 280 - _logger.warning('Missing metadata for $remoteUri'); 280 + _logger.warning('Missing metadata for $remoteUri, inserting placeholder'); 281 + feedsToInsert.add( 282 + SavedFeedsCompanion.insert( 283 + uri: remoteUri, 284 + ownerDid: ownerDid, 285 + displayName: 'Unknown Feed', 286 + description: const Value('Metadata unavailable'), 287 + creatorDid: '', 288 + likeCount: const Value(0), 289 + sortOrder: i, 290 + isPinned: Value(remoteIsPinned), 291 + lastSynced: now, 292 + localUpdatedAt: const Value(null), 293 + ), 294 + ); 281 295 } 282 296 } 283 297 } else if (local.localUpdatedAt == null || ··· 941 955 /// 942 956 /// For unauthenticated users (ownerDid="unauthenticated"), ensures Discover feed. 943 957 /// For authenticated users, cleans up legacy/seed feeds that shouldn't be there. 958 + /// 959 + /// We only delete 'home' as it is an internal alias and never a valid remote URI. 960 + /// We leave 'For You' and 'Discover' alone - syncPreferences will remove them 961 + /// if they are not in the user's remote preferences. 944 962 Future<void> seedDefaultFeeds(String ownerDid) async { 945 963 _logger.debug('Seeding default feeds'); 946 964 947 - final migration = await _migrateDeprecatedFeed(ownerDid); // Pass ownerDid if needed 965 + final migration = await _migrateDeprecatedFeed(ownerDid); 948 966 949 967 if (_api.isAuthenticated) { 950 968 await _dao.deleteFeed(kHomeFeedUri, ownerDid); 951 - await _dao.deleteFeed(kForYouFeedUri, ownerDid); 952 - await _dao.deleteFeed(kDiscoverFeedUri, ownerDid); 953 - _logger.debug('Removed seeded feeds for authenticated user'); 969 + _logger.debug('Cleaned up legacy home feed alias for authenticated user'); 954 970 return; 955 971 } 956 972
+17 -7
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
··· 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/src/core/domain/content_label.dart'; 7 + import 'package:lazurite/src/core/utils/logger_provider.dart'; 7 8 import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 8 9 import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 9 10 import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart'; ··· 34 35 @override 35 36 Widget build(BuildContext context, WidgetRef ref) { 36 37 final filterService = ref.watch(labelFilterServiceProvider); 38 + final interactionAsync = ref.watch(postInteractionStateProvider(item.post.uri)); 39 + final interaction = interactionAsync.value; 40 + final logger = ref.read(loggerProvider('[FeedPostCard|${item.post.uri}]')); 37 41 38 42 Map<String, dynamic>? record; 39 43 try { ··· 41 45 if (decoded is Map<String, dynamic>) { 42 46 record = decoded; 43 47 } 44 - } catch (_) { 45 - /* Invalid JSON, use empty record */ 48 + } catch (e) { 49 + logger.warning('Failed to parse record ${item.post.record}: ${e.toString()}'); 46 50 } 51 + 47 52 final text = record?['text'] as String? ?? ''; 48 53 final createdAt = DateTime.tryParse(item.post.indexedAt?.toIso8601String() ?? ''); 49 54 ··· 60 65 if (decoded is Map<String, dynamic>) { 61 66 reasonJson = decoded; 62 67 } 63 - } catch (_) { 64 - /* Invalid JSON, skip reason */ 68 + } catch (e) { 69 + logger.warning('Failed to parse reason ${item.reason}: ${e.toString()}'); 65 70 } 66 71 } 67 72 ··· 93 98 replyCount: item.post.replyCount, 94 99 repostCount: item.post.repostCount, 95 100 likeCount: item.post.likeCount, 96 - viewerLikeUri: item.interaction?.likeUri ?? item.post.viewerLikeUri, 97 - viewerRepostUri: item.interaction?.repostUri ?? item.post.viewerRepostUri, 98 - viewerBookmarked: item.interaction?.bookmarked ?? item.post.viewerBookmarked, 101 + viewerLikeUri: 102 + interaction?.likeUri ?? item.interaction?.likeUri ?? item.post.viewerLikeUri, 103 + viewerRepostUri: 104 + interaction?.repostUri ?? item.interaction?.repostUri ?? item.post.viewerRepostUri, 105 + viewerBookmarked: 106 + interaction?.bookmarked ?? 107 + item.interaction?.bookmarked ?? 108 + item.post.viewerBookmarked, 99 109 onReply: () { 100 110 final encodedUri = Uri.encodeComponent(item.post.uri); 101 111 GoRouter.of(context).push('/compose?replyTo=$encodedUri');
+6
lib/src/features/profile/domain/profile.dart
··· 1 + import 'package:lazurite/src/core/utils/logger.dart'; 2 + 1 3 /// Domain model for profile data. 2 4 class ProfileData { 3 5 factory ProfileData.fromJson(Map<String, dynamic> json) { ··· 157 159 /// Represents a single feed item from author feed. 158 160 class FeedItem { 159 161 factory FeedItem.fromPostView(Map<String, dynamic> json) { 162 + const logger = Logger('[FeedItem]'); 163 + 164 + logger.info('json: $json'); 165 + 160 166 final author = json['author'] as Map<String, dynamic>; 161 167 final record = json['record'] as Map<String, dynamic>; 162 168 final embed = json['embed'] as Map<String, dynamic>?;
+4
lib/src/features/profile/presentation/widgets/pinned_post_card.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/core/utils/logger_provider.dart'; 4 5 import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 5 6 import 'package:lazurite/src/features/auth/domain/auth_state.dart'; 6 7 import 'package:lazurite/src/features/feeds/application/post_interaction_providers.dart'; ··· 22 23 final postAsync = ref.watch(pinnedPostProvider(postUri)); 23 24 final interaction = ref.watch(postInteractionStateProvider(postUri)).value; 24 25 26 + final logger = ref.watch(loggerProvider('[PinnedPostCard]')); 27 + 25 28 return postAsync.when( 26 29 data: (item) { 30 + logger.info('item: $item'); 27 31 if (item == null) return const SizedBox.shrink(); 28 32 29 33 final author = Profile(
+6 -1
lib/src/features/thread/infrastructure/thread_repository.dart
··· 1 1 import 'package:lazurite/src/core/utils/logger.dart'; 2 2 import 'package:lazurite/src/infrastructure/db/app_database.dart'; 3 3 import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart'; 4 + import 'package:lazurite/src/infrastructure/network/network_failure.dart'; 4 5 import 'package:lazurite/src/infrastructure/network/xrpc_client.dart'; 5 6 6 7 import '../domain/thread.dart'; ··· 25 26 26 27 return thread; 27 28 } catch (e, stack) { 28 - _logger.error('Failed to fetch thread', e, stack); 29 + if (e is AuthFailure && e.message?.contains('nonce') == true) { 30 + _logger.warning('Failed to fetch thread (recoverable auth error): ${e.message}'); 31 + } else { 32 + _logger.error('Failed to fetch thread', e, stack); 33 + } 29 34 rethrow; 30 35 } 31 36 }
+27 -23
lib/src/features/thread/presentation/thread_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:lazurite/src/core/utils/error_message.dart'; 4 - import 'package:lazurite/src/core/widgets/error_view.dart'; 5 - import 'package:lazurite/src/core/widgets/loading_view.dart'; 4 + import 'package:lazurite/src/core/widgets/widgets.dart'; 6 5 import 'package:lazurite/src/features/feeds/presentation/screens/widgets/feed_post_card.dart'; 7 6 import 'package:lazurite/src/features/settings/application/settings_providers.dart'; 8 7 import 'package:lazurite/src/features/settings/domain/bluesky_preferences.dart'; ··· 108 107 Widget _buildTreeView(ThreadViewPost thread, ThreadViewPref pref) { 109 108 final parents = thread.parent != null ? _getParents(thread.parent!) : <ThreadViewPost>[]; 110 109 final sortedReplies = _sortReplies(thread.replies, pref); 110 + return PullToRefreshWrapper( 111 + key: ValueKey(thread.post.uri), 112 + onRefresh: () async { 113 + await ref.read(threadProvider(widget.postUri).notifier).refresh(); 114 + }, 115 + child: CustomScrollView( 116 + slivers: [ 117 + if (parents.isNotEmpty) 118 + SliverList( 119 + delegate: SliverChildBuilderDelegate((context, index) { 120 + final parent = parents[index]; 121 + final position = _getThreadLinePosition( 122 + index: index, 123 + total: parents.length, 124 + isParentChain: true, 125 + ); 126 + return _buildThreadPost(parent, position: position); 127 + }, childCount: parents.length), 128 + ), 111 129 112 - return CustomScrollView( 113 - slivers: [ 114 - if (parents.isNotEmpty) 130 + SliverToBoxAdapter(child: _buildFocalPost(thread)), 131 + 115 132 SliverList( 116 133 delegate: SliverChildBuilderDelegate((context, index) { 117 - final parent = parents[index]; 118 - final position = _getThreadLinePosition( 119 - index: index, 120 - total: parents.length, 121 - isParentChain: true, 122 - ); 123 - return _buildThreadPost(parent, position: position); 124 - }, childCount: parents.length), 134 + final reply = sortedReplies[index]; 135 + return _buildReplyTree(reply, depth: 1); 136 + }, childCount: sortedReplies.length), 125 137 ), 126 - 127 - SliverToBoxAdapter(child: _buildFocalPost(thread)), 128 - 129 - SliverList( 130 - delegate: SliverChildBuilderDelegate((context, index) { 131 - final reply = sortedReplies[index]; 132 - return _buildReplyTree(reply, depth: 1); 133 - }, childCount: sortedReplies.length), 134 - ), 135 - ], 138 + ], 139 + ), 136 140 ); 137 141 } 138 142
+8 -1
lib/src/infrastructure/db/daos/saved_feeds_dao.dart
··· 137 137 /// Used during sync merge operations to update sort order, pin status, and 138 138 /// sync timestamps without requiring all fields. This only affects existing 139 139 /// records - it will not insert a new record if the feed doesn't exist. 140 + /// 141 + /// We only apply sync updates if the user hasn't modified it locally since 142 + /// the sync started (implied by localUpdatedAt being null). 143 + /// 144 + /// If clearLocalModification is true, we clear the localUpdatedAt timestamp 145 + /// after successful remote sync. 140 146 Future<int> updateSyncState({ 141 147 required String uri, 142 148 required int sortOrder, ··· 147 153 }) { 148 154 return (update(savedFeeds) 149 155 ..where((t) => t.uri.equals(uri)) 150 - ..where((t) => t.ownerDid.equals(ownerDid))) 156 + ..where((t) => t.ownerDid.equals(ownerDid)) 157 + ..where((t) => t.localUpdatedAt.isNull())) 151 158 .write( 152 159 SavedFeedsCompanion( 153 160 sortOrder: Value(sortOrder),
+22 -2
lib/src/infrastructure/network/interceptors/auth_interceptor.dart
··· 211 211 Future<void> onError(DioException err, ErrorInterceptorHandler handler) async { 212 212 final requiresAuth = err.requestOptions.extra[requiresAuthKey] == true; 213 213 214 - final session = await getSession(); 215 - if (session != null && err.response != null) { 214 + if (err.response != null) { 216 215 final nonce = DPoPNonceStore.extractFromHeaders(err.response!.headers.map); 217 216 if (nonce != null) { 218 217 final origin = _getOrigin(err.requestOptions.uri); ··· 221 220 } 222 221 } 223 222 223 + final session = await getSession(); 224 + 224 225 if (requiresAuth) { 225 226 if (await _tryRefreshAfterInvalidToken(err, handler)) { 226 227 return; ··· 230 231 if (err.response?.statusCode != 401) { 231 232 return handler.next(err); 232 233 } 234 + 235 + final errorCode = _extractErrorCode(err.response); 236 + if (errorCode == 'use_dpop_nonce') { 237 + final hasRetried = err.requestOptions.extra['_authRetried'] == true; 238 + if (!hasRetried) { 239 + if (session != null) { 240 + final origin = _getOrigin(err.requestOptions.uri); 241 + final nonce = _nonceStore.get(origin); 242 + 243 + if (nonce != null) { 244 + _logger.debug('Retrying request with new DPoP nonce'); 245 + return _retryRequestWithSession(err, session, handler); 246 + } else { 247 + _logger.warning('Received use_dpop_nonce but no nonce found in store'); 248 + } 249 + } 250 + } 251 + } 252 + 233 253 if (!requiresAuth) { 234 254 return handler.next(err); 235 255 }
+7 -1
lib/src/infrastructure/network/xrpc_client.dart
··· 74 74 return _parseResponse(response); 75 75 } on DioException catch (e) { 76 76 final failure = _convertError(e); 77 - _logger.error('XRPC call failed: $nsid', failure, e.stackTrace); 77 + 78 + if (failure is AuthFailure && failure.message?.contains('nonce') == true) { 79 + _logger.warning('XRPC auth failure (recoverable): ${failure.message}'); 80 + } else { 81 + _logger.error('XRPC call failed: $nsid', failure, e.stackTrace); 82 + } 83 + 78 84 throw failure; 79 85 } 80 86 }
+5 -2
test/src/features/feeds/infrastructure/feed_repository_query_test.dart
··· 254 254 }); 255 255 256 256 group('seedDefaultFeeds', () { 257 - test('removes all seeded feeds for authenticated users', () async { 257 + test('removes only legacy home alias for authenticated users', () async { 258 258 when(() => mockApi.isAuthenticated).thenReturn(true); 259 259 260 260 await db.savedFeedsDao.upsertFeeds([ ··· 290 290 await repository.seedDefaultFeeds(ownerDid); 291 291 292 292 final feeds = await db.savedFeedsDao.getAllFeeds(ownerDid); 293 - expect(feeds, isEmpty); 293 + expect(feeds, hasLength(2)); 294 + expect(feeds.any((f) => f.uri == FeedRepository.kForYouFeedUri), isTrue); 295 + expect(feeds.any((f) => f.uri == FeedRepository.kDiscoverFeedUri), isTrue); 296 + expect(feeds.any((f) => f.uri == FeedRepository.kHomeFeedUri), isFalse); 294 297 }); 295 298 296 299 test('seeds Discover feed for unauthenticated users', () async {
+5 -2
test/src/features/feeds/infrastructure/feed_repository_sync_test.dart
··· 188 188 await repository.syncPreferences(ownerDid); 189 189 190 190 final feeds = await db.savedFeedsDao.getAllFeeds(ownerDid); 191 - expect(feeds, hasLength(1)); 192 - expect(feeds[0].displayName, 'Test Feed 2'); 191 + expect(feeds, hasLength(2)); 192 + 193 + expect(feeds[0].uri, 'at://did:plc:abc/app.bsky.feed.generator/test1'); 194 + expect(feeds[0].displayName, 'Unknown Feed'); 195 + expect(feeds[1].displayName, 'Test Feed 2'); 193 196 }); 194 197 }); 195 198
+112
test/src/features/feeds/infrastructure/feed_repository_test.dart
··· 1 + import 'package:drift/drift.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/core/utils/logger.dart'; 4 + import 'package:lazurite/src/features/feeds/infrastructure/feed_repository.dart'; 5 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 6 + import 'package:lazurite/src/infrastructure/db/daos/preference_sync_queue_dao.dart'; 7 + import 'package:lazurite/src/infrastructure/db/daos/profile_dao.dart'; 8 + import 'package:lazurite/src/infrastructure/db/daos/saved_feeds_dao.dart'; 9 + import 'package:lazurite/src/infrastructure/network/xrpc_client.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockXrpcClient extends Mock implements XrpcClient {} 13 + 14 + class MockSavedFeedsDao extends Mock implements SavedFeedsDao {} 15 + 16 + class MockPreferenceSyncQueueDao extends Mock implements PreferenceSyncQueueDao {} 17 + 18 + class MockProfileDao extends Mock implements ProfileDao {} 19 + 20 + class MockLogger extends Mock implements Logger {} 21 + 22 + void main() { 23 + late MockXrpcClient mockApi; 24 + late MockSavedFeedsDao mockSavedFeedsDao; 25 + late MockPreferenceSyncQueueDao mockSyncQueueDao; 26 + late MockProfileDao mockProfileDao; 27 + late MockLogger mockLogger; 28 + late FeedRepository repository; 29 + 30 + setUp(() { 31 + mockApi = MockXrpcClient(); 32 + mockSavedFeedsDao = MockSavedFeedsDao(); 33 + mockSyncQueueDao = MockPreferenceSyncQueueDao(); 34 + mockProfileDao = MockProfileDao(); 35 + mockLogger = MockLogger(); 36 + 37 + repository = FeedRepository( 38 + mockApi, 39 + mockSavedFeedsDao, 40 + mockSyncQueueDao, 41 + mockProfileDao, 42 + mockLogger, 43 + ); 44 + 45 + registerFallbackValue( 46 + SavedFeedsCompanion.insert( 47 + uri: 'uri', 48 + ownerDid: 'did', 49 + displayName: 'name', 50 + creatorDid: 'did', 51 + sortOrder: 0, 52 + lastSynced: DateTime.now(), 53 + ), 54 + ); 55 + }); 56 + 57 + group('FeedRepository', () { 58 + test('syncPreferences inserts placeholder when metadata fetch fails', () async { 59 + const ownerDid = 'did:web:test'; 60 + const feedUri = 'at://did:test/app.bsky.feed.generator/test'; 61 + 62 + when(() => mockApi.isAuthenticated).thenReturn(true); 63 + 64 + when(() => mockApi.call('app.bsky.actor.getPreferences')).thenAnswer( 65 + (_) async => { 66 + 'preferences': [ 67 + { 68 + r'$type': 'app.bsky.actor.defs#savedFeedsPrefV2', 69 + 'items': [ 70 + {'type': 'feed', 'value': feedUri, 'pinned': true, 'id': '1'}, 71 + ], 72 + }, 73 + ], 74 + }, 75 + ); 76 + 77 + when(() => mockSavedFeedsDao.getAllFeeds(ownerDid)).thenAnswer((_) async => []); 78 + 79 + when( 80 + () => mockApi.call('app.bsky.feed.getFeedGenerators', params: any(named: 'params')), 81 + ).thenThrow(Exception('Network error')); 82 + 83 + when(() => mockSavedFeedsDao.upsertFeeds(any())).thenAnswer((_) async {}); 84 + when( 85 + () => mockSavedFeedsDao.db, 86 + ).thenThrow(UnimplementedError('DB access not expected unless transaction used')); 87 + 88 + await repository.syncPreferences(ownerDid); 89 + 90 + final captured = verify(() => mockSavedFeedsDao.upsertFeeds(captureAny())).captured; 91 + final insertedFeeds = captured.first as List<SavedFeedsCompanion>; 92 + 93 + expect(insertedFeeds.length, 1); 94 + expect(insertedFeeds.first.uri.value, feedUri); 95 + expect(insertedFeeds.first.displayName.value, 'Unknown Feed'); // This is the placeholder! 96 + expect(insertedFeeds.first.description.value, 'Metadata unavailable'); 97 + }); 98 + test('seedDefaultFeeds only cleans up legacy home alias for authenticated users', () async { 99 + const ownerDid = 'did:web:test'; 100 + when(() => mockApi.isAuthenticated).thenReturn(true); 101 + 102 + when(() => mockSavedFeedsDao.getFeed(any(), any())).thenAnswer((_) async => null); 103 + when(() => mockSavedFeedsDao.deleteFeed(any(), any())).thenAnswer((_) async => 1); 104 + await repository.seedDefaultFeeds(ownerDid); 105 + 106 + verify(() => mockSavedFeedsDao.deleteFeed(FeedRepository.kHomeFeedUri, ownerDid)).called(1); 107 + 108 + verifyNever(() => mockSavedFeedsDao.deleteFeed(FeedRepository.kForYouFeedUri, ownerDid)); 109 + verifyNever(() => mockSavedFeedsDao.deleteFeed(FeedRepository.kDiscoverFeedUri, ownerDid)); 110 + }); 111 + }); 112 + }
+3
test/src/features/feeds/presentation/widgets/feed_preview_modal_test.dart
··· 106 106 expect(find.text('@creator'), findsOneWidget); 107 107 expect(find.text('Author 2'), findsOneWidget); 108 108 expect(find.text('Save'), findsOneWidget); 109 + 110 + await tester.pumpWidget(const SizedBox()); 111 + await tester.pumpAndSettle(); 109 112 }); 110 113 111 114 testWidgets('Save button in FeedPreviewModal calls repository', (tester) async {
+100
test/src/infrastructure/db/daos/saved_feeds_dao_test.dart
··· 528 528 expect(updated, 0); 529 529 }); 530 530 }); 531 + 532 + group('updateSyncState', () { 533 + test('updates sync state when localUpdatedAt is null', () async { 534 + await dao.upsertFeed( 535 + SavedFeedsCompanion.insert( 536 + uri: 'at://did:plc:abc/app.bsky.feed.generator/test', 537 + displayName: 'Test Feed', 538 + creatorDid: 'did:plc:abc', 539 + ownerDid: ownerDid, 540 + sortOrder: 0, 541 + isPinned: const Value(false), 542 + lastSynced: DateTime(2025, 1, 1), 543 + ), 544 + ); 545 + 546 + final updated = await dao.updateSyncState( 547 + uri: 'at://did:plc:abc/app.bsky.feed.generator/test', 548 + sortOrder: 5, 549 + isPinned: true, 550 + lastSynced: DateTime(2025, 1, 2), 551 + ownerDid: ownerDid, 552 + ); 553 + 554 + expect(updated, 1); 555 + 556 + final result = await dao.getFeed('at://did:plc:abc/app.bsky.feed.generator/test', ownerDid); 557 + expect(result!.sortOrder, 5); 558 + expect(result.isPinned, true); 559 + }); 560 + 561 + test('ignores update when localUpdatedAt is set (race condition prevention)', () async { 562 + await dao.upsertFeed( 563 + SavedFeedsCompanion.insert( 564 + uri: 'at://did:plc:abc/app.bsky.feed.generator/conflicted', 565 + displayName: 'Conflicted Feed', 566 + creatorDid: 'did:plc:abc', 567 + ownerDid: ownerDid, 568 + sortOrder: 0, 569 + isPinned: const Value(false), 570 + lastSynced: DateTime(2025, 1, 1), 571 + localUpdatedAt: Value(DateTime.now()), // SIMULATE LOCAL MODIFICATION 572 + ), 573 + ); 574 + 575 + final updated = await dao.updateSyncState( 576 + uri: 'at://did:plc:abc/app.bsky.feed.generator/conflicted', 577 + sortOrder: 5, 578 + isPinned: true, 579 + lastSynced: DateTime(2025, 1, 2), 580 + ownerDid: ownerDid, 581 + clearLocalModification: true, 582 + ); 583 + 584 + expect(updated, 0, reason: 'Should not update rows where localUpdatedAt is not null'); 585 + 586 + final result = await dao.getFeed( 587 + 'at://did:plc:abc/app.bsky.feed.generator/conflicted', 588 + ownerDid, 589 + ); 590 + 591 + expect(result!.sortOrder, 0); 592 + expect(result.isPinned, false); 593 + expect(result.localUpdatedAt, isNotNull); 594 + }); 595 + 596 + test('clears localUpdatedAt when flag is set and update succeeds', () async { 597 + await dao.upsertFeed( 598 + SavedFeedsCompanion.insert( 599 + uri: 'at://did:plc:abc/app.bsky.feed.generator/sync_success', 600 + displayName: 'Synced Feed', 601 + creatorDid: 'did:plc:abc', 602 + ownerDid: ownerDid, 603 + sortOrder: 0, 604 + lastSynced: DateTime(2025, 1, 1), 605 + ), 606 + ); 607 + 608 + final initial = await dao.getFeed( 609 + 'at://did:plc:abc/app.bsky.feed.generator/sync_success', 610 + ownerDid, 611 + ); 612 + expect(initial!.localUpdatedAt, isNull); 613 + 614 + final updated = await dao.updateSyncState( 615 + uri: 'at://did:plc:abc/app.bsky.feed.generator/sync_success', 616 + sortOrder: 1, 617 + isPinned: true, 618 + lastSynced: DateTime(2025, 1, 2), 619 + ownerDid: ownerDid, 620 + clearLocalModification: true, 621 + ); 622 + 623 + expect(updated, 1); 624 + final result = await dao.getFeed( 625 + 'at://did:plc:abc/app.bsky.feed.generator/sync_success', 626 + ownerDid, 627 + ); 628 + expect(result!.localUpdatedAt, isNull); 629 + }); 630 + }); 531 631 }
+46 -1
test/src/infrastructure/network/interceptors/auth_interceptor_test.dart
··· 134 134 }); 135 135 136 136 group('401 retry behavior', () { 137 + test('retries request immediately on use_dpop_nonce without refresh', () async { 138 + final dio = Dio(BaseOptions(baseUrl: 'https://test.api')); 139 + final adapter = DioAdapter(dio: dio); 140 + 141 + var refreshCalled = false; 142 + 143 + dio.interceptors.add( 144 + AuthInterceptor( 145 + getSession: () async => _createTestSession(), 146 + refreshSession: () async { 147 + refreshCalled = true; 148 + return _createTestSession(accessJwt: 'new-token'); 149 + }, 150 + ), 151 + ); 152 + 153 + adapter.onGet( 154 + '/test', 155 + (server) => server.reply( 156 + 401, 157 + {'error': 'use_dpop_nonce'}, 158 + headers: { 159 + 'DPoP-Nonce': ['new-nonce-value'], 160 + }, 161 + ), 162 + headers: { 163 + 'Authorization': ['DPoP test-token'], 164 + }, 165 + ); 166 + 167 + adapter.onGet( 168 + '/test', 169 + (server) => server.reply(200, {'success': true}), 170 + headers: {'Authorization': 'DPoP test-token'}, 171 + ); 172 + 173 + final response = await dio.get( 174 + '/test', 175 + options: Options(extra: {AuthInterceptor.requiresAuthKey: true}), 176 + ); 177 + 178 + expect(response.statusCode, equals(200)); 179 + expect(refreshCalled, isFalse); 180 + }); 181 + 137 182 test('retries request with new token on 401', () async { 138 183 final dio = Dio(BaseOptions(baseUrl: 'https://test.api')); 139 184 final adapter = DioAdapter(dio: dio); ··· 426 471 adapter.onGet('/public', (server) => server.reply(400, {'error': 'InvalidToken'})); 427 472 428 473 try { 429 - await dio.get('/public'); // No requiresAuth flag 474 + await dio.get('/public'); 430 475 fail('Should throw exception'); 431 476 } catch (e) { 432 477 expect(e, isA<DioException>());