diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index 2dadc3c..3d59bb3 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -46,16 +46,16 @@ Completed [2026-03-21](../../CHANGELOG.md#2026-03-21) ### List CRUD -- [ ] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` -- [ ] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` -- [ ] Delete list via `com.atproto.repo.deleteRecord` -- [ ] Add members — search via `searchActorsTypeahead`, create `listitem` records -- [ ] Remove members — delete `listitem` records +- [x] Create list — name, description, avatar, purpose selector (curation/moderation) via `com.atproto.repo.createRecord` +- [x] Edit list — update name, description, avatar via `com.atproto.repo.putRecord` +- [x] Delete list via `com.atproto.repo.deleteRecord` +- [x] Add members — search via `searchActorsTypeahead`, create `listitem` records +- [x] Remove members — delete `listitem` records ### Moderation Actions -- [ ] Mute list via `muteActorList` / unmute via `unmuteActorList` -- [ ] Block via list — create `listblock` record; unblock — delete `listblock` record +- [x] Mute list via `muteActorList` / unmute via `unmuteActorList` +- [x] Block via list — create `listblock` record; unblock — delete `listblock` record ### Screens diff --git a/lib/features/lists/bloc/list_bloc.dart b/lib/features/lists/bloc/list_bloc.dart index b0be041..469cad0 100644 --- a/lib/features/lists/bloc/list_bloc.dart +++ b/lib/features/lists/bloc/list_bloc.dart @@ -1,4 +1,4 @@ -import 'package:atproto_core/atproto_core.dart'; +import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bluesky/app_bsky_graph_defs.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -19,6 +19,8 @@ class ListBloc extends Bloc { on(_onListUnmuted); on(_onListBlocked); on(_onListUnblocked); + on(_onListUpdated); + on(_onListDeleted); } final ListRepository _listRepository; @@ -125,6 +127,49 @@ class ListBloc extends Bloc { ); } + Future _onListUpdated(ListUpdated event, Emitter emit) async { + if (state.status != ListStatus.loaded || state.listUri == null || state.isMutating || state.list == null) { + return; + } + + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + BlobRef? avatarBlob; + if (event.avatarBytes != null) { + avatarBlob = await _listRepository.uploadListAvatar(bytes: event.avatarBytes!, mimeType: event.avatarMimeType); + } + + await _listRepository.updateList( + listUri: state.listUri!, + userDid: event.userDid, + name: event.name, + purpose: state.list!.purpose.toJson(), + description: event.description, + avatarBlob: avatarBlob, + ); + + await _reloadList(emit, isRefreshing: false, isMutating: true, errorPrefix: 'Failed to update list'); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to update list: $error')); + } + } + + Future _onListDeleted(ListDeleted event, Emitter emit) async { + if (state.status != ListStatus.loaded || state.listUri == null || state.isMutating) { + return; + } + + emit(state.copyWith(isMutating: true, errorMessage: null)); + + try { + await _listRepository.deleteList(listUri: state.listUri!, userDid: event.userDid); + emit(const ListState.deleted()); + } catch (error) { + emit(state.copyWith(isMutating: false, errorMessage: 'Failed to delete list: $error')); + } + } + Future _runMutation( Emitter emit, { required Future Function() action, diff --git a/lib/features/lists/bloc/list_event.dart b/lib/features/lists/bloc/list_event.dart index fa41b6d..d1d84e4 100644 --- a/lib/features/lists/bloc/list_event.dart +++ b/lib/features/lists/bloc/list_event.dart @@ -54,3 +54,31 @@ final class ListBlocked extends ListEvent { final class ListUnblocked extends ListEvent { const ListUnblocked(); } + +final class ListUpdated extends ListEvent { + const ListUpdated({ + required this.userDid, + required this.name, + this.description, + this.avatarBytes, + this.avatarMimeType = 'image/jpeg', + }); + + final String userDid; + final String name; + final String? description; + final List? avatarBytes; + final String avatarMimeType; + + @override + List get props => [userDid, name, description, avatarBytes, avatarMimeType]; +} + +final class ListDeleted extends ListEvent { + const ListDeleted({required this.userDid}); + + final String userDid; + + @override + List get props => [userDid]; +} diff --git a/lib/features/lists/bloc/list_state.dart b/lib/features/lists/bloc/list_state.dart index ec76e3d..9d2c16d 100644 --- a/lib/features/lists/bloc/list_state.dart +++ b/lib/features/lists/bloc/list_state.dart @@ -1,6 +1,6 @@ part of 'list_bloc.dart'; -enum ListStatus { initial, loading, loaded, error } +enum ListStatus { initial, loading, loaded, error, deleted } class ListState extends Equatable { const ListState._({ @@ -47,6 +47,8 @@ class ListState extends Equatable { const ListState.error({required String message, AtUri? listUri, int limit = 50}) : this._(status: ListStatus.error, listUri: listUri, limit: limit, errorMessage: message); + const ListState.deleted() : this._(status: ListStatus.deleted); + final ListStatus status; final AtUri? listUri; final ListView? list; diff --git a/lib/features/lists/cubit/my_lists_cubit.dart b/lib/features/lists/cubit/my_lists_cubit.dart index 67e0bcf..be69a47 100644 --- a/lib/features/lists/cubit/my_lists_cubit.dart +++ b/lib/features/lists/cubit/my_lists_cubit.dart @@ -1,3 +1,4 @@ +import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bluesky/app_bsky_graph_defs.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -54,6 +55,38 @@ class MyListsCubit extends Cubit { } } + Future createList({ + required String userDid, + required String name, + required String purpose, + String? description, + List? avatarBytes, + String avatarMimeType = 'image/jpeg', + }) async { + try { + BlobRef? avatarBlob; + if (avatarBytes != null) { + avatarBlob = await _listRepository.uploadListAvatar(bytes: avatarBytes, mimeType: avatarMimeType); + } + + final listUri = await _listRepository.createList( + userDid: userDid, + name: name, + purpose: purpose, + description: description, + avatarBlob: avatarBlob, + ); + + if (state.status == MyListsStatus.loaded) { + await refresh(); + } + + return listUri; + } catch (_) { + return null; + } + } + Future loadMore() async { if (state.status != MyListsStatus.loaded || state.actor == null || state.cursor == null || state.isLoadingMore) { return; diff --git a/lib/features/lists/data/list_repository.dart b/lib/features/lists/data/list_repository.dart index 2bbf7e9..f6dba40 100644 --- a/lib/features/lists/data/list_repository.dart +++ b/lib/features/lists/data/list_repository.dart @@ -1,4 +1,6 @@ -import 'package:atproto_core/atproto_core.dart'; +import 'dart:typed_data'; + +import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_graph_defs.dart'; @@ -118,6 +120,67 @@ class ListRepository { await _bluesky.graph.listblock.delete(rkey: blockUri.rkey); } + Future uploadListAvatar({required List bytes, String mimeType = 'image/jpeg'}) async { + final response = await _bluesky.atproto.repo.uploadBlob( + bytes: Uint8List.fromList(bytes), + $headers: {'Content-Type': mimeType}, + ); + return response.data.blob.ref; + } + + Future createList({ + required String userDid, + required String name, + required String purpose, + String? description, + BlobRef? avatarBlob, + }) async { + final record = { + r'$type': 'app.bsky.graph.list', + 'purpose': purpose, + 'name': name, + 'createdAt': DateTime.now().toUtc().toIso8601String(), + }; + if (description != null) record['description'] = description; + if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); + + final response = await _bluesky.atproto.repo.createRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + record: record, + ); + return response.data.uri; + } + + Future updateList({ + required AtUri listUri, + required String userDid, + required String name, + required String purpose, + String? description, + BlobRef? avatarBlob, + }) async { + final record = { + r'$type': 'app.bsky.graph.list', + 'purpose': purpose, + 'name': name, + 'createdAt': DateTime.now().toUtc().toIso8601String(), + }; + if (description != null) record['description'] = description; + if (avatarBlob != null) record['avatar'] = avatarBlob.toJson(); + + await _bluesky.atproto.repo.putRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + rkey: listUri.rkey, + record: record, + ); + } + + Future deleteList({required AtUri listUri, required String userDid}) async { + await _bluesky.atproto.repo.deleteRecord(repo: userDid, collection: 'app.bsky.graph.list', rkey: listUri.rkey); + } + List _filterLists(List lists) { return lists.where((list) => !_shouldFilterList(list)).toList(growable: false); } diff --git a/test/features/lists/bloc/list_bloc_test.dart b/test/features/lists/bloc/list_bloc_test.dart index bca5ba7..92a1fde 100644 --- a/test/features/lists/bloc/list_bloc_test.dart +++ b/test/features/lists/bloc/list_bloc_test.dart @@ -1,4 +1,4 @@ -import 'package:atproto_core/atproto_core.dart'; +import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_graph_defs.dart'; @@ -21,6 +21,7 @@ void main() { registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.list/fallback')); registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listitem/fallback')); registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.listblock/fallback')); + registerFallbackValue(const BlobRef(link: 'bafkreifallback')); }); setUp(() { @@ -176,6 +177,127 @@ void main() { ], ); + blocTest( + 'updates the list metadata and rehydrates', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when( + () => mockListRepository.updateList( + listUri: listUri, + userDid: 'did:plc:creator', + name: 'Renamed List', + purpose: any(named: 'purpose'), + description: any(named: 'description'), + avatarBlob: any(named: 'avatarBlob'), + ), + ).thenAnswer((_) async {}); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer( + (_) async => ListDetailResult( + list: _buildListView(listUri: listUri), + items: [firstItem], + cursor: null, + ), + ); + }, + act: (bloc) => + bloc.add(const ListUpdated(userDid: 'did:plc:creator', name: 'Renamed List', description: 'New desc')), + expect: () => [ + predicate((state) => state.isMutating && !state.isRefreshing), + predicate((state) => !state.isMutating && state.status == ListStatus.loaded), + ], + verify: (_) { + verify( + () => mockListRepository.updateList( + listUri: listUri, + userDid: 'did:plc:creator', + name: 'Renamed List', + purpose: any(named: 'purpose'), + description: 'New desc', + avatarBlob: any(named: 'avatarBlob'), + ), + ).called(1); + }, + ); + + blocTest( + 'uploads avatar and updates the list', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when( + () => mockListRepository.uploadListAvatar( + bytes: any(named: 'bytes'), + mimeType: any(named: 'mimeType'), + ), + ).thenAnswer((_) async => const BlobRef(link: 'bafkreinewavatarblob')); + when( + () => mockListRepository.updateList( + listUri: any(named: 'listUri'), + userDid: any(named: 'userDid'), + name: any(named: 'name'), + purpose: any(named: 'purpose'), + description: any(named: 'description'), + avatarBlob: any(named: 'avatarBlob'), + ), + ).thenAnswer((_) async {}); + when( + () => mockListRepository.getList( + listUri: listUri, + cursor: any(named: 'cursor'), + limit: 50, + ), + ).thenAnswer((_) async => ListDetailResult(list: initialList, items: [firstItem], cursor: null)); + }, + act: (bloc) => + bloc.add(const ListUpdated(userDid: 'did:plc:creator', name: 'With Avatar', avatarBytes: [1, 2, 3])), + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => !state.isMutating && state.status == ListStatus.loaded), + ], + verify: (_) { + verify(() => mockListRepository.uploadListAvatar(bytes: [1, 2, 3], mimeType: 'image/jpeg')).called(1); + verify( + () => mockListRepository.updateList( + listUri: any(named: 'listUri'), + userDid: any(named: 'userDid'), + name: any(named: 'name'), + purpose: any(named: 'purpose'), + description: any(named: 'description'), + avatarBlob: const BlobRef(link: 'bafkreinewavatarblob'), + ), + ).called(1); + }, + ); + + blocTest( + 'deletes the list and emits deleted state', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when( + () => mockListRepository.deleteList(listUri: listUri, userDid: 'did:plc:creator'), + ).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const ListDeleted(userDid: 'did:plc:creator')), + expect: () => [ + predicate((state) => state.isMutating), + predicate((state) => state.status == ListStatus.deleted), + ], + verify: (_) { + verify(() => mockListRepository.deleteList(listUri: listUri, userDid: 'did:plc:creator')).called(1); + }, + ); + blocTest( 'blocks and unblocks the active list', build: () => ListBloc(listRepository: mockListRepository), @@ -211,6 +333,105 @@ void main() { predicate((state) => state.list?.viewer?.blocked == null), ], ); + + blocTest( + 'ListUnblocked is a no-op when the list has no block URI', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + act: (bloc) => bloc.add(const ListUnblocked()), + expect: () => [], + ); + + blocTest( + 'ListMuted is a no-op when a mutation is already in progress', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => ListState.loaded( + listUri: listUri, + list: initialList, + items: [firstItem], + cursor: null, + hasMore: false, + isMutating: true, + ), + act: (bloc) => bloc.add(const ListMuted()), + expect: () => [], + ); + + blocTest( + 'ListBlocked is a no-op when a mutation is already in progress', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => ListState.loaded( + listUri: listUri, + list: initialList, + items: [firstItem], + cursor: null, + hasMore: false, + isMutating: true, + ), + act: (bloc) => bloc.add(const ListBlocked()), + expect: () => [], + ); + + blocTest( + 'ListMuted emits error message when muteList throws', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.muteList(listUri: listUri)).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const ListMuted()), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'ListUnmuted emits error message when unmuteList throws', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.unmuteList(listUri: listUri)).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const ListUnmuted()), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'ListBlocked emits error message when blockList throws', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: initialList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.blockList(listUri: listUri)).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const ListBlocked()), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); + + blocTest( + 'ListUnblocked emits error message when unblockList throws', + build: () => ListBloc(listRepository: mockListRepository), + seed: () => + ListState.loaded(listUri: listUri, list: blockedList, items: [firstItem], cursor: null, hasMore: false), + setUp: () { + when(() => mockListRepository.unblockList(blockUri: blockUri)).thenThrow(Exception('network error')); + }, + act: (bloc) => bloc.add(const ListUnblocked()), + expect: () => [ + predicate((state) => state.isMutating && state.errorMessage == null), + predicate((state) => !state.isMutating && state.errorMessage != null), + ], + ); }); } diff --git a/test/features/lists/cubit/my_lists_cubit_test.dart b/test/features/lists/cubit/my_lists_cubit_test.dart index 894e9a3..c355be2 100644 --- a/test/features/lists/cubit/my_lists_cubit_test.dart +++ b/test/features/lists/cubit/my_lists_cubit_test.dart @@ -1,5 +1,5 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:atproto_core/atproto_core.dart'; +import 'package:atproto_core/atproto_core.dart' show AtUri, BlobRef; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_graph_defs.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,6 +17,8 @@ void main() { final moderationListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/moderation'); final referenceListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/reference'); + final newListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/new-list'); + setUp(() { mockListRepository = MockListRepository(); }); @@ -86,6 +88,107 @@ void main() { ], ); + blocTest( + 'createList creates the list and refreshes', + build: () => MyListsCubit(listRepository: mockListRepository), + seed: () => MyListsState.loaded(actor: actor, lists: [curationList], cursor: null, hasMore: false), + setUp: () { + when( + () => mockListRepository.createList( + userDid: actor, + name: 'New List', + purpose: 'app.bsky.graph.defs#curatelist', + description: any(named: 'description'), + avatarBlob: any(named: 'avatarBlob'), + ), + ).thenAnswer((_) async => newListUri); + when( + () => mockListRepository.getLists( + actor: actor, + cursor: any(named: 'cursor'), + limit: 50, + includeReference: any(named: 'includeReference'), + ), + ).thenAnswer((_) async => ListsResult(lists: [curationList, moderationList], cursor: null)); + }, + act: (cubit) => cubit.createList(userDid: actor, name: 'New List', purpose: 'app.bsky.graph.defs#curatelist'), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate((state) => !state.isRefreshing && state.lists.length == 2), + ], + ); + + test('createList returns the new list URI', () async { + final cubit = MyListsCubit(listRepository: mockListRepository); + + when( + () => mockListRepository.createList( + userDid: actor, + name: 'New List', + purpose: 'app.bsky.graph.defs#curatelist', + description: any(named: 'description'), + avatarBlob: any(named: 'avatarBlob'), + ), + ).thenAnswer((_) async => newListUri); + + final result = await cubit.createList( + userDid: actor, + name: 'New List', + purpose: 'app.bsky.graph.defs#curatelist', + ); + + expect(result, newListUri); + }); + + test('createList uploads avatar when bytes provided', () async { + final cubit = MyListsCubit(listRepository: mockListRepository); + const avatarRef = BlobRef(link: 'bafkreiavatarblob'); + + when( + () => mockListRepository.uploadListAvatar( + bytes: any(named: 'bytes'), + mimeType: any(named: 'mimeType'), + ), + ).thenAnswer((_) async => avatarRef); + when( + () => mockListRepository.createList( + userDid: actor, + name: 'With Avatar', + purpose: 'app.bsky.graph.defs#modlist', + description: any(named: 'description'), + avatarBlob: avatarRef, + ), + ).thenAnswer((_) async => newListUri); + + final result = await cubit.createList( + userDid: actor, + name: 'With Avatar', + purpose: 'app.bsky.graph.defs#modlist', + avatarBytes: [1, 2, 3], + ); + + expect(result, newListUri); + verify(() => mockListRepository.uploadListAvatar(bytes: [1, 2, 3], mimeType: 'image/jpeg')).called(1); + }); + + test('createList returns null on failure', () async { + final cubit = MyListsCubit(listRepository: mockListRepository); + + when( + () => mockListRepository.createList( + userDid: actor, + name: any(named: 'name'), + purpose: any(named: 'purpose'), + description: any(named: 'description'), + avatarBlob: any(named: 'avatarBlob'), + ), + ).thenThrow(Exception('network error')); + + final result = await cubit.createList(userDid: actor, name: 'Broken', purpose: 'app.bsky.graph.defs#curatelist'); + + expect(result, isNull); + }); + blocTest( 'loads more pages for the active actor', build: () => MyListsCubit(listRepository: mockListRepository), diff --git a/test/features/lists/data/list_repository_test.dart b/test/features/lists/data/list_repository_test.dart index 0c75f3b..93e424d 100644 --- a/test/features/lists/data/list_repository_test.dart +++ b/test/features/lists/data/list_repository_test.dart @@ -1,4 +1,6 @@ -import 'package:atproto_core/atproto_core.dart'; +import 'dart:typed_data'; + +import 'package:atproto_core/atproto_core.dart' show AtUri, Blob, BlobRef; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_graph_defs.dart'; @@ -17,12 +19,15 @@ void main() { final listItemUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listitem/item-1'); final blockUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.listblock/block-1'); + late _FakeAtprotoService atproto; + setUp(() { graph = _FakeGraphService(); feed = _FakeFeedService(); actor = _FakeActorService(); + atproto = _FakeAtprotoService(); repository = ListRepository( - bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor), + bluesky: _FakeBlueskyClient(graph: graph, feed: feed, actor: actor, atproto: atproto), ); }); @@ -136,6 +141,69 @@ void main() { expect(graph.listblock.lastCreatedSubject, listUri); expect(graph.listblock.lastDeletedRkey, blockUri.rkey); }); + + test('uploadListAvatar uploads bytes and returns BlobRef', () async { + final bytes = [1, 2, 3, 4]; + final ref = await repository.uploadListAvatar(bytes: bytes, mimeType: 'image/png'); + + expect(ref, atproto.repo.uploadedBlobRef); + expect(atproto.repo.lastUploadedBytes, Uint8List.fromList(bytes)); + expect(atproto.repo.lastUploadHeaders, {'Content-Type': 'image/png'}); + }); + + test('createList creates a record and returns the new URI', () async { + final createdUri = await repository.createList( + userDid: 'did:plc:creator', + name: 'My List', + purpose: 'app.bsky.graph.defs#curatelist', + description: 'A great list', + ); + + expect(createdUri, atproto.repo.createdListUri); + expect(atproto.repo.lastCreateRepo, 'did:plc:creator'); + expect(atproto.repo.lastCreateCollection, 'app.bsky.graph.list'); + expect(atproto.repo.lastCreateRecord?[r'$type'], 'app.bsky.graph.list'); + expect(atproto.repo.lastCreateRecord?['name'], 'My List'); + expect(atproto.repo.lastCreateRecord?['purpose'], 'app.bsky.graph.defs#curatelist'); + expect(atproto.repo.lastCreateRecord?['description'], 'A great list'); + }); + + test('createList embeds avatar blob when provided', () async { + const blobRef = BlobRef(link: 'bafkreiavatarblob'); + + await repository.createList( + userDid: 'did:plc:creator', + name: 'List With Avatar', + purpose: 'app.bsky.graph.defs#modlist', + avatarBlob: blobRef, + ); + + expect(atproto.repo.lastCreateRecord?['avatar'], blobRef.toJson()); + }); + + test('updateList puts an updated record', () async { + await repository.updateList( + listUri: listUri, + userDid: 'did:plc:creator', + name: 'Updated Name', + purpose: 'app.bsky.graph.defs#curatelist', + description: 'Updated description', + ); + + expect(atproto.repo.lastPutRepo, 'did:plc:creator'); + expect(atproto.repo.lastPutCollection, 'app.bsky.graph.list'); + expect(atproto.repo.lastPutRkey, listUri.rkey); + expect(atproto.repo.lastPutRecord?['name'], 'Updated Name'); + expect(atproto.repo.lastPutRecord?['description'], 'Updated description'); + }); + + test('deleteList deletes the record by rkey', () async { + await repository.deleteList(listUri: listUri, userDid: 'did:plc:creator'); + + expect(atproto.repo.lastDeleteRepo, 'did:plc:creator'); + expect(atproto.repo.lastDeleteCollection, 'app.bsky.graph.list'); + expect(atproto.repo.lastDeleteRkey, listUri.rkey); + }); }); } @@ -151,11 +219,100 @@ ListView _buildListView(AtUri uri) { } class _FakeBlueskyClient { - _FakeBlueskyClient({required this.graph, required this.feed, required this.actor}); + _FakeBlueskyClient({required this.graph, required this.feed, required this.actor, _FakeAtprotoService? atproto}) + : atproto = atproto ?? _FakeAtprotoService(); final _FakeGraphService graph; final _FakeFeedService feed; final _FakeActorService actor; + final _FakeAtprotoService atproto; +} + +class _FakeAtprotoService { + _FakeAtprotoService() : repo = _FakeRepoService(); + + final _FakeRepoService repo; +} + +class _FakeRepoService { + final AtUri createdListUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.list/created-list'); + final BlobRef uploadedBlobRef = const BlobRef(link: 'bafkreitestblobref'); + + String? lastCreateRepo; + String? lastCreateCollection; + Map? lastCreateRecord; + + String? lastPutRepo; + String? lastPutCollection; + String? lastPutRkey; + Map? lastPutRecord; + + String? lastDeleteRepo; + String? lastDeleteCollection; + String? lastDeleteRkey; + + Uint8List? lastUploadedBytes; + Map? lastUploadHeaders; + + Future<_FakeResponse<_FakeCreateRecordData>> createRecord({ + required String repo, + required String collection, + required Map record, + String? rkey, + Map? $headers, + }) async { + lastCreateRepo = repo; + lastCreateCollection = collection; + lastCreateRecord = record; + return _FakeResponse(_FakeCreateRecordData(createdListUri)); + } + + Future<_FakeResponse> putRecord({ + required String repo, + required String collection, + required String rkey, + required Map record, + Map? $headers, + }) async { + lastPutRepo = repo; + lastPutCollection = collection; + lastPutRkey = rkey; + lastPutRecord = record; + return _FakeResponse(Object()); + } + + Future<_FakeResponse> deleteRecord({ + required String repo, + required String collection, + required String rkey, + Map? $headers, + }) async { + lastDeleteRepo = repo; + lastDeleteCollection = collection; + lastDeleteRkey = rkey; + return _FakeResponse(Object()); + } + + Future<_FakeResponse<_FakeUploadBlobData>> uploadBlob({ + required Uint8List bytes, + Map? $headers, + }) async { + lastUploadedBytes = bytes; + lastUploadHeaders = $headers; + return _FakeResponse(_FakeUploadBlobData(Blob(mimeType: 'image/jpeg', size: bytes.length, ref: uploadedBlobRef))); + } +} + +class _FakeCreateRecordData { + const _FakeCreateRecordData(this.uri); + + final AtUri uri; +} + +class _FakeUploadBlobData { + const _FakeUploadBlobData(this.blob); + + final Blob blob; } class _FakeGraphService {