From e06e125693ab838ea9f6dfc83097d0e983dca975 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 22 Mar 2026 00:50:01 -0500 Subject: [PATCH] feat: starter pack management BLoC & Cubit --- docs/tasks/phase-4.md | 4 +- .../starter_packs/bloc/starter_pack_bloc.dart | 163 +++++++ .../bloc/starter_pack_event.dart | 74 ++++ .../bloc/starter_pack_state.dart | 71 +++ .../cubit/actor_starter_packs_cubit.dart | 86 ++++ .../cubit/actor_starter_packs_state.dart | 95 ++++ .../data/starter_pack_repository.dart | 131 ++++++ .../presentation/list_detail_screen_test.dart | 4 +- .../presentation/my_lists_screen_test.dart | 2 +- .../bloc/starter_pack_bloc_test.dart | 418 ++++++++++++++++++ .../cubit/actor_starter_packs_cubit_test.dart | 198 +++++++++ 11 files changed, 1241 insertions(+), 5 deletions(-) create mode 100644 lib/features/starter_packs/bloc/starter_pack_bloc.dart create mode 100644 lib/features/starter_packs/bloc/starter_pack_event.dart create mode 100644 lib/features/starter_packs/bloc/starter_pack_state.dart create mode 100644 lib/features/starter_packs/cubit/actor_starter_packs_cubit.dart create mode 100644 lib/features/starter_packs/cubit/actor_starter_packs_state.dart create mode 100644 lib/features/starter_packs/data/starter_pack_repository.dart create mode 100644 test/features/starter_packs/bloc/starter_pack_bloc_test.dart create mode 100644 test/features/starter_packs/cubit/actor_starter_packs_cubit_test.dart diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index 9d3dc7a..981e04b 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -75,8 +75,8 @@ Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) ### Core -- [ ] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` -- [ ] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` +- [x] `StarterPackBloc` — events: `StarterPackRequested`, `StarterPackCreated`, `StarterPackUpdated`, `StarterPackDeleted`, `MemberAdded`, `MemberRemoved` +- [x] `ActorStarterPacksCubit` — load starter packs for an actor via `getActorStarterPacks` ### Viewing diff --git a/lib/features/starter_packs/bloc/starter_pack_bloc.dart b/lib/features/starter_packs/bloc/starter_pack_bloc.dart new file mode 100644 index 0000000..911ce24 --- /dev/null +++ b/lib/features/starter_packs/bloc/starter_pack_bloc.dart @@ -0,0 +1,163 @@ +import 'package:atproto_core/atproto_core.dart' show AtUri; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; + +part 'starter_pack_event.dart'; +part 'starter_pack_state.dart'; + +class StarterPackBloc extends Bloc { + StarterPackBloc({required StarterPackRepository starterPackRepository}) + : _starterPackRepository = starterPackRepository, + super(const StarterPackState.initial()) { + on(_onStarterPackRequested); + on(_onStarterPackCreated); + on(_onStarterPackUpdated); + on(_onStarterPackDeleted); + on(_onMemberAdded); + on(_onMemberRemoved); + } + + final StarterPackRepository _starterPackRepository; + + Future _onStarterPackRequested(StarterPackRequested event, Emitter emit) async { + emit(StarterPackState.loading(packUri: event.starterPackUri)); + + try { + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: event.starterPackUri); + + emit(StarterPackState.loaded(packUri: event.starterPackUri, starterPack: starterPack)); + } catch (error) { + emit(StarterPackState.error(message: 'Failed to load starter pack: $error', packUri: event.starterPackUri)); + } + } + + Future _onStarterPackCreated(StarterPackCreated event, Emitter emit) async { + emit(const StarterPackState.loading()); + + try { + final packUri = await _starterPackRepository.createStarterPack( + userDid: event.userDid, + name: event.name, + description: event.description, + memberDids: event.memberDids, + feedUris: event.feedUris, + ); + + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: packUri); + + emit(StarterPackState.loaded(packUri: packUri, starterPack: starterPack)); + } catch (error) { + emit(StarterPackState.error(message: 'Failed to create starter pack: $error')); + } + } + + Future _onStarterPackUpdated(StarterPackUpdated event, Emitter emit) async { + final packUri = state.packUri; + final refListUri = state.starterPack?.list?.uri; + + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { + return; + } + + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + await _starterPackRepository.updateStarterPack( + packUri: packUri, + referenceListUri: refListUri, + name: event.name, + description: event.description, + feedUris: event.feedUris, + ); + + await _reloadPack(emit, packUri: packUri, errorPrefix: 'Failed to update starter pack'); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to update starter pack: $error')); + } + } + + Future _onStarterPackDeleted(StarterPackDeleted event, Emitter emit) async { + final packUri = state.packUri; + final refListUri = state.starterPack?.list?.uri; + + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { + return; + } + + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + await _starterPackRepository.deleteStarterPack( + packUri: packUri, + referenceListUri: refListUri, + userDid: event.userDid, + ); + + emit(const StarterPackState.deleted()); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to delete starter pack: $error')); + } + } + + Future _onMemberAdded(MemberAdded event, Emitter emit) async { + final packUri = state.packUri; + final refListUri = state.starterPack?.list?.uri; + + if (state.status != StarterPackStatus.loaded || packUri == null || refListUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + packUri: packUri, + action: () => _starterPackRepository.addMember(listUri: refListUri, subjectDid: event.subjectDid), + errorPrefix: 'Failed to add member', + ); + } + + Future _onMemberRemoved(MemberRemoved event, Emitter emit) async { + final packUri = state.packUri; + + if (state.status != StarterPackStatus.loaded || packUri == null || state.isMutating) { + return; + } + + await _runMutation( + emit, + packUri: packUri, + action: () => _starterPackRepository.removeMember(listItemUri: event.listItemUri), + errorPrefix: 'Failed to remove member', + ); + } + + Future _runMutation( + Emitter emit, { + required AtUri packUri, + required Future Function() action, + required String errorPrefix, + }) async { + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + await action(); + await _reloadPack(emit, packUri: packUri, errorPrefix: errorPrefix); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: '$errorPrefix: $error')); + } + } + + Future _reloadPack( + Emitter emit, { + required AtUri packUri, + required String errorPrefix, + }) async { + try { + final starterPack = await _starterPackRepository.getStarterPack(starterPackUri: packUri); + emit(StarterPackState.loaded(packUri: packUri, starterPack: starterPack)); + } catch (error) { + emit(state.copyWith(isMutating: false, isRefreshing: false, errorMessage: '$errorPrefix: $error')); + } + } +} diff --git a/lib/features/starter_packs/bloc/starter_pack_event.dart b/lib/features/starter_packs/bloc/starter_pack_event.dart new file mode 100644 index 0000000..8243f32 --- /dev/null +++ b/lib/features/starter_packs/bloc/starter_pack_event.dart @@ -0,0 +1,74 @@ +part of 'starter_pack_bloc.dart'; + +sealed class StarterPackEvent extends Equatable { + const StarterPackEvent(); + + @override + List get props => []; +} + +final class StarterPackRequested extends StarterPackEvent { + const StarterPackRequested({required this.starterPackUri}); + + final AtUri starterPackUri; + + @override + List get props => [starterPackUri]; +} + +final class StarterPackCreated extends StarterPackEvent { + const StarterPackCreated({ + required this.userDid, + required this.name, + this.description, + this.memberDids = const [], + this.feedUris = const [], + }); + + final String userDid; + final String name; + final String? description; + final List memberDids; + final List feedUris; + + @override + List get props => [userDid, name, description, memberDids, feedUris]; +} + +final class StarterPackUpdated extends StarterPackEvent { + const StarterPackUpdated({required this.name, this.description, this.feedUris = const []}); + + final String name; + final String? description; + final List feedUris; + + @override + List get props => [name, description, feedUris]; +} + +final class StarterPackDeleted extends StarterPackEvent { + const StarterPackDeleted({required this.userDid}); + + final String userDid; + + @override + List get props => [userDid]; +} + +final class MemberAdded extends StarterPackEvent { + const MemberAdded({required this.subjectDid}); + + final String subjectDid; + + @override + List get props => [subjectDid]; +} + +final class MemberRemoved extends StarterPackEvent { + const MemberRemoved({required this.listItemUri}); + + final AtUri listItemUri; + + @override + List get props => [listItemUri]; +} diff --git a/lib/features/starter_packs/bloc/starter_pack_state.dart b/lib/features/starter_packs/bloc/starter_pack_state.dart new file mode 100644 index 0000000..a877eb9 --- /dev/null +++ b/lib/features/starter_packs/bloc/starter_pack_state.dart @@ -0,0 +1,71 @@ +part of 'starter_pack_bloc.dart'; + +enum StarterPackStatus { initial, loading, loaded, error, deleted } + +class StarterPackState extends Equatable { + const StarterPackState._({ + required this.status, + this.packUri, + this.starterPack, + this.isRefreshing = false, + this.isMutating = false, + this.errorMessage, + }); + + const StarterPackState.initial() : this._(status: StarterPackStatus.initial); + + const StarterPackState.loading({AtUri? packUri}) : this._(status: StarterPackStatus.loading, packUri: packUri); + + const StarterPackState.loaded({ + required AtUri packUri, + required StarterPackView starterPack, + bool isRefreshing = false, + bool isMutating = false, + String? errorMessage, + }) : this._( + status: StarterPackStatus.loaded, + packUri: packUri, + starterPack: starterPack, + isRefreshing: isRefreshing, + isMutating: isMutating, + errorMessage: errorMessage, + ); + + const StarterPackState.error({required String message, AtUri? packUri}) + : this._(status: StarterPackStatus.error, packUri: packUri, errorMessage: message); + + const StarterPackState.deleted() : this._(status: StarterPackStatus.deleted); + + final StarterPackStatus status; + final AtUri? packUri; + final StarterPackView? starterPack; + final bool isRefreshing; + final bool isMutating; + final String? errorMessage; + + bool get isLoading => status == StarterPackStatus.loading; + bool get hasError => status == StarterPackStatus.error || errorMessage != null; + + StarterPackState copyWith({ + StarterPackStatus? status, + Object? packUri = _starterPackNoValue, + Object? starterPack = _starterPackNoValue, + bool? isRefreshing, + bool? isMutating, + Object? errorMessage = _starterPackNoValue, + }) { + return StarterPackState._( + status: status ?? this.status, + packUri: packUri == _starterPackNoValue ? this.packUri : packUri as AtUri?, + starterPack: starterPack == _starterPackNoValue ? this.starterPack : starterPack as StarterPackView?, + isRefreshing: isRefreshing ?? this.isRefreshing, + isMutating: isMutating ?? this.isMutating, + errorMessage: errorMessage == _starterPackNoValue ? this.errorMessage : errorMessage as String?, + ); + } + + @override + List get props => [status, packUri, starterPack, isRefreshing, isMutating, errorMessage]; +} + +const _starterPackNoValue = Object(); diff --git a/lib/features/starter_packs/cubit/actor_starter_packs_cubit.dart b/lib/features/starter_packs/cubit/actor_starter_packs_cubit.dart new file mode 100644 index 0000000..fcdd191 --- /dev/null +++ b/lib/features/starter_packs/cubit/actor_starter_packs_cubit.dart @@ -0,0 +1,86 @@ +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; + +part 'actor_starter_packs_state.dart'; + +class ActorStarterPacksCubit extends Cubit { + ActorStarterPacksCubit({required StarterPackRepository starterPackRepository}) + : _starterPackRepository = starterPackRepository, + super(const ActorStarterPacksState.initial()); + + final StarterPackRepository _starterPackRepository; + + Future load({required String actor, int limit = 50}) async { + emit(ActorStarterPacksState.loading(actor: actor, limit: limit)); + + try { + final result = await _starterPackRepository.getActorStarterPacks(actor: actor, limit: limit); + emit( + ActorStarterPacksState.loaded( + actor: actor, + starterPacks: result.starterPacks, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: limit, + ), + ); + } catch (error) { + emit(ActorStarterPacksState.error(message: 'Failed to load starter packs: $error', actor: actor, limit: limit)); + } + } + + Future refresh() async { + if (state.actor == null) { + return; + } + + emit(state.copyWith(isRefreshing: true, errorMessage: null)); + + try { + final result = await _starterPackRepository.getActorStarterPacks(actor: state.actor!, limit: state.limit); + emit( + ActorStarterPacksState.loaded( + actor: state.actor!, + starterPacks: result.starterPacks, + cursor: result.cursor, + hasMore: result.cursor != null, + limit: state.limit, + ), + ); + } catch (error) { + emit(state.copyWith(isRefreshing: false, errorMessage: 'Failed to refresh starter packs: $error')); + } + } + + Future loadMore() async { + if (state.status != ActorStarterPacksStatus.loaded || + state.actor == null || + state.cursor == null || + state.isLoadingMore) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final result = await _starterPackRepository.getActorStarterPacks( + actor: state.actor!, + cursor: state.cursor, + limit: state.limit, + ); + + emit( + state.copyWith( + starterPacks: [...state.starterPacks, ...result.starterPacks], + cursor: result.cursor, + hasMore: result.cursor != null, + isLoadingMore: false, + ), + ); + } catch (_) { + emit(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } +} diff --git a/lib/features/starter_packs/cubit/actor_starter_packs_state.dart b/lib/features/starter_packs/cubit/actor_starter_packs_state.dart new file mode 100644 index 0000000..ab41966 --- /dev/null +++ b/lib/features/starter_packs/cubit/actor_starter_packs_state.dart @@ -0,0 +1,95 @@ +part of 'actor_starter_packs_cubit.dart'; + +enum ActorStarterPacksStatus { initial, loading, loaded, error } + +class ActorStarterPacksState extends Equatable { + const ActorStarterPacksState._({ + required this.status, + this.actor, + this.starterPacks = const [], + this.cursor, + this.hasMore = false, + this.limit = 50, + this.isRefreshing = false, + this.isLoadingMore = false, + this.errorMessage, + }); + + const ActorStarterPacksState.initial() : this._(status: ActorStarterPacksStatus.initial); + + const ActorStarterPacksState.loading({required String actor, int limit = 50}) + : this._(status: ActorStarterPacksStatus.loading, actor: actor, limit: limit); + + const ActorStarterPacksState.loaded({ + required String actor, + required List starterPacks, + String? cursor, + required bool hasMore, + int limit = 50, + bool isRefreshing = false, + bool isLoadingMore = false, + String? errorMessage, + }) : this._( + status: ActorStarterPacksStatus.loaded, + actor: actor, + starterPacks: starterPacks, + cursor: cursor, + hasMore: hasMore, + limit: limit, + isRefreshing: isRefreshing, + isLoadingMore: isLoadingMore, + errorMessage: errorMessage, + ); + + const ActorStarterPacksState.error({required String message, String? actor, int limit = 50}) + : this._(status: ActorStarterPacksStatus.error, actor: actor, limit: limit, errorMessage: message); + + final ActorStarterPacksStatus status; + final String? actor; + final List starterPacks; + final String? cursor; + final bool hasMore; + final int limit; + final bool isRefreshing; + final bool isLoadingMore; + final String? errorMessage; + + ActorStarterPacksState copyWith({ + ActorStarterPacksStatus? status, + Object? actor = _actorStarterPacksNoValue, + List? starterPacks, + Object? cursor = _actorStarterPacksNoValue, + bool? hasMore, + int? limit, + bool? isRefreshing, + bool? isLoadingMore, + Object? errorMessage = _actorStarterPacksNoValue, + }) { + return ActorStarterPacksState._( + status: status ?? this.status, + actor: actor == _actorStarterPacksNoValue ? this.actor : actor as String?, + starterPacks: starterPacks ?? this.starterPacks, + cursor: cursor == _actorStarterPacksNoValue ? this.cursor : cursor as String?, + hasMore: hasMore ?? this.hasMore, + limit: limit ?? this.limit, + isRefreshing: isRefreshing ?? this.isRefreshing, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage == _actorStarterPacksNoValue ? this.errorMessage : errorMessage as String?, + ); + } + + @override + List get props => [ + status, + actor, + starterPacks, + cursor, + hasMore, + limit, + isRefreshing, + isLoadingMore, + errorMessage, + ]; +} + +const _actorStarterPacksNoValue = Object(); diff --git a/lib/features/starter_packs/data/starter_pack_repository.dart b/lib/features/starter_packs/data/starter_pack_repository.dart new file mode 100644 index 0000000..adac890 --- /dev/null +++ b/lib/features/starter_packs/data/starter_pack_repository.dart @@ -0,0 +1,131 @@ +import 'package:atproto_core/atproto_core.dart' show AtUri; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:bluesky/app_bsky_graph_starterpack.dart'; +import 'package:lazurite/features/moderation/data/moderation_service.dart'; + +class StarterPackRepository { + StarterPackRepository({required dynamic bluesky, ModerationService? moderationService}) + : _bluesky = bluesky, + _moderationService = moderationService; + + final dynamic _bluesky; + final ModerationService? _moderationService; + + Future getActorStarterPacks({required String actor, String? cursor, int limit = 50}) async { + final response = await _bluesky.graph.getActorStarterPacks( + actor: actor, + cursor: cursor, + limit: limit, + $headers: await _moderationService?.headersForRequest(), + ); + + return ActorStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); + } + + Future getStarterPack({required AtUri starterPackUri}) async { + final response = await _bluesky.graph.getStarterPack( + starterPack: starterPackUri, + $headers: await _moderationService?.headersForRequest(), + ); + + return response.data.starterPack; + } + + /// Creates a starter pack using the 3-step flow: + /// 1. Create a reference list + /// 2. Add members as listitem records + /// 3. Create the starter pack record pointing at the reference list + Future createStarterPack({ + required String userDid, + required String name, + String? description, + List memberDids = const [], + List feedUris = const [], + }) async { + final refListUri = await _createReferenceList(userDid: userDid); + + for (final did in memberDids) { + await addMember(listUri: refListUri, subjectDid: did); + } + + final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); + + final response = await _bluesky.graph.starterpack.create( + name: name, + description: description, + list: refListUri, + feeds: feeds.isEmpty ? null : feeds, + createdAt: DateTime.now(), + ); + + return response.data.uri; + } + + Future updateStarterPack({ + required AtUri packUri, + required AtUri referenceListUri, + required String name, + String? description, + List feedUris = const [], + }) async { + final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); + + await _bluesky.graph.starterpack.put( + rkey: packUri.rkey, + name: name, + description: description, + list: referenceListUri, + feeds: feeds.isEmpty ? null : feeds, + createdAt: DateTime.now(), + ); + } + + Future deleteStarterPack({ + required AtUri packUri, + required AtUri referenceListUri, + required String userDid, + }) async { + await _bluesky.graph.starterpack.delete(rkey: packUri.rkey); + await _bluesky.atproto.repo.deleteRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + rkey: referenceListUri.rkey, + ); + } + + Future addMember({required AtUri listUri, required String subjectDid}) async { + final response = await _bluesky.graph.listitem.create( + list: listUri, + subject: subjectDid, + createdAt: DateTime.now(), + ); + + return response.data.uri.toString(); + } + + Future removeMember({required AtUri listItemUri}) async { + await _bluesky.graph.listitem.delete(rkey: listItemUri.rkey); + } + + Future _createReferenceList({required String userDid}) async { + final response = await _bluesky.atproto.repo.createRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + record: { + r'$type': 'app.bsky.graph.list', + 'purpose': 'app.bsky.graph.defs#referencelist', + 'name': 'Starter Pack Members', + 'createdAt': DateTime.now().toUtc().toIso8601String(), + }, + ); + + return response.data.uri; + } +} + +class ActorStarterPacksResult { + const ActorStarterPacksResult({required this.starterPacks, this.cursor}); + + final List starterPacks; + final String? cursor; +} diff --git a/test/features/lists/presentation/list_detail_screen_test.dart b/test/features/lists/presentation/list_detail_screen_test.dart index 4494f49..145f566 100644 --- a/test/features/lists/presentation/list_detail_screen_test.dart +++ b/test/features/lists/presentation/list_detail_screen_test.dart @@ -104,9 +104,9 @@ void main() { }); await tester.pumpWidget(buildSubject()); - await tester.pump(); // allow bloc event to be processed + await tester.pump(); expect(find.byType(CircularProgressIndicator), findsWidgets); - await tester.pump(const Duration(hours: 2)); // drain pending timers + await tester.pump(const Duration(hours: 2)); }); testWidgets('shows list name in app bar after loading', (tester) async { diff --git a/test/features/lists/presentation/my_lists_screen_test.dart b/test/features/lists/presentation/my_lists_screen_test.dart index 6fa8fed..4ee4aab 100644 --- a/test/features/lists/presentation/my_lists_screen_test.dart +++ b/test/features/lists/presentation/my_lists_screen_test.dart @@ -90,7 +90,7 @@ void main() { await tester.pumpWidget(buildSubject()); expect(find.byType(CircularProgressIndicator), findsOneWidget); - await tester.pump(const Duration(hours: 2)); // drain pending timers + await tester.pump(const Duration(hours: 2)); }); testWidgets('shows FEEDS and MODERATION tabs', (tester) async { diff --git a/test/features/starter_packs/bloc/starter_pack_bloc_test.dart b/test/features/starter_packs/bloc/starter_pack_bloc_test.dart new file mode 100644 index 0000000..d8afed5 --- /dev/null +++ b/test/features/starter_packs/bloc/starter_pack_bloc_test.dart @@ -0,0 +1,418 @@ +import 'package:atproto_core/atproto_core.dart' show AtUri; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStarterPackRepository extends Mock implements StarterPackRepository {} + +void main() { + late MockStarterPackRepository mockRepository; + + final packUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); + final refListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/ref-list-1'); + final itemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); + + setUpAll(() { + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.starterpack/fallback')); + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); + }); + + setUp(() { + mockRepository = MockStarterPackRepository(); + }); + + group('StarterPackBloc', () { + final starterPack = _buildStarterPackView(packUri: packUri, refListUri: refListUri); + + blocTest( + 'loads a starter pack', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + setUp: () { + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add(StarterPackRequested(starterPackUri: packUri)), + expect: () => [ + StarterPackState.loading(packUri: packUri), + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + ], + ); + + blocTest( + 'emits error when load fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + setUp: () { + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(StarterPackRequested(starterPackUri: packUri)), + expect: () => [ + StarterPackState.loading(packUri: packUri), + predicate((state) => state.status == StarterPackStatus.error && state.errorMessage != null), + ], + ); + + blocTest( + 'creates a starter pack and emits loaded state', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + setUp: () { + when( + () => mockRepository.createStarterPack( + userDid: 'did:plc:creator', + name: 'My Pack', + description: any(named: 'description'), + memberDids: any(named: 'memberDids'), + feedUris: any(named: 'feedUris'), + ), + ).thenAnswer((_) async => packUri); + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add(const StarterPackCreated(userDid: 'did:plc:creator', name: 'My Pack')), + expect: () => [ + const StarterPackState.loading(), + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + ], + verify: (_) { + verify( + () => mockRepository.createStarterPack( + userDid: 'did:plc:creator', + name: 'My Pack', + description: any(named: 'description'), + memberDids: any(named: 'memberDids'), + feedUris: any(named: 'feedUris'), + ), + ).called(1); + }, + ); + + blocTest( + 'creates a starter pack with members and feeds', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + setUp: () { + when( + () => mockRepository.createStarterPack( + userDid: 'did:plc:creator', + name: 'Full Pack', + description: 'A description', + memberDids: ['did:plc:member-1', 'did:plc:member-2'], + feedUris: any(named: 'feedUris'), + ), + ).thenAnswer((_) async => packUri); + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add( + const StarterPackCreated( + userDid: 'did:plc:creator', + name: 'Full Pack', + description: 'A description', + memberDids: ['did:plc:member-1', 'did:plc:member-2'], + ), + ), + expect: () => [ + const StarterPackState.loading(), + StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + ], + ); + + blocTest( + 'emits error when create fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + setUp: () { + when( + () => mockRepository.createStarterPack( + userDid: any(named: 'userDid'), + name: any(named: 'name'), + description: any(named: 'description'), + memberDids: any(named: 'memberDids'), + feedUris: any(named: 'feedUris'), + ), + ).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const StarterPackCreated(userDid: 'did:plc:creator', name: 'My Pack')), + expect: () => [ + const StarterPackState.loading(), + predicate((state) => state.status == StarterPackStatus.error && state.errorMessage != null), + ], + ); + + blocTest( + 'updates a starter pack and reloads', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.updateStarterPack( + packUri: packUri, + referenceListUri: refListUri, + name: 'Updated Name', + description: any(named: 'description'), + feedUris: any(named: 'feedUris'), + ), + ).thenAnswer((_) async {}); + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), + expect: () => [ + predicate((state) => state.isMutating && !state.isRefreshing), + predicate((state) => !state.isMutating && state.status == StarterPackStatus.loaded), + ], + verify: (_) { + verify( + () => mockRepository.updateStarterPack( + packUri: packUri, + referenceListUri: refListUri, + name: 'Updated Name', + description: any(named: 'description'), + feedUris: any(named: 'feedUris'), + ), + ).called(1); + }, + ); + + blocTest( + 'StarterPackUpdated emits error when update fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.updateStarterPack( + packUri: any(named: 'packUri'), + referenceListUri: any(named: 'referenceListUri'), + name: any(named: 'name'), + description: any(named: 'description'), + feedUris: any(named: 'feedUris'), + ), + ).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'StarterPackUpdated is a no-op when not loaded', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), + expect: () => [], + ); + + blocTest( + 'StarterPackUpdated is a no-op when ref list is missing', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded( + packUri: packUri, + starterPack: _buildStarterPackView(packUri: packUri, refListUri: null), + ), + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), + expect: () => [], + ); + + blocTest( + 'StarterPackUpdated is a no-op when a mutation is in progress', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), + act: (bloc) => bloc.add(const StarterPackUpdated(name: 'Updated Name')), + expect: () => [], + ); + + blocTest( + 'deletes the starter pack and its reference list', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.deleteStarterPack( + packUri: packUri, + referenceListUri: refListUri, + userDid: 'did:plc:creator', + ), + ).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => state.status == StarterPackStatus.deleted), + ], + verify: (_) { + verify( + () => mockRepository.deleteStarterPack( + packUri: packUri, + referenceListUri: refListUri, + userDid: 'did:plc:creator', + ), + ).called(1); + }, + ); + + blocTest( + 'StarterPackDeleted emits error when delete fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.deleteStarterPack( + packUri: any(named: 'packUri'), + referenceListUri: any(named: 'referenceListUri'), + userDid: any(named: 'userDid'), + ), + ).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'StarterPackDeleted is a no-op when not loaded', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + act: (bloc) => bloc.add(const StarterPackDeleted(userDid: 'did:plc:creator')), + expect: () => [], + ); + + blocTest( + 'adds a member and reloads', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.addMember(listUri: refListUri, subjectDid: 'did:plc:new-member'), + ).thenAnswer((_) async => itemUri.toString()); + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), + expect: () => [ + predicate((state) => state.isMutating && !state.isRefreshing), + predicate((state) => !state.isMutating && state.status == StarterPackStatus.loaded), + ], + verify: (_) { + verify(() => mockRepository.addMember(listUri: refListUri, subjectDid: 'did:plc:new-member')).called(1); + }, + ); + + blocTest( + 'MemberAdded emits error when addMember fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.addMember( + listUri: any(named: 'listUri'), + subjectDid: any(named: 'subjectDid'), + ), + ).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'MemberAdded is a no-op when not loaded', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), + expect: () => [], + ); + + blocTest( + 'MemberAdded is a no-op when ref list is missing', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded( + packUri: packUri, + starterPack: _buildStarterPackView(packUri: packUri, refListUri: null), + ), + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), + expect: () => [], + ); + + blocTest( + 'MemberAdded is a no-op when a mutation is in progress', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), + act: (bloc) => bloc.add(const MemberAdded(subjectDid: 'did:plc:new-member')), + expect: () => [], + ); + + blocTest( + 'removes a member and reloads', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when(() => mockRepository.removeMember(listItemUri: itemUri)).thenAnswer((_) async {}); + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => starterPack); + }, + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), + expect: () => [ + predicate((state) => state.isMutating && !state.isRefreshing), + predicate((state) => !state.isMutating && state.status == StarterPackStatus.loaded), + ], + verify: (_) { + verify(() => mockRepository.removeMember(listItemUri: itemUri)).called(1); + }, + ); + + blocTest( + 'MemberRemoved emits error when removeMember fails', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack), + setUp: () { + when( + () => mockRepository.removeMember(listItemUri: any(named: 'listItemUri')), + ).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'MemberRemoved is a no-op when not loaded', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), + expect: () => [], + ); + + blocTest( + 'MemberRemoved is a no-op when a mutation is in progress', + build: () => StarterPackBloc(starterPackRepository: mockRepository), + seed: () => StarterPackState.loaded(packUri: packUri, starterPack: starterPack, isMutating: true), + act: (bloc) => bloc.add(MemberRemoved(listItemUri: itemUri)), + expect: () => [], + ); + }); +} + +StarterPackView _buildStarterPackView({required AtUri packUri, AtUri? refListUri}) { + final listViewBasic = refListUri == null + ? null + : ListViewBasic( + uri: refListUri, + cid: 'cid-ref-list', + name: 'Starter Pack Members', + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), + ); + + return StarterPackView( + uri: packUri, + cid: 'cid-pack', + record: const { + r'$type': 'app.bsky.graph.starterpack', + 'name': 'My Starter Pack', + 'list': 'at://did:plc:creator/app.bsky.graph.list/ref-list-1', + 'createdAt': '2026-03-22T00:00:00.000Z', + }, + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), + list: listViewBasic, + indexedAt: DateTime.utc(2026, 3, 22), + ); +} diff --git a/test/features/starter_packs/cubit/actor_starter_packs_cubit_test.dart b/test/features/starter_packs/cubit/actor_starter_packs_cubit_test.dart new file mode 100644 index 0000000..84b1071 --- /dev/null +++ b/test/features/starter_packs/cubit/actor_starter_packs_cubit_test.dart @@ -0,0 +1,198 @@ +import 'package:atproto_core/atproto_core.dart' show AtUri; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_graph_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; +import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockStarterPackRepository extends Mock implements StarterPackRepository {} + +void main() { + late MockStarterPackRepository mockRepository; + + const actor = 'did:plc:creator'; + + final packUri1 = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); + final packUri2 = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-2'); + + setUp(() { + mockRepository = MockStarterPackRepository(); + }); + + group('ActorStarterPacksCubit', () { + final pack1 = _buildStarterPackViewBasic(uri: packUri1, name: 'Pack One'); + final pack2 = _buildStarterPackViewBasic(uri: packUri2, name: 'Pack Two'); + + blocTest( + 'loads starter packs for an actor', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + setUp: () { + when( + () => mockRepository.getActorStarterPacks( + actor: actor, + cursor: any(named: 'cursor'), + limit: 25, + ), + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack1, pack2], cursor: 'cursor-1')); + }, + act: (cubit) => cubit.load(actor: actor, limit: 25), + expect: () => [ + const ActorStarterPacksState.loading(actor: actor, limit: 25), + predicate( + (state) => + state.status == ActorStarterPacksStatus.loaded && + state.starterPacks.length == 2 && + state.cursor == 'cursor-1' && + state.hasMore, + ), + ], + ); + + blocTest( + 'emits error when load fails', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + setUp: () { + when( + () => mockRepository.getActorStarterPacks( + actor: any(named: 'actor'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('network error')); + }, + act: (cubit) => cubit.load(actor: actor), + expect: () => [ + const ActorStarterPacksState.loading(actor: actor), + predicate( + (state) => state.status == ActorStarterPacksStatus.error && state.errorMessage != null, + ), + ], + ); + + blocTest( + 'refreshes the current starter pack collection', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockRepository.getActorStarterPacks( + actor: actor, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack1, pack2], cursor: null)); + }, + act: (cubit) => cubit.refresh(), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate( + (state) => !state.isRefreshing && state.starterPacks.length == 2 && !state.hasMore, + ), + ], + ); + + blocTest( + 'refresh is a no-op when actor is null', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + act: (cubit) => cubit.refresh(), + expect: () => [], + ); + + blocTest( + 'refresh emits error when fetch fails', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: null, hasMore: false), + setUp: () { + when( + () => mockRepository.getActorStarterPacks( + actor: any(named: 'actor'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('network error')); + }, + act: (cubit) => cubit.refresh(), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate((state) => !state.isRefreshing && state.errorMessage != null), + ], + ); + + blocTest( + 'loads more pages appending to existing list', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockRepository.getActorStarterPacks(actor: actor, cursor: 'cursor-1', limit: 50), + ).thenAnswer((_) async => ActorStarterPacksResult(starterPacks: [pack2], cursor: null)); + }, + act: (cubit) => cubit.loadMore(), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate( + (state) => !state.isLoadingMore && state.starterPacks.length == 2 && !state.hasMore, + ), + ], + ); + + blocTest( + 'loadMore is a no-op when no cursor', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: null, hasMore: false), + act: (cubit) => cubit.loadMore(), + expect: () => [], + ); + + blocTest( + 'loadMore is a no-op when already loading more', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded( + actor: actor, + starterPacks: [pack1], + cursor: 'cursor-1', + hasMore: true, + isLoadingMore: true, + ), + act: (cubit) => cubit.loadMore(), + expect: () => [], + ); + + blocTest( + 'loadMore stops pagination when fetch fails', + build: () => ActorStarterPacksCubit(starterPackRepository: mockRepository), + seed: () => ActorStarterPacksState.loaded(actor: actor, starterPacks: [pack1], cursor: 'cursor-1', hasMore: true), + setUp: () { + when( + () => mockRepository.getActorStarterPacks( + actor: any(named: 'actor'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('network error')); + }, + act: (cubit) => cubit.loadMore(), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate((state) => !state.isLoadingMore && !state.hasMore), + ], + ); + }); +} + +StarterPackViewBasic _buildStarterPackViewBasic({required AtUri uri, required String name}) { + return StarterPackViewBasic( + uri: uri, + cid: 'cid-${uri.rkey}', + record: { + r'$type': 'app.bsky.graph.starterpack', + 'name': name, + 'list': 'at://did:plc:creator/app.bsky.graph.list/ref', + 'createdAt': '2026-03-22T00:00:00.000Z', + }, + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), + indexedAt: DateTime.utc(2026, 3, 22), + ); +} -- 2.51.2