+2
-2
justfile
+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
-1
lib/src/features/feeds/application/feed_sync_controller.dart
+25
-9
lib/src/features/feeds/infrastructure/feed_repository.dart
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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>());