From 13b24cf4d066c78c5a43dbf3deeaba829fd39296 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 1 Oct 2026 20:08:36 -0700 Subject: [PATCH] fix(feed): remove a deleted post's card from every feed that shows it Deleting your own post from its card showed "Post deleted" but left the card in place: PostCard never passed onDeleted to PostCardActions, and the callback only fired while the card was still mounted. Changes: - PostCard and FeedPage pass the delete callback through. PostCardActions captures it before the request and calls it after a successful delete even if the card has left the tree. The call sits outside the try, so a throwing callback is not reported as a failed delete - MultiFeedProvider.removePost drops the post from every loaded home feed; a load-more page landing afterwards appends to the current list, so the removal sticks - UserProfileProvider.removePost removes it from the profile posts tab - A delete from a community feed or a profile also removes the post from the app-lifetime home feeds - Host tests per surface and for the in-flight page race; Maestro flows e6_delete_post_from_feed and e7_delete_post_from_profile Known gap: a delete from Home leaves the card in an already-open Profile tab. Issue: 2026-09-28-mobile-feed-card-post-delete-leaves-card Co-Authored-By: Claude Opus 5.5 (1M context) --- .maestro/config.yaml | 2 + .maestro/e6_delete_post_from_feed.yaml | 95 +++ .maestro/e7_delete_post_from_profile.yaml | 113 +++ lib/providers/multi_feed_provider.dart | 37 +- lib/providers/user_profile_provider.dart | 15 + .../community/community_feed_screen.dart | 18 +- lib/screens/home/feed_screen.dart | 1 + lib/screens/home/profile_screen.dart | 15 +- lib/widgets/feed_page.dart | 15 +- lib/widgets/post_card.dart | 12 +- lib/widgets/post_card_actions.dart | 35 +- ...d_provider_remove_post_in_flight_test.dart | 173 +++++ .../multi_feed_provider_remove_post_test.dart | 216 ++++++ ...ser_profile_provider_remove_post_test.dart | 201 ++++++ test/screens/community_feed_screen_test.dart | 31 +- test/screens/community_feed_search_test.dart | 1 + .../profile_screen_isolation_test.dart | 1 + .../viewer_state_hydration_screens_test.dart | 2 + test/test_helpers/fake_providers.dart | 26 +- .../comment_share_propagation_test.dart | 16 +- .../community_feed_screen_share_test.dart | 16 +- .../post_card_delete_callback_test.dart | 302 ++++++++ ...t_delete_removes_card_acceptance_test.dart | 642 ++++++++++++++++++ test/widgets/profile_screen_share_test.dart | 25 +- 24 files changed, 1950 insertions(+), 60 deletions(-) create mode 100644 .maestro/e6_delete_post_from_feed.yaml create mode 100644 .maestro/e7_delete_post_from_profile.yaml create mode 100644 test/providers/multi_feed_provider_remove_post_in_flight_test.dart create mode 100644 test/providers/multi_feed_provider_remove_post_test.dart create mode 100644 test/providers/user_profile_provider_remove_post_test.dart create mode 100644 test/widgets/post_card_delete_callback_test.dart create mode 100644 test/widgets/post_delete_removes_card_acceptance_test.dart diff --git a/.maestro/config.yaml b/.maestro/config.yaml index 6da43a8..4d223cc 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -49,6 +49,8 @@ executionOrder: - e3_community_picker - e4_reply_draft - e5_kill_mid_compose + - e6_delete_post_from_feed + - e7_delete_post_from_profile - f1_own_profile - f2_author_profile - f3_edit_profile_persist diff --git a/.maestro/e6_delete_post_from_feed.yaml b/.maestro/e6_delete_post_from_feed.yaml new file mode 100644 index 0000000..82bc61a --- /dev/null +++ b/.maestro/e6_delete_post_from_feed.yaml @@ -0,0 +1,95 @@ +# Section E — check 6: delete a post from its home-feed card. Creates its +# own timestamped post (same path as e1), finds it on the Discover feed, +# deletes it from that card's "Post options" menu, and checks the card +# leaves the feed at once, with no pull-to-refresh. +# Only ever deletes the post this flow created: the menu tap is scoped to +# the card whose semantics label carries the unique title, so a miss fails +# the flow instead of opening another card's menu. +# Assumes a signed-in session. +# NOTE: never use hideKeyboard on the compose screen — Maestro implements it +# as a BACK press, which the shell's dirty-draft guard redirects to Home. +appId: social.coves.dev +--- +- launchApp: + stopApp: true +- extendedWaitUntil: + visible: "Discover feed.*" + timeout: 30000 +- waitForAnimationToEnd: + timeout: 5000 +# --- create the post this flow will delete --- +- retry: + maxRetries: 2 + commands: + - tapOn: "Create" + - extendedWaitUntil: + visible: "Create Post" + timeout: 15000 +- tapOn: "Select a community" +- extendedWaitUntil: + visible: "Post to" + timeout: 15000 +- tapOn: "Search for a community" +- inputText: "scien" +- extendedWaitUntil: + visible: "Science[\\s\\S]*" + timeout: 15000 +- tapOn: "Science[\\s\\S]*" +- extendedWaitUntil: + visible: "Create Post" + timeout: 15000 +- evalScript: ${output.e6Title = 'QA E6 delete ' + Date.now()} +- tapOn: "Title" +- inputText: ${output.e6Title} +- tapOn: "What are your thoughts?" +- inputText: "Created and deleted by the Section E delete-from-feed flow." +- tapOn: "Post" +- extendedWaitUntil: + visible: "[\\s\\S]*${output.e6Title}[\\s\\S]*" + timeout: 20000 +# --- find it on the home Discover feed (indexing lag absorbed by retries) --- +- launchApp: + stopApp: true +- extendedWaitUntil: + visible: "Discover feed.*" + timeout: 30000 +- retry: + maxRetries: 4 + commands: + - swipe: + start: 50%, 30% + end: 50%, 80% + duration: 400 + - extendedWaitUntil: + visible: "Feed post in [\\s\\S]*${output.e6Title}[\\s\\S]*" + timeout: 10000 +- scrollUntilVisible: + element: + text: "Feed post in [\\s\\S]*${output.e6Title}[\\s\\S]*" + direction: DOWN + timeout: 30000 + centerElement: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: build/maestro/e6_before_delete +# --- delete from this card's menu only --- +- tapOn: + text: "Post options" + childOf: + text: "Feed post in [\\s\\S]*${output.e6Title}[\\s\\S]*" +- extendedWaitUntil: + visible: "Delete post" + timeout: 10000 +- tapOn: "Delete post" +- extendedWaitUntil: + visible: "Delete Post" + timeout: 10000 +- tapOn: "Delete" +- extendedWaitUntil: + visible: "Post deleted" + timeout: 15000 +# The card is gone without a refresh. +- extendedWaitUntil: + notVisible: "Feed post in [\\s\\S]*${output.e6Title}[\\s\\S]*" + timeout: 5000 +- takeScreenshot: build/maestro/e6_after_delete diff --git a/.maestro/e7_delete_post_from_profile.yaml b/.maestro/e7_delete_post_from_profile.yaml new file mode 100644 index 0000000..57df8ef --- /dev/null +++ b/.maestro/e7_delete_post_from_profile.yaml @@ -0,0 +1,113 @@ +# Section E — check 7: deleting a post from the Me (profile) tab also removes +# its card from the Home feed, which the shell keeps alive. Creates its own +# timestamped post (same path as e6), brings its card on screen in the home +# Discover feed, deletes it from the profile posts list, then returns to Home +# and checks the Discover card is gone with no pull-to-refresh. +# Only ever deletes the post this flow created: the menu tap is the "Post +# options" button nearest below the unique title, and the flow then checks +# that title is gone from the profile. +# Assumes a signed-in session. +# NOTE: never use hideKeyboard on the compose screen — Maestro implements it +# as a BACK press, which the shell's dirty-draft guard redirects to Home. +appId: social.coves.dev +--- +- launchApp: + stopApp: true +- extendedWaitUntil: + visible: "Discover feed.*" + timeout: 30000 +- waitForAnimationToEnd: + timeout: 5000 +# --- create the post this flow will delete --- +- retry: + maxRetries: 2 + commands: + - tapOn: "Create" + - extendedWaitUntil: + visible: "Create Post" + timeout: 15000 +- tapOn: "Select a community" +- extendedWaitUntil: + visible: "Post to" + timeout: 15000 +- tapOn: "Search for a community" +- inputText: "scien" +- extendedWaitUntil: + visible: "Science[\\s\\S]*" + timeout: 15000 +- tapOn: "Science[\\s\\S]*" +- extendedWaitUntil: + visible: "Create Post" + timeout: 15000 +- evalScript: ${output.e7Title = 'QA E7 delete ' + Date.now()} +- tapOn: "Title" +- inputText: ${output.e7Title} +- tapOn: "What are your thoughts?" +- inputText: "Created and deleted by the Section E delete-from-profile flow." +- tapOn: "Post" +- extendedWaitUntil: + visible: "[\\s\\S]*${output.e7Title}[\\s\\S]*" + timeout: 20000 +# --- bring its card on screen in the home Discover feed --- +- launchApp: + stopApp: true +- extendedWaitUntil: + visible: "Discover feed.*" + timeout: 30000 +- retry: + maxRetries: 4 + commands: + - swipe: + start: 50%, 30% + end: 50%, 80% + duration: 400 + - extendedWaitUntil: + visible: "Feed post in [\\s\\S]*${output.e7Title}[\\s\\S]*" + timeout: 10000 +- scrollUntilVisible: + element: + text: "Feed post in [\\s\\S]*${output.e7Title}[\\s\\S]*" + direction: DOWN + timeout: 30000 + centerElement: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: build/maestro/e7_home_before_delete +# --- delete it from the Me tab's posts list --- +- tapOn: "Me" +- scrollUntilVisible: + element: + text: "${output.e7Title}" + direction: DOWN + timeout: 30000 + centerElement: true +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: build/maestro/e7_profile_before_delete +- tapOn: + text: "Post options" + below: + text: "${output.e7Title}" +- extendedWaitUntil: + visible: "Delete post" + timeout: 10000 +- tapOn: "Delete post" +- extendedWaitUntil: + visible: "Delete Post" + timeout: 10000 +- tapOn: "Delete" +- extendedWaitUntil: + visible: "Post deleted" + timeout: 15000 +- extendedWaitUntil: + notVisible: "${output.e7Title}" + timeout: 5000 +# --- back on Home, the Discover card is gone without a refresh --- +- tapOn: "Home" +- extendedWaitUntil: + visible: "Discover feed.*" + timeout: 15000 +- extendedWaitUntil: + notVisible: "Feed post in [\\s\\S]*${output.e7Title}[\\s\\S]*" + timeout: 5000 +- takeScreenshot: build/maestro/e7_home_after_delete diff --git a/lib/providers/multi_feed_provider.dart b/lib/providers/multi_feed_provider.dart index 67da472..a63f5f2 100644 --- a/lib/providers/multi_feed_provider.dart +++ b/lib/providers/multi_feed_provider.dart @@ -427,9 +427,13 @@ class MultiFeedProvider with ChangeNotifier { // an un-deduped append would render a second PostCard (and trip the // duplicate-key assertion in debug builds). Mirrors // CursorPaginationController's `idOf` guard. + // + // A load-more page is appended to the feed's posts as they are now, + // not to the pre-request snapshot, so a removePost that ran while the + // page was in flight stays applied. final existing = replacePosts ? const [] - : currentState.posts; + : getState(type).posts; final newPosts = [ ...existing, ..._withoutDuplicates(response.feed, existing), @@ -657,6 +661,37 @@ class MultiFeedProvider with ChangeNotifier { notifyListeners(); } + /// Remove the post with [uri] from every loaded feed without refetching, + /// e.g. after the author deletes it. + /// + /// Each affected feed gets a new posts list; every other part of its state + /// is kept. Notifies once when any feed held the post, and not at all + /// otherwise. + void removePost(String uri) { + if (_isDisposed) { + return; + } + + var removedAny = false; + for (final type in FeedType.values) { + final state = _feedStates[type]; + if (state == null) { + continue; + } + final remainingPosts = state.posts + .where((feedPost) => feedPost.post.uri != uri) + .toList(); + if (remainingPosts.length != state.posts.length) { + _feedStates[type] = state.copyWith(posts: remainingPosts); + removedAny = true; + } + } + + if (removedAny) { + notifyListeners(); + } + } + /// Reset feed state for a specific feed void reset(FeedType type) { if (_isDisposed) { diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index dd0b7be..218103a 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -550,6 +550,21 @@ class UserProfileProvider with ChangeNotifier { } } + /// Remove the post with [uri] from the loaded posts without refetching, + /// e.g. after the author deletes it from its card. + /// + /// Every other part of the posts state (cursor, loading flags, errors) is + /// kept, and a load-more already in flight still appends its page. Notifies + /// only when the post was loaded. A no-op after [dispose]: the screen that + /// owns this provider can close while the delete is in flight. + void removePost(String uri) { + if (_disposed) { + return; + } + // The controller notifies, which re-projects _postsState. + _postsController.removeWhere((feedPost) => feedPost.post.uri == uri); + } + /// Clear current profile and reset state void clearProfile() { _profileLoadGeneration++; diff --git a/lib/screens/community/community_feed_screen.dart b/lib/screens/community/community_feed_screen.dart index 9cddeec..b386848 100644 --- a/lib/screens/community/community_feed_screen.dart +++ b/lib/screens/community/community_feed_screen.dart @@ -13,6 +13,7 @@ import '../../models/community.dart'; import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../providers/community_subscription_provider.dart'; +import '../../providers/multi_feed_provider.dart'; import '../../providers/vote_provider.dart'; import '../../services/api_exceptions.dart'; import '../../services/coves_api_service.dart'; @@ -64,6 +65,11 @@ const int _maxSearchQueryBytes = 500; class _CommunityFeedScreenState extends State { // Shared app-wide API client (owned by main.dart) — do not dispose here late final CovesApiService _apiService; + + // App-wide home feeds, which stay alive under this screen: a post deleted + // here must leave them too. Read once, since a delete can finish after + // this screen has closed. + late final MultiFeedProvider _multiFeedProvider; final ScrollController _scrollController = ScrollController(); // Tab state @@ -105,6 +111,7 @@ class _CommunityFeedScreenState extends State { void initState() { super.initState(); _apiService = context.read(); + _multiFeedProvider = context.read(); _community = widget.community; _feedController = CursorPaginationController( @@ -920,7 +927,16 @@ class _CommunityFeedScreenState extends State { endOfFeedWidget: _buildEndOfFeed(), emptyWidget: _buildEmptyPostsState(), itemBuilder: (context, post, index) { - final postCard = PostCard(post: post, currentTime: _currentTime); + final postCard = PostCard( + post: post, + currentTime: _currentTime, + onDeleted: () { + _feedController.removeWhere( + (feedPost) => feedPost.post.uri == post.post.uri, + ); + _multiFeedProvider.removePost(post.post.uri); + }, + ); // Constrain width on tablets for better readability if (ResponsiveUtils.isTablet(context)) { diff --git a/lib/screens/home/feed_screen.dart b/lib/screens/home/feed_screen.dart index c3e1d6a..95eb3c5 100644 --- a/lib/screens/home/feed_screen.dart +++ b/lib/screens/home/feed_screen.dart @@ -467,6 +467,7 @@ class FeedScreenState extends State { ..clearError(feedType) ..loadMore(feedType), onRetryLoadMore: () => provider.retryLoadMore(feedType), + onPostDeleted: provider.removePost, isAuthenticated: isAuthenticated, currentTime: currentTime, ); diff --git a/lib/screens/home/profile_screen.dart b/lib/screens/home/profile_screen.dart index dc0aa73..d932a36 100644 --- a/lib/screens/home/profile_screen.dart +++ b/lib/screens/home/profile_screen.dart @@ -11,6 +11,7 @@ import '../../models/post.dart'; import '../../models/user_profile.dart'; import '../../providers/auth_provider.dart'; import '../../providers/block_provider.dart'; +import '../../providers/multi_feed_provider.dart'; import '../../providers/user_profile_provider.dart'; import '../../services/comment_service.dart'; import '../../services/coves_api_service.dart'; @@ -83,12 +84,18 @@ class _ProfileViewState extends State<_ProfileView> { final ScrollController _scrollController = ScrollController(); late final PaginationScrollListener _paginationListener; + // App-wide home feeds, which stay alive beside the Profile tab and under a + // pushed profile: a post deleted here must leave them too. Read once, since + // a delete can finish after this screen has closed. + late final MultiFeedProvider _multiFeedProvider; + // One pending post-frame "does the content fill the viewport?" check. bool _viewportFillCheckScheduled = false; @override void initState() { super.initState(); + _multiFeedProvider = context.read(); // Pagination is driven by the scroll position, not by the item builder: // triggering a load from build() re-enters the provider mid-frame. _paginationListener = PaginationScrollListener( @@ -628,7 +635,13 @@ class _ProfileViewState extends State<_ProfileView> { ), ), itemBuilder: (context, feedViewPost, index) { - final postCard = PostCard(post: feedViewPost); + final postCard = PostCard( + post: feedViewPost, + onDeleted: () { + profileProvider.removePost(feedViewPost.post.uri); + _multiFeedProvider.removePost(feedViewPost.post.uri); + }, + ); // Constrain width on tablets for better readability if (ResponsiveUtils.isTablet(context)) { diff --git a/lib/widgets/feed_page.dart b/lib/widgets/feed_page.dart index 2f5f681..51483a2 100644 --- a/lib/widgets/feed_page.dart +++ b/lib/widgets/feed_page.dart @@ -37,6 +37,7 @@ class FeedPage extends StatefulWidget { required this.currentTime, this.loadMoreError, this.onRetryLoadMore, + this.onPostDeleted, super.key, }); @@ -52,6 +53,9 @@ class FeedPage extends StatefulWidget { final VoidCallback onRetry; final VoidCallback onClearErrorAndLoadMore; final VoidCallback? onRetryLoadMore; + + /// Called with a post's URI after its author deletes it from this page. + final ValueChanged? onPostDeleted; final bool isAuthenticated; final DateTime? currentTime; @@ -210,6 +214,9 @@ class _FeedPageState extends State } final post = widget.posts[index]; + // Captured now so the delete callback does not read this State + // after the card (or the page) has left the tree. + final onPostDeleted = widget.onPostDeleted; final authorName = post.post.author.displayName ?? post.post.author.handle; @@ -226,7 +233,13 @@ class _FeedPageState extends State '$authorName. ' '${post.post.title ?? ""}', button: true, - child: PostCard(post: post, currentTime: widget.currentTime), + child: PostCard( + post: post, + currentTime: widget.currentTime, + onDeleted: onPostDeleted == null + ? null + : () => onPostDeleted(post.post.uri), + ), ), ); diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index eca94c8..a0d7a6a 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -63,6 +63,7 @@ class PostCard extends StatefulWidget { this.embedHeight = 180, this.titleFontSize = 16, this.titleFontWeight = FontWeight.w400, + this.onDeleted, super.key, }); @@ -81,6 +82,7 @@ class PostCard extends StatefulWidget { final double embedHeight; final double titleFontSize; final FontWeight titleFontWeight; + final VoidCallback? onDeleted; @override State createState() => _PostCardState(); @@ -132,6 +134,7 @@ class _PostCardState extends State { concealed: widget.post.post.isSensitive && _revealedUri != widget.post.post.uri, onToggleReveal: _toggleReveal, + onDeleted: widget.onDeleted, ); } } @@ -157,6 +160,7 @@ class _PostCardContent extends StatelessWidget { required this.titleFontWeight, required this.concealed, required this.onToggleReveal, + required this.onDeleted, }); final FeedViewPost post; @@ -182,6 +186,8 @@ class _PostCardContent extends StatelessWidget { /// Flips the card's reveal state. final VoidCallback onToggleReveal; + final VoidCallback? onDeleted; + void _navigateToDetail(BuildContext context) { // Navigate to post detail screen // Use URI-encoded version of the post URI for the URL path @@ -409,7 +415,11 @@ class _PostCardContent extends StatelessWidget { // Action buttons row if (showActions) - PostCardActions(post: post, showCommentButton: showCommentButton), + PostCardActions( + post: post, + showCommentButton: showCommentButton, + onDeleted: onDeleted, + ), ], ), ), diff --git a/lib/widgets/post_card_actions.dart b/lib/widgets/post_card_actions.dart index 1d53917..b005072 100644 --- a/lib/widgets/post_card_actions.dart +++ b/lib/widgets/post_card_actions.dart @@ -245,20 +245,15 @@ class _PostCardActionsState extends State { // Shared app-wide API client (owned by main.dart) — do not dispose final apiService = context.read(); + // Captured before the request: the card can leave the tree (scrolled + // off-screen) while it is in flight, and the owner must still hear + // about a successful delete. + final notifyDeleted = onDeleted; + + var deleted = false; try { await apiService.deletePost(uri: post.post.uri); - - if (context.mounted) { - messenger.showSnackBar( - const SnackBar( - content: Text('Post deleted'), - behavior: SnackBarBehavior.floating, - ), - ); - - // Notify parent to handle post removal from feed - onDeleted?.call(); - } + deleted = true; } on NetworkException catch (e) { if (kDebugMode) { debugPrint('Network error deleting post: $e'); @@ -320,6 +315,22 @@ class _PostCardActionsState extends State { setState(() => _isDeleting = false); } } + + // Outside the try so an error thrown by the callback is not reported + // as a failed delete. + if (deleted) { + if (context.mounted) { + messenger.showSnackBar( + const SnackBar( + content: Text('Post deleted'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + // Notify parent to handle post removal from feed + notifyDeleted?.call(); + } } } diff --git a/test/providers/multi_feed_provider_remove_post_in_flight_test.dart b/test/providers/multi_feed_provider_remove_post_in_flight_test.dart new file mode 100644 index 0000000..a543576 --- /dev/null +++ b/test/providers/multi_feed_provider_remove_post_in_flight_test.dart @@ -0,0 +1,173 @@ +// A post removed while a load-more for its feed is in flight stays removed +// when that page lands: the page is appended to the feed as it is now, not +// to the snapshot taken when the request started. +// +// Bounded scenario: the page that lands does not itself contain the removed +// post (a server re-delivering a deleted post is out of scope). + +import 'dart:async'; + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/providers/multi_feed_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_helpers/fake_providers.dart'; +import '../test_helpers/test_mocks.dart'; + +const String myDid = 'did:plc:me'; +const String removedPostUri = + 'at://did:plc:community/social.coves.community.post/removed'; +const String keptPostUri = + 'at://did:plc:community/social.coves.community.post/kept'; +const String pageTwoPostUri = + 'at://did:plc:community/social.coves.community.post/page-two'; +const String pageTwoCursor = 'page-2'; + +FeedViewPost _post(String uri) { + return FeedViewPost( + post: PostView( + uri: uri, + cid: 'cid-${uri.split('/').last}', + rkey: uri.split('/').last, + author: AuthorView(did: myDid, handle: 'me.coves.test'), + community: CommunityRef(did: 'did:plc:community', name: 'testcove'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: const PostRecord(title: 'Title', content: 'Body'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); +} + +List _uris(List posts) => + posts.map((post) => post.post.uri).toList(); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeAuthProvider authProvider; + late MockCovesApiService apiService; + late FakeVoteProvider voteProvider; + late FakeSubscriptionProvider subscriptionProvider; + late MultiFeedProvider provider; + + setUp(() { + authProvider = FakeAuthProvider(signedInDid: myDid); + apiService = MockCovesApiService(); + voteProvider = FakeVoteProvider(authProvider); + subscriptionProvider = FakeSubscriptionProvider( + authProvider: authProvider, + apiService: apiService, + ); + provider = MultiFeedProvider( + authProvider, + apiService: apiService, + voteProvider: voteProvider, + subscriptionProvider: subscriptionProvider, + clock: () => DateTime.utc(2026, 9, 29, 12), + ); + }); + + tearDown(() { + provider.dispose(); + subscriptionProvider.dispose(); + voteProvider.dispose(); + authProvider.dispose(); + }); + + /// Answers [feedType]'s page requests with [answer], given the requested + /// cursor. Discover is served by getDiscover, For You (signed in) by + /// getTimeline. + void stubFeed( + FeedType feedType, + Future Function(String? cursor) answer, + ) { + if (feedType == FeedType.discover) { + when( + apiService.getDiscover( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer( + (invocation) => answer(invocation.namedArguments[#cursor] as String?), + ); + } else { + when( + apiService.getTimeline( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer( + (invocation) => answer(invocation.namedArguments[#cursor] as String?), + ); + } + } + + // Discover's page two points at a further page; For You's page two is the + // last one. Either way the feed's cursor and hasMore come from page two. + final cases = <({FeedType feedType, String? pageThreeCursor})>[ + (feedType: FeedType.discover, pageThreeCursor: 'page-3'), + (feedType: FeedType.forYou, pageThreeCursor: null), + ]; + + for (final scenario in cases) { + final feedType = scenario.feedType; + final pageThreeCursor = scenario.pageThreeCursor; + + test('${feedType.name}: a post removed while load-more is in flight ' + 'does not come back when the page lands', () async { + final pageTwo = Completer(); + final requestedCursors = []; + stubFeed(feedType, (cursor) { + requestedCursors.add(cursor); + if (cursor == null) { + return Future.value( + TimelineResponse( + feed: [_post(removedPostUri), _post(keptPostUri)], + cursor: pageTwoCursor, + ), + ); + } + return pageTwo.future; + }); + + await provider.loadFeed(feedType, refresh: true); + expect(_uris(provider.getState(feedType).posts), [ + removedPostUri, + keptPostUri, + ]); + expect(provider.getState(feedType).cursor, pageTwoCursor); + expect(provider.getState(feedType).hasMore, isTrue); + + // Page two is requested and held open. + final loadMore = provider.loadMore(feedType); + expect(requestedCursors, [null, pageTwoCursor]); + expect(provider.getState(feedType).isLoadingMore, isTrue); + + // The post is removed while page two is pending, and disappears now. + provider.removePost(removedPostUri); + expect(_uris(provider.getState(feedType).posts), [keptPostUri]); + + pageTwo.complete( + TimelineResponse( + feed: [_post(pageTwoPostUri)], + cursor: pageThreeCursor, + ), + ); + await loadMore; + + final state = provider.getState(feedType); + expect(_uris(state.posts), [keptPostUri, pageTwoPostUri]); + expect(state.cursor, pageThreeCursor); + expect(state.hasMore, pageThreeCursor != null); + expect(state.isLoadingMore, isFalse); + expect(state.loadMoreError, isNull); + expect(requestedCursors, [null, pageTwoCursor]); + }); + } +} diff --git a/test/providers/multi_feed_provider_remove_post_test.dart b/test/providers/multi_feed_provider_remove_post_test.dart new file mode 100644 index 0000000..fb2e1d9 --- /dev/null +++ b/test/providers/multi_feed_provider_remove_post_test.dart @@ -0,0 +1,216 @@ +// MultiFeedProvider.removePost drops a deleted post from every loaded feed +// in place: Discover and For You both lose it, nothing else about either +// feed changes, and nothing is refetched. + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/providers/multi_feed_provider.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_helpers/fake_providers.dart'; +import '../test_helpers/test_mocks.dart'; + +const String myDid = 'did:plc:me'; +const String deletedPostUri = + 'at://did:plc:community/social.coves.community.post/deleted'; +const String keptPostUri = + 'at://did:plc:community/social.coves.community.post/kept'; +const String otherPostUri = + 'at://did:plc:community/social.coves.community.post/other'; +const String discoverCursor = 'discover-page-2'; + +FeedViewPost _post(String uri) { + return FeedViewPost( + post: PostView( + uri: uri, + cid: 'cid-${uri.split('/').last}', + rkey: uri.split('/').last, + author: AuthorView(did: myDid, handle: 'me.coves.test'), + community: CommunityRef(did: 'did:plc:community', name: 'testcove'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: const PostRecord(title: 'Title', content: 'Body'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); +} + +List _uris(List posts) => + posts.map((post) => post.post.uri).toList(); + +/// Everything in a feed's state except its posts. +Map _nonPostFields(MultiFeedProvider provider, FeedType type) { + final state = provider.getState(type); + return { + 'cursor': state.cursor, + 'hasMore': state.hasMore, + 'isLoading': state.isLoading, + 'isLoadingMore': state.isLoadingMore, + 'error': state.error, + 'loadMoreError': state.loadMoreError, + 'scrollPosition': state.scrollPosition, + 'lastRefreshTime': state.lastRefreshTime, + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeAuthProvider authProvider; + late MockCovesApiService apiService; + late FakeVoteProvider voteProvider; + late FakeSubscriptionProvider subscriptionProvider; + late MultiFeedProvider provider; + late int discoverFetches; + late int timelineFetches; + + setUp(() async { + authProvider = FakeAuthProvider(signedInDid: myDid); + apiService = MockCovesApiService(); + discoverFetches = 0; + timelineFetches = 0; + + // Discover: page one has a next page, and loading it fails, so the + // feed carries a cursor, hasMore and a loadMoreError. + when( + apiService.getDiscover( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((invocation) async { + discoverFetches++; + if (invocation.namedArguments[#cursor] == discoverCursor) { + throw ApiException('Server error', statusCode: 500); + } + return TimelineResponse( + feed: [_post(deletedPostUri), _post(keptPostUri), _post(otherPostUri)], + cursor: discoverCursor, + ); + }); + // For You: a single, final page with the deleted post in the middle. + when( + apiService.getTimeline( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + timelineFetches++; + return TimelineResponse( + feed: [_post(otherPostUri), _post(deletedPostUri), _post(keptPostUri)], + ); + }); + + voteProvider = FakeVoteProvider(authProvider); + subscriptionProvider = FakeSubscriptionProvider( + authProvider: authProvider, + apiService: apiService, + ); + provider = MultiFeedProvider( + authProvider, + apiService: apiService, + voteProvider: voteProvider, + subscriptionProvider: subscriptionProvider, + clock: () => DateTime.utc(2026, 9, 29, 12), + ); + + await provider.loadFeed(FeedType.discover, refresh: true); + await provider.loadFeed(FeedType.forYou, refresh: true); + await provider.loadMore(FeedType.discover); + provider.saveScrollPosition(FeedType.discover, 320); + + // The fixture is what the tests below rely on. + expect(_uris(provider.getState(FeedType.discover).posts), [ + deletedPostUri, + keptPostUri, + otherPostUri, + ]); + expect(_uris(provider.getState(FeedType.forYou).posts), [ + otherPostUri, + deletedPostUri, + keptPostUri, + ]); + expect(provider.getState(FeedType.discover).cursor, discoverCursor); + expect(provider.getState(FeedType.discover).hasMore, isTrue); + expect(provider.getState(FeedType.discover).loadMoreError, isNotNull); + expect(provider.getState(FeedType.forYou).hasMore, isFalse); + }); + + tearDown(() { + provider.dispose(); + subscriptionProvider.dispose(); + voteProvider.dispose(); + authProvider.dispose(); + }); + + test('removes the post from both feeds in place, notifying once and ' + 'fetching nothing', () { + var notifications = 0; + provider.addListener(() => notifications++); + final discoverPostsBefore = provider.getState(FeedType.discover).posts; + final forYouPostsBefore = provider.getState(FeedType.forYou).posts; + final discoverFieldsBefore = _nonPostFields(provider, FeedType.discover); + final forYouFieldsBefore = _nonPostFields(provider, FeedType.forYou); + final discoverFetchesBefore = discoverFetches; + final timelineFetchesBefore = timelineFetches; + + provider.removePost(deletedPostUri); + + expect(_uris(provider.getState(FeedType.discover).posts), [ + keptPostUri, + otherPostUri, + ]); + expect(_uris(provider.getState(FeedType.forYou).posts), [ + otherPostUri, + keptPostUri, + ]); + expect(notifications, 1); + expect(_nonPostFields(provider, FeedType.discover), discoverFieldsBefore); + expect(_nonPostFields(provider, FeedType.forYou), forYouFieldsBefore); + + // The lists handed out before the removal are replaced, not mutated. + expect(_uris(discoverPostsBefore), [ + deletedPostUri, + keptPostUri, + otherPostUri, + ]); + expect(_uris(forYouPostsBefore), [ + otherPostUri, + deletedPostUri, + keptPostUri, + ]); + + expect(discoverFetches, discoverFetchesBefore); + expect(timelineFetches, timelineFetchesBefore); + }); + + test('a uri that is in no loaded feed changes nothing and does not ' + 'notify', () { + var notifications = 0; + provider.addListener(() => notifications++); + final discoverFieldsBefore = _nonPostFields(provider, FeedType.discover); + final forYouFieldsBefore = _nonPostFields(provider, FeedType.forYou); + + provider.removePost( + 'at://did:plc:community/social.coves.community.post/not-loaded', + ); + + expect(notifications, 0); + expect(_uris(provider.getState(FeedType.discover).posts), [ + deletedPostUri, + keptPostUri, + otherPostUri, + ]); + expect(_uris(provider.getState(FeedType.forYou).posts), [ + otherPostUri, + deletedPostUri, + keptPostUri, + ]); + expect(_nonPostFields(provider, FeedType.discover), discoverFieldsBefore); + expect(_nonPostFields(provider, FeedType.forYou), forYouFieldsBefore); + }); +} diff --git a/test/providers/user_profile_provider_remove_post_test.dart b/test/providers/user_profile_provider_remove_post_test.dart new file mode 100644 index 0000000..46bce36 --- /dev/null +++ b/test/providers/user_profile_provider_remove_post_test.dart @@ -0,0 +1,201 @@ +// UserProfileProvider.removePost drops a deleted post from the loaded +// profile posts in place: order is kept, nothing else about the posts state +// changes, and nothing is refetched. A load-more that is in flight when the +// post is removed appends onto the list as it is now, so the post stays gone. + +import 'dart:async'; + +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/models/user_profile.dart'; +import 'package:coves_flutter/providers/user_profile_provider.dart'; +import 'package:coves_flutter/services/profile_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_helpers/test_mocks.dart'; + +const String profileDid = 'did:plc:profileowner'; +const String pageTwoCursor = 'c1'; +const String pageThreeCursor = 'c2'; + +String postUri(String id) => + 'at://did:plc:profileowner/social.coves.community.post/$id'; + +FeedViewPost buildPost(String id) { + return FeedViewPost( + post: PostView( + uri: postUri(id), + cid: 'cid-$id', + rkey: id, + author: AuthorView(did: profileDid, handle: 'me.test'), + community: CommunityRef(did: 'did:plc:community', name: 'test-community'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: PostRecord(title: 'Post $id', content: 'body'), + stats: PostStats(upvotes: 1, downvotes: 0, score: 1, commentCount: 0), + ), + ); +} + +List postUris(UserProfileProvider provider) => + provider.postsState.posts.map((feedPost) => feedPost.post.uri).toList(); + +/// Everything in the posts state except the posts themselves. +Map nonPostFields(UserProfileProvider provider) { + final state = provider.postsState; + return { + 'cursor': state.cursor, + 'hasMore': state.hasMore, + 'isLoading': state.isLoading, + 'isLoadingMore': state.isLoadingMore, + 'error': state.error, + 'loadMoreError': state.loadMoreError, + 'scrollPosition': state.scrollPosition, + 'lastRefreshTime': state.lastRefreshTime, + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockAuthProvider mockAuthProvider; + late MockCovesApiService mockApiService; + late MockCommentService mockCommentService; + late ProfileCache profileCache; + late UserProfileProvider provider; + + setUp(() async { + mockAuthProvider = MockAuthProvider(); + mockApiService = MockCovesApiService(); + mockCommentService = MockCommentService(); + + // Signed in as the profile's owner, who is the one deleting posts. + when(mockAuthProvider.isAuthenticated).thenReturn(true); + when(mockAuthProvider.did).thenReturn(profileDid); + + profileCache = ProfileCache(mockAuthProvider); + provider = UserProfileProvider( + mockAuthProvider, + apiService: mockApiService, + commentService: mockCommentService, + profileCache: profileCache, + ); + + when( + mockApiService.getProfile(actor: anyNamed('actor')), + ).thenAnswer((_) async => UserProfile(did: profileDid, handle: 'me.test')); + + await provider.loadProfile(profileDid); + }); + + tearDown(() { + provider.dispose(); + profileCache.dispose(); + }); + + /// Answers getAuthorPosts with [answer], given the requested cursor. + void stubAuthorPosts( + Future Function(String? cursor) answer, + ) { + when( + mockApiService.getAuthorPosts( + actor: anyNamed('actor'), + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer( + (invocation) => answer(invocation.namedArguments[#cursor] as String?), + ); + } + + final pageOne = TimelineResponse( + feed: [buildPost('a'), buildPost('b'), buildPost('c')], + cursor: pageTwoCursor, + ); + + group('with page one [A, B, C] loaded and more to come', () { + late List requestedCursors; + + setUp(() async { + requestedCursors = []; + stubAuthorPosts((cursor) { + requestedCursors.add(cursor); + return Future.value(pageOne); + }); + + await provider.loadPosts(refresh: true); + + // The fixture is what the tests below rely on. + expect(postUris(provider), [postUri('a'), postUri('b'), postUri('c')]); + expect(provider.postsState.cursor, pageTwoCursor); + expect(provider.postsState.hasMore, isTrue); + expect(requestedCursors, [null]); + }); + + test('removes the post in place, keeps the rest of the state, notifies ' + 'and fetches nothing', () { + var notifications = 0; + provider.addListener(() => notifications++); + final fieldsBefore = nonPostFields(provider); + + provider.removePost(postUri('a')); + + expect(postUris(provider), [postUri('b'), postUri('c')]); + expect(nonPostFields(provider), fieldsBefore); + expect(notifications, greaterThanOrEqualTo(1)); + expect(requestedCursors, [null]); + }); + + test('a uri that is not loaded changes nothing and does not notify', () { + var notifications = 0; + provider.addListener(() => notifications++); + final fieldsBefore = nonPostFields(provider); + + provider.removePost(postUri('not-loaded')); + + expect(postUris(provider), [postUri('a'), postUri('b'), postUri('c')]); + expect(nonPostFields(provider), fieldsBefore); + expect(notifications, 0); + expect(requestedCursors, [null]); + }); + }); + + test('a post removed while load-more is in flight does not come back when ' + 'the page lands', () async { + final pageTwo = Completer(); + final requestedCursors = []; + stubAuthorPosts((cursor) { + requestedCursors.add(cursor); + if (cursor == null) { + return Future.value(pageOne); + } + return pageTwo.future; + }); + + await provider.loadPosts(refresh: true); + expect(postUris(provider), [postUri('a'), postUri('b'), postUri('c')]); + + // Page two is requested and held open. + final loadMore = provider.loadMorePosts(); + expect(requestedCursors, [null, pageTwoCursor]); + expect(provider.postsState.isLoadingMore, isTrue); + + // The post is removed while page two is pending, and disappears now. + provider.removePost(postUri('a')); + expect(postUris(provider), [postUri('b'), postUri('c')]); + + pageTwo.complete( + TimelineResponse(feed: [buildPost('d')], cursor: pageThreeCursor), + ); + await loadMore; + + expect(postUris(provider), [postUri('b'), postUri('c'), postUri('d')]); + expect(provider.postsState.cursor, pageThreeCursor); + expect(provider.postsState.hasMore, isTrue); + expect(provider.postsState.isLoadingMore, isFalse); + expect(provider.postsState.loadMoreError, isNull); + expect(requestedCursors, [null, pageTwoCursor]); + }); +} diff --git a/test/screens/community_feed_screen_test.dart b/test/screens/community_feed_screen_test.dart index ea12260..41dbf24 100644 --- a/test/screens/community_feed_screen_test.dart +++ b/test/screens/community_feed_screen_test.dart @@ -78,6 +78,7 @@ void main() { ), ChangeNotifierProvider.value(value: blocks), Provider(create: (_) => StreamableService()), + unloadedMultiFeedProvider(auth: auth, apiService: mockApiService), ], child: const CommunityFeedScreen(identifier: communityDid), ), @@ -136,9 +137,8 @@ void main() { 'pages on once the community loads', (tester) async { final community = Completer(); final firstPage = Completer(); - when( - mockApiService.getCommunity(community: anyNamed('community')), - ).thenAnswer((_) => community.future); + when(mockApiService.getCommunity(community: anyNamed('community'))) + .thenAnswer((_) => community.future); stubShortFeed(firstPage); await pumpFeedScreen(tester); @@ -165,19 +165,18 @@ void main() { 'failed pages on once a retry loads the community', (tester) async { final firstPage = Completer(); var communityAttempts = 0; - when( - mockApiService.getCommunity(community: anyNamed('community')), - ).thenAnswer((_) async { - communityAttempts++; - if (communityAttempts == 1) { - throw ApiException('Server error', statusCode: 500); - } - return CommunityView( - did: communityDid, - name: communityHandle, - displayName: 'Test Cove', - ); - }); + when(mockApiService.getCommunity(community: anyNamed('community'))) + .thenAnswer((_) async { + communityAttempts++; + if (communityAttempts == 1) { + throw ApiException('Server error', statusCode: 500); + } + return CommunityView( + did: communityDid, + name: communityHandle, + displayName: 'Test Cove', + ); + }); stubShortFeed(firstPage); await pumpFeedScreen(tester); diff --git a/test/screens/community_feed_search_test.dart b/test/screens/community_feed_search_test.dart index 59cb2a1..2707c6b 100644 --- a/test/screens/community_feed_search_test.dart +++ b/test/screens/community_feed_search_test.dart @@ -115,6 +115,7 @@ void main() { ), ChangeNotifierProvider.value(value: blocks), Provider(create: (_) => StreamableService()), + unloadedMultiFeedProvider(auth: auth, apiService: mockApiService), ], child: const CommunityFeedScreen(identifier: communityDid), ), diff --git a/test/screens/profile_screen_isolation_test.dart b/test/screens/profile_screen_isolation_test.dart index 113034a..7704ebb 100644 --- a/test/screens/profile_screen_isolation_test.dart +++ b/test/screens/profile_screen_isolation_test.dart @@ -223,6 +223,7 @@ void main() { create: (_) => ProfileCache(authProvider), dispose: (_, cache) => cache.dispose(), ), + unloadedMultiFeedProvider(auth: authProvider, apiService: apiService), ], child: MaterialApp.router(theme: AppTheme.dark, routerConfig: router), ), diff --git a/test/screens/viewer_state_hydration_screens_test.dart b/test/screens/viewer_state_hydration_screens_test.dart index 1d01643..02815bd 100644 --- a/test/screens/viewer_state_hydration_screens_test.dart +++ b/test/screens/viewer_state_hydration_screens_test.dart @@ -31,6 +31,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/fake_providers.dart'; import '../test_helpers/test_mocks.dart'; class _FakeAuthProvider extends AuthProvider { @@ -245,6 +246,7 @@ void main() { ), ChangeNotifierProvider.value(value: blocks), Provider(create: (_) => StreamableService()), + unloadedMultiFeedProvider(auth: auth, apiService: mockApiService), ], child: const MaterialApp( home: CommunityFeedScreen(identifier: communityHandle), diff --git a/test/test_helpers/fake_providers.dart b/test/test_helpers/fake_providers.dart index 0616db3..113be25 100644 --- a/test/test_helpers/fake_providers.dart +++ b/test/test_helpers/fake_providers.dart @@ -1,6 +1,7 @@ import 'package:coves_flutter/providers/auth_provider.dart'; import 'package:coves_flutter/providers/block_provider.dart'; import 'package:coves_flutter/providers/community_subscription_provider.dart'; +import 'package:coves_flutter/providers/multi_feed_provider.dart'; import 'package:coves_flutter/providers/vote_provider.dart'; import 'package:coves_flutter/services/comment_service.dart'; import 'package:coves_flutter/services/coves_api_service.dart'; @@ -126,11 +127,27 @@ List postCardProviders({ ]; } +/// The app-wide [MultiFeedProvider] registration for a test that pumps a +/// community or profile screen, which also removes a post its author deletes +/// from the home feeds, but never shows those feeds. +/// +/// Real, and never loaded, so it makes no requests. +SingleChildWidget unloadedMultiFeedProvider({ + required AuthProvider auth, + required CovesApiService apiService, +}) { + return ChangeNotifierProvider( + create: (_) => MultiFeedProvider(auth, apiService: apiService), + ); +} + /// The app-level dependencies a `ProfileScreen` resolves, mirroring the /// registrations in main.dart. /// /// Each ProfileScreen builds and owns its own UserProfileProvider from -/// these, so no UserProfileProvider is provided here. +/// these, so no UserProfileProvider is provided here. The app-wide +/// [MultiFeedProvider] is a real one that nothing loads unless a home +/// `FeedScreen` is also pumped, so it makes no requests on its own. List profileScreenProviders({ required AuthProvider auth, required CovesApiService apiService, @@ -154,6 +171,13 @@ List profileScreenProviders({ voteProvider: context.read(), ), ), + ChangeNotifierProvider( + create: (context) => MultiFeedProvider( + auth, + apiService: apiService, + hydrator: context.read(), + ), + ), Provider.value(value: StreamableService()), Provider( create: (_) => ProfileCache(auth), diff --git a/test/widgets/comment_share_propagation_test.dart b/test/widgets/comment_share_propagation_test.dart index a00b824..b33e8bd 100644 --- a/test/widgets/comment_share_propagation_test.dart +++ b/test/widgets/comment_share_propagation_test.dart @@ -26,6 +26,7 @@ import 'package:provider/provider.dart'; // Shared generated mockito mocks (real provider types) so the // Consumer/Consumer lookups inside CommentCard // resolve correctly. +import '../test_helpers/fake_providers.dart'; import '../test_helpers/test_mocks.dart'; /// A `LinkSharer` that records dispatches instead of opening a native sheet. @@ -142,12 +143,13 @@ void main() { // where the parent post reached the card. when(mockAuthProvider.isAuthenticated).thenReturn(false); when(mockAuthProvider.did).thenReturn(null); + // Read by the app-wide MultiFeedProvider the profile screen resolves. + when(mockAuthProvider.restoredSessionRecoveryCount).thenReturn(0); when(mockVoteProvider.isLiked(any)).thenReturn(false); when(mockVoteProvider.getVoteState(any)).thenReturn(null); when(mockVoteProvider.isPending(any)).thenReturn(false); - when(mockVoteProvider.getAdjustedScore(any, any)).thenAnswer( - (invocation) => invocation.positionalArguments[1] as int, - ); + when(mockVoteProvider.getAdjustedScore(any, any)) + .thenAnswer((invocation) => invocation.positionalArguments[1] as int); blockProvider = BlockProvider( apiService: mockApiService, authProvider: mockAuthProvider, @@ -507,9 +509,7 @@ void main() { group('profile comment list has no post to share under', () { setUp(() { - when( - mockApiService.getProfile(actor: anyNamed('actor')), - ).thenAnswer( + when(mockApiService.getProfile(actor: anyNamed('actor'))).thenAnswer( (_) async => UserProfile( did: 'did:plc:commenter789', handle: 'bob.coves.social', @@ -563,6 +563,10 @@ void main() { dispose: (_, cache) => cache.dispose(), ), Provider.value(value: linkSharer), + unloadedMultiFeedProvider( + auth: mockAuthProvider, + apiService: mockApiService, + ), ], child: MaterialApp( theme: AppTheme.dark, diff --git a/test/widgets/community_feed_screen_share_test.dart b/test/widgets/community_feed_screen_share_test.dart index 5b7eed9..f4fa2de 100644 --- a/test/widgets/community_feed_screen_share_test.dart +++ b/test/widgets/community_feed_screen_share_test.dart @@ -75,9 +75,8 @@ void main() { } void stubCommunity(CommunityView view) { - when( - mockApiService.getCommunity(community: anyNamed('community')), - ).thenAnswer((_) async => view); + when(mockApiService.getCommunity(community: anyNamed('community'))) + .thenAnswer((_) async => view); } /// An empty first page with no cursor: the feed never pages on its own and @@ -119,6 +118,7 @@ void main() { ChangeNotifierProvider.value(value: blocks), Provider(create: (_) => StreamableService()), Provider.value(value: linkSharer), + unloadedMultiFeedProvider(auth: auth, apiService: mockApiService), ], child: const CommunityFeedScreen(identifier: communityDid), ); @@ -204,9 +204,8 @@ void main() { // Never completes: the screen stays in its loading state, and the feed // load that follows it never starts. final pending = Completer(); - when( - mockApiService.getCommunity(community: anyNamed('community')), - ).thenAnswer((_) => pending.future); + when(mockApiService.getCommunity(community: anyNamed('community'))) + .thenAnswer((_) => pending.future); final auth = SignedInAuthProvider(); addTearDown(auth.dispose); @@ -227,9 +226,8 @@ void main() { testWidgets('offers no share button when the community failed to load', ( tester, ) async { - when( - mockApiService.getCommunity(community: anyNamed('community')), - ).thenThrow(NotFoundException('Community not found')); + when(mockApiService.getCommunity(community: anyNamed('community'))) + .thenThrow(NotFoundException('Community not found')); stubEmptyFeed(); await pumpCommunityFeed(tester); diff --git a/test/widgets/post_card_delete_callback_test.dart b/test/widgets/post_card_delete_callback_test.dart new file mode 100644 index 0000000..f5f0de4 --- /dev/null +++ b/test/widgets/post_card_delete_callback_test.dart @@ -0,0 +1,302 @@ +// A PostCard reports a successful delete of its post through `onDeleted`, +// so whichever feed owns the card can drop it. It reports nothing when the +// author cancels or when the delete fails. A delete that succeeds after the +// card has left the tree is still reported. + +import 'dart:async'; + +import 'package:coves_flutter/constants/app_theme.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/providers/auth_provider.dart'; +import 'package:coves_flutter/providers/block_provider.dart'; +import 'package:coves_flutter/providers/community_subscription_provider.dart'; +import 'package:coves_flutter/providers/vote_provider.dart'; +import 'package:coves_flutter/services/api_exceptions.dart'; +import 'package:coves_flutter/services/coves_api_service.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/widgets/post_card.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/fake_providers.dart'; +import '../test_helpers/test_mocks.dart'; + +const String myDid = 'did:plc:me'; +const String myHandle = 'me.coves.test'; +const String postUri = 'at://did:plc:community/social.coves.community.post/p1'; + +/// A post written by the signed-in user, so its menu offers "Delete post". +FeedViewPost _myPost() { + return FeedViewPost( + post: PostView( + uri: postUri, + cid: 'cid-p1', + rkey: 'p1', + author: AuthorView(did: myDid, handle: myHandle), + community: CommunityRef(did: 'did:plc:community', name: 'testcove'), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: const PostRecord(title: 'My post', content: 'Body'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); +} + +void main() { + late FakeAuthProvider authProvider; + late MockCovesApiService apiService; + late int deletedCallbackCount; + + setUp(() { + authProvider = FakeAuthProvider( + signedInDid: myDid, + signedInHandle: myHandle, + ); + apiService = MockCovesApiService(); + deletedCallbackCount = 0; + }); + + tearDown(() { + authProvider.dispose(); + }); + + /// Pumps the author's post card with an `onDeleted` that counts calls, + /// or with [onDeleted] when given. + /// + /// With [cardShown], the Scaffold body is a host that stays mounted and + /// shows the card only while [cardShown] is true, the way a feed list + /// disposes a card scrolled off screen. + Future pumpMyPostCard( + WidgetTester tester, { + ValueListenable? cardShown, + VoidCallback? onDeleted, + }) async { + final onCardDeleted = onDeleted ?? () => deletedCallbackCount++; + + // The delete flow awaits HapticFeedback; without a handler the platform + // channel call never completes in tests. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (methodCall) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: apiService), + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider( + create: (_) => FakeVoteProvider(authProvider), + ), + ChangeNotifierProvider( + create: (_) => FakeSubscriptionProvider( + authProvider: authProvider, + apiService: apiService, + ), + ), + ChangeNotifierProvider( + create: (_) => BlockProvider( + apiService: apiService, + authProvider: authProvider, + ), + ), + Provider.value(value: StreamableService()), + ], + child: MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: cardShown == null + ? PostCard(post: _myPost(), onDeleted: onCardDeleted) + : ValueListenableBuilder( + valueListenable: cardShown, + builder: (context, shown, _) => shown + ? PostCard(post: _myPost(), onDeleted: onCardDeleted) + : const SizedBox.shrink(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Opens "Post options", taps "Delete post", and waits on the dialog. + Future openDeleteDialog(WidgetTester tester) async { + await tester.tap(find.byTooltip('Post options')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete post')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Post'), findsOneWidget); + } + + testWidgets('onDeleted fires once after a confirmed delete succeeds', ( + tester, + ) async { + when(apiService.deletePost(uri: postUri)).thenAnswer((_) async {}); + + await pumpMyPostCard(tester); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + verify(apiService.deletePost(uri: postUri)).called(1); + expect(deletedCallbackCount, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'an onDeleted that throws after a successful delete does not report the ' + 'delete as failed', + (tester) async { + when(apiService.deletePost(uri: postUri)).thenAnswer((_) async {}); + final callbackError = Exception('onDeleted failed'); + + await pumpMyPostCard(tester, onDeleted: () => throw callbackError); + + // The delete flow starts in a post-frame callback of a pump run here, + // so an error escaping it reaches this zone's handler. Outside such a + // zone flutter_test fails the test on the uncaught async error, and + // tester.takeException() never sees it. Any other error is rethrown. + final uncaughtErrors = []; + await runZonedGuarded( + () async { + await openDeleteDialog(tester); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + }, + (error, stack) { + if (!identical(error, callbackError)) { + Error.throwWithStackTrace(error, stack); + } + uncaughtErrors.add(error); + }, + ); + + expect(find.text('Post deleted'), findsOneWidget); + + // Snackbars queue, so let "Post deleted" time out before checking that + // no failure snackbar was queued behind it. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + expect(find.text('Post deleted'), findsNothing); + expect( + find.text('Could not delete post. Please try again.'), + findsNothing, + ); + // The callback's own error escapes the delete flow uncaught. + expect(uncaughtErrors, [callbackError]); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('cancelling the dialog neither deletes nor fires onDeleted', ( + tester, + ) async { + await pumpMyPostCard(tester); + await openDeleteDialog(tester); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Post'), findsNothing); + verifyNever(apiService.deletePost(uri: anyNamed('uri'))); + expect(deletedCallbackCount, 0); + expect(tester.takeException(), isNull); + }); + + group('a failed delete does not fire onDeleted', () { + final failures = { + 'network error': () => NetworkException('Connection refused'), + 'forbidden (403)': () => ApiException('Forbidden', statusCode: 403), + 'not found': () => NotFoundException('Post not found'), + }; + + for (final MapEntry(key: description, value: failure) in failures.entries) { + testWidgets(description, (tester) async { + when(apiService.deletePost(uri: postUri)) + .thenAnswer((_) async => throw failure()); + + await pumpMyPostCard(tester); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + verify(apiService.deletePost(uri: postUri)).called(1); + expect(deletedCallbackCount, 0); + expect(tester.takeException(), isNull); + }); + } + }); + + group('the card leaves the tree while deletePost is in flight', () { + /// Confirms the delete, then removes the card while the request is + /// still pending. Returns the pending request. + /// + /// The Completer is created here, inside the test body: one created in + /// setUp lives outside testWidgets' fake-async zone, and its completion + /// would never reach the awaiting delete flow during the test. + Future> confirmDeleteThenRemoveCard( + WidgetTester tester, + ) async { + final deleteRequest = Completer(); + when(apiService.deletePost(uri: postUri)) + .thenAnswer((_) => deleteRequest.future); + final cardShown = ValueNotifier(true); + addTearDown(cardShown.dispose); + + await pumpMyPostCard(tester, cardShown: cardShown); + await openDeleteDialog(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + verify(apiService.deletePost(uri: postUri)).called(1); + expect(deleteRequest.isCompleted, isFalse); + + cardShown.value = false; + await tester.pumpAndSettle(); + expect(find.byType(PostCard), findsNothing); + + return deleteRequest; + } + + testWidgets('a delete that then succeeds still fires onDeleted once', ( + tester, + ) async { + final deleteRequest = await confirmDeleteThenRemoveCard(tester); + + deleteRequest.complete(); + await tester.pumpAndSettle(); + + expect(deletedCallbackCount, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('a delete that then fails does not fire onDeleted', ( + tester, + ) async { + final deleteRequest = await confirmDeleteThenRemoveCard(tester); + + deleteRequest.completeError(NetworkException('Connection refused')); + await tester.pumpAndSettle(); + + expect(deletedCallbackCount, 0); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/widgets/post_delete_removes_card_acceptance_test.dart b/test/widgets/post_delete_removes_card_acceptance_test.dart new file mode 100644 index 0000000..5a9ade9 --- /dev/null +++ b/test/widgets/post_delete_removes_card_acceptance_test.dart @@ -0,0 +1,642 @@ +// Acceptance: deleting a post from its feed card removes that card from the +// feed it was deleted from, without refetching the feed. +// +// The author opens the card's overflow menu, taps "Delete post" and confirms +// with "Delete". Once `CovesApiService.deletePost` succeeds, the deleted +// post's card must be gone from the list and the other post's card must +// stay, on every feed surface a card lives on: +// +// * home (Discover and For You, both backed by the app-wide +// MultiFeedProvider): the post leaves both tabs, including the one the +// author did not delete it from; +// * a community feed (the screen's own pagination controller); +// * the author's own profile, Posts tab (the screen's UserProfileProvider). +// +// A post deleted from a community feed pushed over Home, or from the Profile +// tab, also leaves the home feeds (Discover and For You), which stay alive +// underneath on the app-wide MultiFeedProvider. +// +// Each surface runs on its real screen and real feed owner. Only the API +// boundary is mocked. Every fixture page has a null cursor, so no list ever +// pages on its own, and the feed fetch count is pinned: removing the card +// must not cost a refetch. + +import 'dart:async'; + +import 'package:coves_flutter/constants/app_theme.dart'; +import 'package:coves_flutter/models/community.dart'; +import 'package:coves_flutter/models/post.dart'; +import 'package:coves_flutter/models/user_profile.dart'; +import 'package:coves_flutter/providers/auth_provider.dart'; +import 'package:coves_flutter/providers/block_provider.dart'; +import 'package:coves_flutter/providers/community_subscription_provider.dart'; +import 'package:coves_flutter/providers/multi_feed_provider.dart'; +import 'package:coves_flutter/providers/vote_provider.dart'; +import 'package:coves_flutter/screens/community/community_feed_screen.dart'; +import 'package:coves_flutter/screens/home/feed_screen.dart'; +import 'package:coves_flutter/screens/home/main_shell_screen.dart'; +import 'package:coves_flutter/screens/home/profile_screen.dart'; +import 'package:coves_flutter/services/coves_api_service.dart'; +import 'package:coves_flutter/services/streamable_service.dart'; +import 'package:coves_flutter/services/viewer_state_hydrator.dart'; +import 'package:coves_flutter/widgets/feed_page.dart'; +import 'package:coves_flutter/widgets/post_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +import '../test_helpers/fake_providers.dart'; +import '../test_helpers/test_mocks.dart'; + +const String myDid = 'did:plc:me'; +const String myHandle = 'me.coves.test'; +const String otherDid = 'did:plc:other'; +const String otherHandle = 'other.coves.test'; +const String communityDid = 'did:plc:community'; +const String communityHandle = 'testcove'; + +/// The signed-in author's post, the one deleted in every case. +const String deletedPostUri = + 'at://did:plc:community/social.coves.community.post/deleted'; + +/// The post that must survive the deletion. +const String keptPostUri = + 'at://did:plc:community/social.coves.community.post/kept'; + +FeedViewPost _post({ + required String uri, + required String rkey, + required String authorDid, + required String authorHandle, + required String title, +}) { + return FeedViewPost( + post: PostView( + uri: uri, + cid: 'cid-$rkey', + rkey: rkey, + author: AuthorView(did: authorDid, handle: authorHandle), + community: CommunityRef(did: communityDid, name: communityHandle), + createdAt: DateTime.parse('2025-01-01T12:00:00Z'), + indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), + record: PostRecord(title: title, content: 'Body of $title'), + stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0), + ), + ); +} + +/// Post A: written by the signed-in user, so its menu offers "Delete post". +FeedViewPost _deletedPost() => _post( + uri: deletedPostUri, + rkey: 'deleted', + authorDid: myDid, + authorHandle: myHandle, + title: 'Post to delete', +); + +/// Post B, by [authorDid]: someone else on shared feeds, the author on +/// their own profile. +FeedViewPost _keptPost({ + String authorDid = otherDid, + String authorHandle = otherHandle, +}) => _post( + uri: keptPostUri, + rkey: 'kept', + authorDid: authorDid, + authorHandle: authorHandle, + title: 'Post that stays', +); + +/// The rendered [PostCard] for the post at [uri], anywhere under [within] +/// (the whole tree when null). Offstage cards count, so an "absent" check +/// also catches a card kept alive off screen. +Finder _postCard(String uri, {Finder? within}) { + final card = find.byWidgetPredicate( + (widget) => widget is PostCard && widget.post.post.uri == uri, + skipOffstage: false, + ); + if (within == null) { + return card; + } + return find.descendant(of: within, matching: card, skipOffstage: false); +} + +/// The home [FeedPage] rendering [feedType]. +Finder _feedPage(FeedType feedType) => find.byWidgetPredicate( + (widget) => widget is FeedPage && widget.feedType == feedType, + skipOffstage: false, +); + +/// A tall phone layout, so both cards sit on screen below each surface's +/// header without scrolling. +void _usePhoneLayout(WidgetTester tester) { + tester.view.physicalSize = const Size(540, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +/// The delete flow awaits HapticFeedback; without a handler the platform +/// channel call never completes in tests. +void _answerPlatformChannel(WidgetTester tester) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (methodCall) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); +} + +/// Opens [card]'s overflow menu, taps "Delete post" and confirms. +Future _deleteFromCard(WidgetTester tester, Finder card) async { + final menuButton = find.descendant( + of: card, + matching: find.byTooltip('Post options'), + ); + expect(menuButton, findsOneWidget); + await tester.tap(menuButton); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete post')); + await tester.pumpAndSettle(); + + expect(find.text('Delete Post'), findsOneWidget); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); +} + +/// The app-level wiring main.dart registers for the home and community +/// feeds, with the real app-wide MultiFeedProvider over the mocked API. +List _appProviders({ + required AuthProvider authProvider, + required CovesApiService apiService, +}) { + return [ + Provider.value(value: apiService), + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider( + create: (_) => FakeVoteProvider(authProvider), + ), + ChangeNotifierProvider( + create: (_) => FakeSubscriptionProvider( + authProvider: authProvider, + apiService: apiService, + ), + ), + ChangeNotifierProvider( + create: (_) => + BlockProvider(apiService: apiService, authProvider: authProvider), + ), + Provider( + create: (context) => ViewerStateHydrator( + authProvider: authProvider, + voteProvider: context.read(), + subscriptionProvider: context.read(), + ), + ), + ChangeNotifierProvider( + create: (context) => MultiFeedProvider( + authProvider, + apiService: apiService, + hydrator: context.read(), + ), + ), + Provider.value(value: StreamableService()), + ]; +} + +void main() { + late FakeAuthProvider authProvider; + late MockCovesApiService apiService; + + setUp(() { + authProvider = FakeAuthProvider( + signedInDid: myDid, + signedInHandle: myHandle, + ); + apiService = MockCovesApiService(); + when(apiService.deletePost(uri: anyNamed('uri'))).thenAnswer((_) async {}); + }); + + tearDown(() { + authProvider.dispose(); + }); + + void expectDeletedOnceWithDeletedPostUri() { + expect( + verify(apiService.deletePost(uri: captureAnyNamed('uri'))).captured, + [deletedPostUri], + ); + } + + testWidgets('home: deleting a post from Discover removes its card there ' + 'and from For You, without refetching either feed', (tester) async { + _usePhoneLayout(tester); + _answerPlatformChannel(tester); + + var discoverFetches = 0; + var timelineFetches = 0; + when( + apiService.getDiscover( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + discoverFetches++; + return TimelineResponse(feed: [_deletedPost(), _keptPost()]); + }); + when( + apiService.getTimeline( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + timelineFetches++; + return TimelineResponse(feed: [_deletedPost(), _keptPost()]); + }); + + await tester.pumpWidget( + MultiProvider( + providers: _appProviders( + authProvider: authProvider, + apiService: apiService, + ), + child: MaterialApp(theme: AppTheme.dark, home: const FeedScreen()), + ), + ); + await tester.pumpAndSettle(); + + final discoverFetchesAfterLoad = discoverFetches; + final timelineFetchesAfterLoad = timelineFetches; + + final discoverPage = _feedPage(FeedType.discover); + final forYouPage = _feedPage(FeedType.forYou); + + // Both tabs show both posts before the delete. + expect(_postCard(deletedPostUri, within: discoverPage), findsOneWidget); + expect(_postCard(keptPostUri, within: discoverPage), findsOneWidget); + + await tester.tap(find.text('For You')); + await tester.pumpAndSettle(); + expect(_postCard(deletedPostUri, within: forYouPage), findsOneWidget); + expect(_postCard(keptPostUri, within: forYouPage), findsOneWidget); + + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + + await _deleteFromCard( + tester, + _postCard(deletedPostUri, within: discoverPage), + ); + + expectDeletedOnceWithDeletedPostUri(); + expect(_postCard(deletedPostUri, within: discoverPage), findsNothing); + expect(_postCard(keptPostUri, within: discoverPage), findsOneWidget); + + // The author switches to the other tab: the post is gone there too. + await tester.tap(find.text('For You')); + await tester.pumpAndSettle(); + expect(_postCard(deletedPostUri, within: forYouPage), findsNothing); + expect(_postCard(keptPostUri, within: forYouPage), findsOneWidget); + + expect(discoverFetches, discoverFetchesAfterLoad); + expect(timelineFetches, timelineFetchesAfterLoad); + expect(tester.takeException(), isNull); + }); + + testWidgets('community feed: deleting a post removes its card without ' + 'refetching the feed', (tester) async { + _usePhoneLayout(tester); + _answerPlatformChannel(tester); + + when(apiService.getCommunity(community: anyNamed('community'))).thenAnswer( + (_) async => CommunityView( + did: communityDid, + name: communityHandle, + displayName: 'Test Cove', + ), + ); + var communityFeedFetches = 0; + when( + apiService.getCommunityFeed( + community: anyNamed('community'), + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + communityFeedFetches++; + return TimelineResponse(feed: [_deletedPost(), _keptPost()]); + }); + + await tester.pumpWidget( + MultiProvider( + providers: _appProviders( + authProvider: authProvider, + apiService: apiService, + ), + child: MaterialApp( + theme: AppTheme.dark, + home: const CommunityFeedScreen(identifier: communityDid), + ), + ), + ); + await tester.pumpAndSettle(); + + final communityFeedFetchesAfterLoad = communityFeedFetches; + + expect(_postCard(deletedPostUri), findsOneWidget); + expect(_postCard(keptPostUri), findsOneWidget); + + await _deleteFromCard(tester, _postCard(deletedPostUri)); + + expectDeletedOnceWithDeletedPostUri(); + expect(_postCard(deletedPostUri), findsNothing); + expect(_postCard(keptPostUri), findsOneWidget); + expect(communityFeedFetches, communityFeedFetchesAfterLoad); + expect(tester.takeException(), isNull); + }); + + testWidgets("profile: deleting a post from the author's own Posts tab " + 'removes its card without refetching the posts', (tester) async { + _usePhoneLayout(tester); + _answerPlatformChannel(tester); + + when(apiService.getProfile(actor: myDid)) + .thenAnswer((_) async => UserProfile(did: myDid, handle: myHandle)); + var authorPostsFetches = 0; + when( + apiService.getAuthorPosts( + actor: myDid, + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + authorPostsFetches++; + return TimelineResponse( + feed: [ + _deletedPost(), + _keptPost(authorDid: myDid, authorHandle: myHandle), + ], + ); + }); + + // App-level dependencies only: the screen owns its UserProfileProvider. + await tester.pumpWidget( + MultiProvider( + providers: profileScreenProviders( + auth: authProvider, + apiService: apiService, + commentService: MockCommentService(), + ), + child: MaterialApp(theme: AppTheme.dark, home: const ProfileScreen()), + ), + ); + await tester.pumpAndSettle(); + + final authorPostsFetchesAfterLoad = authorPostsFetches; + + expect(_postCard(deletedPostUri), findsOneWidget); + expect(_postCard(keptPostUri), findsOneWidget); + + await _deleteFromCard(tester, _postCard(deletedPostUri)); + + expectDeletedOnceWithDeletedPostUri(); + expect(_postCard(deletedPostUri), findsNothing); + expect(_postCard(keptPostUri), findsOneWidget); + expect(authorPostsFetches, authorPostsFetchesAfterLoad); + expect(tester.takeException(), isNull); + }); + + group('a delete elsewhere also leaves the kept-alive home feeds', () { + // Every feed fetch keeps returning the deleted post, and the fetch counts + // are pinned: a card that leaves Home was removed by the delete, not by + // a refetch. + late int discoverFetches; + late int timelineFetches; + + setUp(() { + discoverFetches = 0; + timelineFetches = 0; + when( + apiService.getDiscover( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + discoverFetches++; + return TimelineResponse(feed: [_deletedPost(), _keptPost()]); + }); + when( + apiService.getTimeline( + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer((_) async { + timelineFetches++; + return TimelineResponse(feed: [_deletedPost(), _keptPost()]); + }); + }); + + /// Both home tabs show both posts; leaves Discover selected. + Future expectHomeShowsBothPostsInBothTabs(WidgetTester tester) async { + expect( + _postCard(deletedPostUri, within: _feedPage(FeedType.discover)), + findsOneWidget, + ); + await tester.tap(find.text('For You')); + await tester.pumpAndSettle(); + expect( + _postCard(deletedPostUri, within: _feedPage(FeedType.forYou)), + findsOneWidget, + ); + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + } + + /// The deleted post is gone from both home tabs, the kept post stays, + /// and neither home feed was refetched after [discoverFetchesAfterLoad] + /// and [timelineFetchesAfterLoad]. + Future expectHomeDropsOnlyTheDeletedPost( + WidgetTester tester, { + required int discoverFetchesAfterLoad, + required int timelineFetchesAfterLoad, + }) async { + final discoverPage = _feedPage(FeedType.discover); + expect(_postCard(deletedPostUri, within: discoverPage), findsNothing); + expect(_postCard(keptPostUri, within: discoverPage), findsOneWidget); + + await tester.tap(find.text('For You')); + await tester.pumpAndSettle(); + final forYouPage = _feedPage(FeedType.forYou); + expect(_postCard(deletedPostUri, within: forYouPage), findsNothing); + expect(_postCard(keptPostUri, within: forYouPage), findsOneWidget); + + expect(discoverFetches, discoverFetchesAfterLoad); + expect(timelineFetches, timelineFetchesAfterLoad); + } + + testWidgets('from a community feed pushed over Home: after going back, ' + 'the card is gone from Discover and For You', (tester) async { + _usePhoneLayout(tester); + _answerPlatformChannel(tester); + + when(apiService.getCommunity(community: anyNamed('community'))) + .thenAnswer( + (_) async => CommunityView( + did: communityDid, + name: communityHandle, + displayName: 'Test Cove', + ), + ); + when( + apiService.getCommunityFeed( + community: anyNamed('community'), + sort: anyNamed('sort'), + timeframe: anyNamed('timeframe'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer( + (_) async => TimelineResponse(feed: [_deletedPost(), _keptPost()]), + ); + + final navigatorKey = GlobalKey(); + await tester.pumpWidget( + MultiProvider( + providers: _appProviders( + authProvider: authProvider, + apiService: apiService, + ), + child: MaterialApp( + theme: AppTheme.dark, + navigatorKey: navigatorKey, + home: const FeedScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + final discoverFetchesAfterLoad = discoverFetches; + final timelineFetchesAfterLoad = timelineFetches; + await expectHomeShowsBothPostsInBothTabs(tester); + + unawaited( + navigatorKey.currentState!.push( + MaterialPageRoute( + builder: (_) => const CommunityFeedScreen(identifier: communityDid), + ), + ), + ); + await tester.pumpAndSettle(); + + final communityScreen = find.byType(CommunityFeedScreen); + await _deleteFromCard( + tester, + _postCard(deletedPostUri, within: communityScreen), + ); + expectDeletedOnceWithDeletedPostUri(); + expect(_postCard(deletedPostUri, within: communityScreen), findsNothing); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(communityScreen, findsNothing); + + await expectHomeDropsOnlyTheDeletedPost( + tester, + discoverFetchesAfterLoad: discoverFetchesAfterLoad, + timelineFetchesAfterLoad: timelineFetchesAfterLoad, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('from the Profile tab: back on the Home tab, the card is gone ' + 'from Discover and For You', (tester) async { + _usePhoneLayout(tester); + _answerPlatformChannel(tester); + + when(apiService.getProfile(actor: myDid)) + .thenAnswer((_) async => UserProfile(did: myDid, handle: myHandle)); + when( + apiService.getAuthorPosts( + actor: myDid, + filter: anyNamed('filter'), + community: anyNamed('community'), + limit: anyNamed('limit'), + cursor: anyNamed('cursor'), + ), + ).thenAnswer( + (_) async => TimelineResponse( + feed: [ + _deletedPost(), + _keptPost(authorDid: myDid, authorHandle: myHandle), + ], + ), + ); + + // The shell keeps Home alive in its IndexedStack while the Profile tab + // is shown, as the app does. + await tester.pumpWidget( + MultiProvider( + providers: profileScreenProviders( + auth: authProvider, + apiService: apiService, + commentService: MockCommentService(), + ), + child: MaterialApp( + theme: AppTheme.dark, + home: const MainShellScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + final discoverFetchesAfterLoad = discoverFetches; + final timelineFetchesAfterLoad = timelineFetches; + await expectHomeShowsBothPostsInBothTabs(tester); + + Finder navItem(String label) => find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == label, + ); + + await tester.tap(navItem('Me')); + await tester.pumpAndSettle(); + + final profileScreen = find.byType(ProfileScreen); + await _deleteFromCard( + tester, + _postCard(deletedPostUri, within: profileScreen), + ); + expectDeletedOnceWithDeletedPostUri(); + expect(_postCard(deletedPostUri, within: profileScreen), findsNothing); + + await tester.tap(navItem('Home')); + await tester.pumpAndSettle(); + + await expectHomeDropsOnlyTheDeletedPost( + tester, + discoverFetchesAfterLoad: discoverFetchesAfterLoad, + timelineFetchesAfterLoad: timelineFetchesAfterLoad, + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/widgets/profile_screen_share_test.dart b/test/widgets/profile_screen_share_test.dart index e2281b8..0400eb4 100644 --- a/test/widgets/profile_screen_share_test.dart +++ b/test/widgets/profile_screen_share_test.dart @@ -19,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/fake_providers.dart'; import '../test_helpers/test_mocks.dart'; /// A `LinkSharer` that records dispatches instead of opening a native sheet. @@ -40,8 +41,7 @@ class RecordingLinkSharer implements LinkSharer { void main() { // Literals, never produced by the code under test. const otherProfileUrl = 'https://coves.social/profile/alice.coves.social'; - const didProfileUrl = - 'https://coves.social/profile/did%3Aplc%3Aauthor123'; + const didProfileUrl = 'https://coves.social/profile/did%3Aplc%3Aauthor123'; const ownProfileUrl = 'https://coves.social/profile/viewer.coves.social'; const otherDid = 'did:plc:author123'; @@ -64,6 +64,8 @@ void main() { // Signed in as a third party by default; the signed-out group overrides. when(mockAuthProvider.isAuthenticated).thenReturn(true); when(mockAuthProvider.did).thenReturn(viewerDid); + // Read by the app-wide MultiFeedProvider the profile screen resolves. + when(mockAuthProvider.restoredSessionRecoveryCount).thenReturn(0); blockProvider = BlockProvider( apiService: mockApiService, @@ -86,9 +88,8 @@ void main() { /// Answers `getProfile` with [profile]. void stubProfile(UserProfile profile) { - when( - mockApiService.getProfile(actor: anyNamed('actor')), - ).thenAnswer((_) async => profile); + when(mockApiService.getProfile(actor: anyNamed('actor'))) + .thenAnswer((_) async => profile); } Widget profileApp(String? actor) { @@ -110,6 +111,10 @@ void main() { dispose: (_, cache) => cache.dispose(), ), Provider.value(value: linkSharer), + unloadedMultiFeedProvider( + auth: mockAuthProvider, + apiService: mockApiService, + ), ], child: MaterialApp( theme: AppTheme.dark, @@ -231,9 +236,8 @@ void main() { ) async { // Never completes: the screen stays in its loading state. final pending = Completer(); - when( - mockApiService.getProfile(actor: anyNamed('actor')), - ).thenAnswer((_) => pending.future); + when(mockApiService.getProfile(actor: anyNamed('actor'))) + .thenAnswer((_) => pending.future); addTearDown(() async { await tester.pumpWidget( @@ -256,9 +260,8 @@ void main() { testWidgets('offers no share button when the profile failed to load', ( tester, ) async { - when( - mockApiService.getProfile(actor: anyNamed('actor')), - ).thenThrow(NotFoundException('User not found')); + when(mockApiService.getProfile(actor: anyNamed('actor'))) + .thenThrow(NotFoundException('User not found')); await pumpProfile(tester, actor: otherDid); -- 2.51.2