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

Configure Feed

Select the types of activity you want to include in your feed.

feat: search starter packs (data layer)

+567 -10
+5 -5
docs/tasks/phase-5.md
··· 9 9 10 10 ### Core 11 11 12 - - [ ] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 13 - - [ ] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension 12 + - [x] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 13 + - [x] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension 14 14 15 15 ### Cubit 16 16 17 - - [ ] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 - - [ ] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields 17 + - [x] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 + - [x] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields 19 19 20 20 ### UI 21 21 ··· 26 26 27 27 ### Tests 28 28 29 - - [ ] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 29 + - [x] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 30 30 - [ ] Widget tests: third tab renders, results display, empty state, tap navigation 31 31 32 32 ## M21 - Suggested Follows Sheet
+46 -2
lib/features/search/bloc/search_bloc.dart
··· 2 2 3 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 6 import 'package:equatable/equatable.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:lazurite/core/database/app_database.dart'; ··· 65 66 } catch (error) { 66 67 emit(SearchState.error(query: query, message: 'Failed to search posts: $error')); 67 68 } 68 - } else { 69 + } else if (currentTab == SearchTab.actors) { 69 70 emit(SearchState.loadingActors(query: query)); 70 71 71 72 try { ··· 83 84 } catch (error) { 84 85 emit(SearchState.error(query: query, message: 'Failed to search actors: $error')); 85 86 } 87 + } else { 88 + emit(SearchState.loadingStarterPacks(query: query)); 89 + 90 + try { 91 + final result = await _searchRepository.searchStarterPacks(query: query, limit: 25); 92 + 93 + emit( 94 + SearchState.loadedStarterPacks( 95 + query: query, 96 + starterPacks: result.starterPacks, 97 + starterPacksCursor: result.cursor, 98 + ), 99 + ); 100 + } catch (error) { 101 + emit(SearchState.error(query: query, message: 'Failed to search starter packs: $error')); 102 + } 86 103 } 87 104 } 88 105 ··· 107 124 } 108 125 109 126 Future<void> _onLoadMoreRequested(LoadMoreRequested event, Emitter<SearchState> emit) async { 110 - if (state.isLoadingMore || state.cursor == null) return; 127 + if (state.isLoadingMore) return; 128 + 129 + if (state.currentTab == SearchTab.starterPacks) { 130 + if (state.starterPacksCursor == null) return; 131 + 132 + emit(state.copyWith(isLoadingMore: true)); 133 + 134 + try { 135 + final result = await _searchRepository.searchStarterPacks( 136 + query: state.query, 137 + cursor: state.starterPacksCursor, 138 + limit: 25, 139 + ); 140 + 141 + emit( 142 + SearchState.loadedStarterPacks( 143 + query: state.query, 144 + starterPacks: [...state.starterPacks, ...result.starterPacks], 145 + starterPacksCursor: result.cursor, 146 + ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 147 + ); 148 + } catch (error) { 149 + emit(state.copyWith(isLoadingMore: false)); 150 + } 151 + return; 152 + } 153 + 154 + if (state.cursor == null) return; 111 155 112 156 emit(state.copyWith(isLoadingMore: true)); 113 157
+29 -3
lib/features/search/bloc/search_state.dart
··· 1 1 part of 'search_bloc.dart'; 2 2 3 - enum SearchTab { posts, actors } 3 + enum SearchTab { posts, actors, starterPacks } 4 4 5 5 extension SearchTabLabel on SearchTab { 6 6 String get label => switch (this) { 7 7 SearchTab.posts => 'Posts', 8 8 SearchTab.actors => 'People', 9 + SearchTab.starterPacks => 'Starter Packs', 9 10 }; 10 11 } 11 12 ··· 38 39 this.currentSort = 'top', 39 40 this.posts = const [], 40 41 this.actors = const [], 42 + this.starterPacks = const [], 41 43 this.cursor, 44 + this.starterPacksCursor, 42 45 this.hitsTotal, 43 46 this.errorMessage, 44 47 this.isLoadingMore = false, ··· 53 56 54 57 const SearchState.loadingActors({required String query}) 55 58 : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.actors); 59 + 60 + const SearchState.loadingStarterPacks({required String query}) 61 + : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.starterPacks); 56 62 57 63 const SearchState.loadedPosts({ 58 64 required String query, ··· 72 78 const SearchState.loadedActors({required String query, required List<ProfileView> actors, String? cursor}) 73 79 : this._(status: SearchStatus.loaded, query: query, currentTab: SearchTab.actors, actors: actors, cursor: cursor); 74 80 81 + const SearchState.loadedStarterPacks({ 82 + required String query, 83 + required List<StarterPackViewBasic> starterPacks, 84 + String? starterPacksCursor, 85 + }) : this._( 86 + status: SearchStatus.loaded, 87 + query: query, 88 + currentTab: SearchTab.starterPacks, 89 + starterPacks: starterPacks, 90 + starterPacksCursor: starterPacksCursor, 91 + ); 92 + 75 93 const SearchState.error({required String query, required String message}) 76 94 : this._(status: SearchStatus.error, query: query, errorMessage: message); 77 95 ··· 81 99 final String currentSort; 82 100 final List<PostView> posts; 83 101 final List<ProfileView> actors; 102 + final List<StarterPackViewBasic> starterPacks; 84 103 final String? cursor; 104 + final String? starterPacksCursor; 85 105 final int? hitsTotal; 86 106 final String? errorMessage; 87 107 final bool isLoadingMore; ··· 90 110 91 111 bool get isLoading => status == SearchStatus.loading; 92 112 bool get hasError => status == SearchStatus.error; 93 - bool get hasResults => posts.isNotEmpty || actors.isNotEmpty; 94 - bool get hasMore => cursor != null; 113 + bool get hasResults => posts.isNotEmpty || actors.isNotEmpty || starterPacks.isNotEmpty; 114 + bool get hasMore => cursor != null || starterPacksCursor != null; 95 115 96 116 SearchState copyWith({ 97 117 SearchStatus? status, ··· 100 120 String? currentSort, 101 121 List<PostView>? posts, 102 122 List<ProfileView>? actors, 123 + List<StarterPackViewBasic>? starterPacks, 103 124 String? cursor, 125 + String? starterPacksCursor, 104 126 int? hitsTotal, 105 127 String? errorMessage, 106 128 bool? isLoadingMore, ··· 114 136 currentSort: currentSort ?? this.currentSort, 115 137 posts: posts ?? this.posts, 116 138 actors: actors ?? this.actors, 139 + starterPacks: starterPacks ?? this.starterPacks, 117 140 cursor: cursor ?? this.cursor, 141 + starterPacksCursor: starterPacksCursor ?? this.starterPacksCursor, 118 142 hitsTotal: hitsTotal ?? this.hitsTotal, 119 143 errorMessage: errorMessage ?? this.errorMessage, 120 144 isLoadingMore: isLoadingMore ?? this.isLoadingMore, ··· 131 155 currentSort, 132 156 posts, 133 157 actors, 158 + starterPacks, 134 159 cursor, 160 + starterPacksCursor, 135 161 hitsTotal, 136 162 errorMessage, 137 163 isLoadingMore,
+14
lib/features/search/data/search_repository.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_searchposts.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 4 5 import 'package:bluesky/bluesky.dart'; 5 6 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 6 7 ··· 46 47 ); 47 48 48 49 return SearchActorsResult(actors: _filterProfiles(response.data.actors), cursor: response.data.cursor); 50 + } 51 + 52 + Future<SearchStarterPacksResult> searchStarterPacks({required String query, String? cursor, int limit = 25}) async { 53 + final response = await _bluesky.graph.searchStarterPacks(q: query, cursor: cursor, limit: limit); 54 + 55 + return SearchStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); 49 56 } 50 57 51 58 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 10}) async { ··· 100 107 final List<ProfileView> actors; 101 108 final String? cursor; 102 109 } 110 + 111 + class SearchStarterPacksResult { 112 + SearchStarterPacksResult({required this.starterPacks, this.cursor}); 113 + 114 + final List<StarterPackViewBasic> starterPacks; 115 + final String? cursor; 116 + }
+366
test/features/search/bloc/search_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_graph_defs.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 9 + import 'package:lazurite/features/search/data/search_repository.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockSearchRepository extends Mock implements SearchRepository {} 13 + 14 + class MockAppDatabase extends Mock implements AppDatabase {} 15 + 16 + void main() { 17 + late MockSearchRepository mockRepository; 18 + late MockAppDatabase mockDatabase; 19 + 20 + final samplePost = PostView( 21 + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'), 22 + cid: 'cid-post', 23 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 24 + record: const {r'$type': 'app.bsky.feed.post', 'text': 'Hello world', 'createdAt': '2026-01-01T00:00:00.000Z'}, 25 + indexedAt: DateTime.utc(2026, 1, 1), 26 + ); 27 + 28 + final sampleActor = ProfileView( 29 + did: 'did:plc:actor', 30 + handle: 'actor.bsky.social', 31 + indexedAt: DateTime.utc(2026, 1, 1), 32 + ); 33 + 34 + final sampleStarterPack = StarterPackViewBasic( 35 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 36 + cid: 'cid-pack-1', 37 + record: const { 38 + r'$type': 'app.bsky.graph.starterpack', 39 + 'name': 'Test Starter Pack', 40 + 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 41 + 'createdAt': '2026-01-01T00:00:00.000Z', 42 + }, 43 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 44 + indexedAt: DateTime.utc(2026, 1, 1), 45 + ); 46 + 47 + setUp(() { 48 + mockRepository = MockSearchRepository(); 49 + mockDatabase = MockAppDatabase(); 50 + 51 + when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); 52 + when( 53 + () => mockDatabase.addSearchHistoryEntry( 54 + query: any(named: 'query'), 55 + type: any(named: 'type'), 56 + accountDid: any(named: 'accountDid'), 57 + ), 58 + ).thenAnswer((_) async {}); 59 + when( 60 + () => mockRepository.searchPosts( 61 + query: any(named: 'query'), 62 + sort: any(named: 'sort'), 63 + cursor: any(named: 'cursor'), 64 + limit: any(named: 'limit'), 65 + ), 66 + ).thenAnswer((_) async => SearchPostsResult(posts: [])); 67 + when( 68 + () => mockRepository.searchActors( 69 + query: any(named: 'query'), 70 + cursor: any(named: 'cursor'), 71 + limit: any(named: 'limit'), 72 + ), 73 + ).thenAnswer((_) async => SearchActorsResult(actors: [])); 74 + when( 75 + () => mockRepository.searchStarterPacks( 76 + query: any(named: 'query'), 77 + cursor: any(named: 'cursor'), 78 + limit: any(named: 'limit'), 79 + ), 80 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [])); 81 + }); 82 + 83 + SearchBloc buildBloc() => 84 + SearchBloc(searchRepository: mockRepository, database: mockDatabase, accountDid: 'did:plc:test'); 85 + 86 + group('SearchTab enum', () { 87 + test('has posts, actors, and starterPacks values', () { 88 + expect(SearchTab.values, containsAll([SearchTab.posts, SearchTab.actors, SearchTab.starterPacks])); 89 + }); 90 + 91 + test('SearchTabLabel returns correct labels', () { 92 + expect(SearchTab.posts.label, 'Posts'); 93 + expect(SearchTab.actors.label, 'People'); 94 + expect(SearchTab.starterPacks.label, 'Starter Packs'); 95 + }); 96 + }); 97 + 98 + group('SearchState starter packs fields', () { 99 + test('initial state has empty starterPacks and null starterPacksCursor', () { 100 + const state = SearchState.initial(); 101 + expect(state.starterPacks, isEmpty); 102 + expect(state.starterPacksCursor, isNull); 103 + }); 104 + 105 + test('loadingStarterPacks sets correct tab and status', () { 106 + const state = SearchState.loadingStarterPacks(query: 'test'); 107 + expect(state.status, SearchStatus.loading); 108 + expect(state.currentTab, SearchTab.starterPacks); 109 + expect(state.query, 'test'); 110 + }); 111 + 112 + test('loadedStarterPacks sets packs and cursor', () { 113 + final pack = sampleStarterPack; 114 + final state = SearchState.loadedStarterPacks(query: 'test', starterPacks: [pack], starterPacksCursor: 'cursor-1'); 115 + expect(state.status, SearchStatus.loaded); 116 + expect(state.currentTab, SearchTab.starterPacks); 117 + expect(state.starterPacks, [pack]); 118 + expect(state.starterPacksCursor, 'cursor-1'); 119 + }); 120 + 121 + test('hasResults is true when starterPacks non-empty', () { 122 + final state = SearchState.loadedStarterPacks(query: 'test', starterPacks: [sampleStarterPack]); 123 + expect(state.hasResults, isTrue); 124 + }); 125 + 126 + test('hasMore is true when starterPacksCursor is non-null', () { 127 + final state = SearchState.loadedStarterPacks( 128 + query: 'test', 129 + starterPacks: [sampleStarterPack], 130 + starterPacksCursor: 'next', 131 + ); 132 + expect(state.hasMore, isTrue); 133 + }); 134 + 135 + test('copyWith preserves starterPacks and starterPacksCursor', () { 136 + const base = SearchState.initial(); 137 + final updated = base.copyWith(starterPacks: [sampleStarterPack], starterPacksCursor: 'cursor-x'); 138 + expect(updated.starterPacks, [sampleStarterPack]); 139 + expect(updated.starterPacksCursor, 'cursor-x'); 140 + }); 141 + }); 142 + 143 + group('QuerySubmitted - starterPacks tab', () { 144 + blocTest<SearchBloc, SearchState>( 145 + 'searches starter packs when starterPacks tab is active', 146 + build: buildBloc, 147 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 148 + setUp: () { 149 + when( 150 + () => mockRepository.searchStarterPacks( 151 + query: 'flutter', 152 + cursor: any(named: 'cursor'), 153 + limit: any(named: 'limit'), 154 + ), 155 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack], cursor: 'c1')); 156 + }, 157 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'flutter')), 158 + expect: () => [ 159 + predicate<SearchState>( 160 + (s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.starterPacks && s.query == 'flutter', 161 + ), 162 + predicate<SearchState>( 163 + (s) => 164 + s.status == SearchStatus.loaded && 165 + s.currentTab == SearchTab.starterPacks && 166 + s.starterPacks.length == 1 && 167 + s.starterPacksCursor == 'c1', 168 + ), 169 + ], 170 + ); 171 + 172 + blocTest<SearchBloc, SearchState>( 173 + 'emits error when starterPacks search fails', 174 + build: buildBloc, 175 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 176 + setUp: () { 177 + when( 178 + () => mockRepository.searchStarterPacks( 179 + query: any(named: 'query'), 180 + cursor: any(named: 'cursor'), 181 + limit: any(named: 'limit'), 182 + ), 183 + ).thenThrow(Exception('network error')); 184 + }, 185 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'fail')), 186 + expect: () => [ 187 + predicate<SearchState>((s) => s.status == SearchStatus.loading), 188 + predicate<SearchState>((s) => s.status == SearchStatus.error && s.errorMessage != null), 189 + ], 190 + ); 191 + 192 + blocTest<SearchBloc, SearchState>( 193 + 'does not add search history entry for starterPacks tab', 194 + build: buildBloc, 195 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 196 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'flutter')), 197 + verify: (_) { 198 + verifyNever( 199 + () => mockDatabase.addSearchHistoryEntry( 200 + query: any(named: 'query'), 201 + type: any(named: 'type'), 202 + accountDid: any(named: 'accountDid'), 203 + ), 204 + ); 205 + }, 206 + ); 207 + }); 208 + 209 + group('SearchTabChanged', () { 210 + blocTest<SearchBloc, SearchState>( 211 + 'switches to starterPacks tab and re-searches when query present', 212 + build: buildBloc, 213 + seed: () => SearchState.loadedPosts(query: 'flutter', sort: 'top', posts: [samplePost], cursor: null), 214 + setUp: () { 215 + when( 216 + () => mockRepository.searchStarterPacks( 217 + query: 'flutter', 218 + cursor: any(named: 'cursor'), 219 + limit: any(named: 'limit'), 220 + ), 221 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack])); 222 + }, 223 + act: (bloc) => bloc.add(const SearchTabChanged(tab: SearchTab.starterPacks)), 224 + expect: () => [ 225 + predicate<SearchState>((s) => s.currentTab == SearchTab.starterPacks), 226 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.starterPacks), 227 + predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.starterPacks.length == 1), 228 + ], 229 + ); 230 + 231 + blocTest<SearchBloc, SearchState>( 232 + 'switches from starterPacks tab to posts tab and re-searches', 233 + build: buildBloc, 234 + seed: () => SearchState.loadedStarterPacks(query: 'flutter', starterPacks: [sampleStarterPack]), 235 + setUp: () { 236 + when( 237 + () => mockRepository.searchPosts( 238 + query: 'flutter', 239 + sort: any(named: 'sort'), 240 + cursor: any(named: 'cursor'), 241 + limit: any(named: 'limit'), 242 + ), 243 + ).thenAnswer((_) async => SearchPostsResult(posts: [samplePost])); 244 + }, 245 + act: (bloc) => bloc.add(const SearchTabChanged(tab: SearchTab.posts)), 246 + expect: () => [ 247 + predicate<SearchState>((s) => s.currentTab == SearchTab.posts), 248 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.posts), 249 + predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.posts.length == 1), 250 + ], 251 + ); 252 + }); 253 + 254 + group('LoadMoreRequested - starterPacks tab', () { 255 + blocTest<SearchBloc, SearchState>( 256 + 'loads more starter packs using starterPacksCursor', 257 + build: buildBloc, 258 + seed: () => SearchState.loadedStarterPacks( 259 + query: 'flutter', 260 + starterPacks: [sampleStarterPack], 261 + starterPacksCursor: 'cursor-page-1', 262 + ), 263 + setUp: () { 264 + final secondPack = StarterPackViewBasic( 265 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-2'), 266 + cid: 'cid-pack-2', 267 + record: const { 268 + r'$type': 'app.bsky.graph.starterpack', 269 + 'name': 'Another Pack', 270 + 'list': 'at://did:plc:creator/app.bsky.graph.list/list-2', 271 + 'createdAt': '2026-01-01T00:00:00.000Z', 272 + }, 273 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 274 + indexedAt: DateTime.utc(2026, 1, 1), 275 + ); 276 + when( 277 + () => mockRepository.searchStarterPacks( 278 + query: 'flutter', 279 + cursor: 'cursor-page-1', 280 + limit: any(named: 'limit'), 281 + ), 282 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [secondPack], cursor: null)); 283 + }, 284 + act: (bloc) => bloc.add(const LoadMoreRequested()), 285 + expect: () => [ 286 + predicate<SearchState>((s) => s.isLoadingMore), 287 + predicate<SearchState>((s) => !s.isLoadingMore && s.starterPacks.length == 2 && s.starterPacksCursor == null), 288 + ], 289 + ); 290 + 291 + blocTest<SearchBloc, SearchState>( 292 + 'does not load more when starterPacksCursor is null', 293 + build: buildBloc, 294 + seed: () => 295 + SearchState.loadedStarterPacks(query: 'flutter', starterPacks: [sampleStarterPack], starterPacksCursor: null), 296 + act: (bloc) => bloc.add(const LoadMoreRequested()), 297 + expect: () => [], 298 + ); 299 + 300 + blocTest<SearchBloc, SearchState>( 301 + 'handles load more error gracefully', 302 + build: buildBloc, 303 + seed: () => SearchState.loadedStarterPacks( 304 + query: 'flutter', 305 + starterPacks: [sampleStarterPack], 306 + starterPacksCursor: 'cursor-1', 307 + ), 308 + setUp: () { 309 + when( 310 + () => mockRepository.searchStarterPacks( 311 + query: any(named: 'query'), 312 + cursor: any(named: 'cursor'), 313 + limit: any(named: 'limit'), 314 + ), 315 + ).thenThrow(Exception('network error')); 316 + }, 317 + act: (bloc) => bloc.add(const LoadMoreRequested()), 318 + expect: () => [ 319 + predicate<SearchState>((s) => s.isLoadingMore), 320 + predicate<SearchState>((s) => !s.isLoadingMore && s.starterPacks.length == 1), 321 + ], 322 + ); 323 + }); 324 + 325 + group('Existing tab behavior unaffected', () { 326 + blocTest<SearchBloc, SearchState>( 327 + 'searches posts when posts tab is active', 328 + build: buildBloc, 329 + setUp: () { 330 + when( 331 + () => mockRepository.searchPosts( 332 + query: 'hello', 333 + sort: any(named: 'sort'), 334 + cursor: any(named: 'cursor'), 335 + limit: any(named: 'limit'), 336 + ), 337 + ).thenAnswer((_) async => SearchPostsResult(posts: [samplePost], cursor: 'c1')); 338 + }, 339 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'hello')), 340 + expect: () => [ 341 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.posts), 342 + predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.posts.length == 1), 343 + ], 344 + ); 345 + 346 + blocTest<SearchBloc, SearchState>( 347 + 'searches actors when actors tab is active', 348 + build: buildBloc, 349 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.actors), 350 + setUp: () { 351 + when( 352 + () => mockRepository.searchActors( 353 + query: 'alice', 354 + cursor: any(named: 'cursor'), 355 + limit: any(named: 'limit'), 356 + ), 357 + ).thenAnswer((_) async => SearchActorsResult(actors: [sampleActor])); 358 + }, 359 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'alice')), 360 + expect: () => [ 361 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.actors), 362 + predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.actors.length == 1), 363 + ], 364 + ); 365 + }); 366 + }
+107
test/features/search/data/search_repository_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_graph_defs.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/search/data/search_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockSearchRepository extends Mock implements SearchRepository {} 9 + 10 + void main() { 11 + late MockSearchRepository mockRepository; 12 + 13 + setUp(() { 14 + mockRepository = MockSearchRepository(); 15 + }); 16 + 17 + final sampleStarterPack = StarterPackViewBasic( 18 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 19 + cid: 'cid-pack-1', 20 + record: const { 21 + r'$type': 'app.bsky.graph.starterpack', 22 + 'name': 'Test Starter Pack', 23 + 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 24 + 'createdAt': '2026-01-01T00:00:00.000Z', 25 + }, 26 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 27 + indexedAt: DateTime.utc(2026, 1, 1), 28 + ); 29 + 30 + group('SearchRepository.searchStarterPacks', () { 31 + test('returns result with starterPacks and cursor', () async { 32 + when( 33 + () => mockRepository.searchStarterPacks( 34 + query: 'flutter', 35 + cursor: any(named: 'cursor'), 36 + limit: any(named: 'limit'), 37 + ), 38 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack], cursor: 'next-cursor')); 39 + 40 + final result = await mockRepository.searchStarterPacks(query: 'flutter'); 41 + 42 + expect(result.starterPacks.length, 1); 43 + expect(result.cursor, 'next-cursor'); 44 + }); 45 + 46 + test('returns empty result when no packs found', () async { 47 + when( 48 + () => mockRepository.searchStarterPacks( 49 + query: any(named: 'query'), 50 + cursor: any(named: 'cursor'), 51 + limit: any(named: 'limit'), 52 + ), 53 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [])); 54 + 55 + final result = await mockRepository.searchStarterPacks(query: 'noresults'); 56 + 57 + expect(result.starterPacks, isEmpty); 58 + expect(result.cursor, isNull); 59 + }); 60 + 61 + test('passes cursor for pagination', () async { 62 + when( 63 + () => mockRepository.searchStarterPacks( 64 + query: 'test', 65 + cursor: 'page-2-cursor', 66 + limit: any(named: 'limit'), 67 + ), 68 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack], cursor: 'page-3-cursor')); 69 + 70 + final result = await mockRepository.searchStarterPacks(query: 'test', cursor: 'page-2-cursor'); 71 + 72 + expect(result.cursor, 'page-3-cursor'); 73 + verify( 74 + () => mockRepository.searchStarterPacks( 75 + query: 'test', 76 + cursor: 'page-2-cursor', 77 + limit: any(named: 'limit'), 78 + ), 79 + ).called(1); 80 + }); 81 + 82 + test('propagates exceptions', () async { 83 + when( 84 + () => mockRepository.searchStarterPacks( 85 + query: any(named: 'query'), 86 + cursor: any(named: 'cursor'), 87 + limit: any(named: 'limit'), 88 + ), 89 + ).thenThrow(Exception('network error')); 90 + 91 + expect(() => mockRepository.searchStarterPacks(query: 'fail'), throwsException); 92 + }); 93 + }); 94 + 95 + group('SearchStarterPacksResult', () { 96 + test('holds starterPacks and optional cursor', () { 97 + final result = SearchStarterPacksResult(starterPacks: [sampleStarterPack], cursor: 'cursor'); 98 + expect(result.starterPacks.length, 1); 99 + expect(result.cursor, 'cursor'); 100 + }); 101 + 102 + test('cursor defaults to null', () { 103 + final result = SearchStarterPacksResult(starterPacks: []); 104 + expect(result.cursor, isNull); 105 + }); 106 + }); 107 + }