From 6f1bf82f024b6ec9a3f14885af0334e5146dcae4 Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 27 Mar 2026 17:52:41 +0700 Subject: [PATCH] fixup! TF-4385 Fix spam banner show it once per day --- .../get_spam_mailbox_cached_interactor.dart | 6 +- ...t_spam_mailbox_cached_interactor_test.dart | 177 ++++++++++++++++ .../spam_report_controller_test.dart | 196 ++++++++++++++++++ 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 test/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor_test.dart create mode 100644 test/features/mailbox_dashboard/presentation/controller/spam_report_controller_test.dart diff --git a/lib/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart b/lib/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart index a673d2e7c..f10187f83 100644 --- a/lib/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart +++ b/lib/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart @@ -49,8 +49,12 @@ class GetSpamMailboxCachedInteractor { final lastTime = DateTime.fromMillisecondsSinceEpoch(lastTimeDismissedMs); final now = DateTime.now(); final elapsed = now.difference(lastTime); + if (elapsed.isNegative) { + if (elapsed.abs() < const Duration(days: 1)) return false; + return true; + } final isIntervalElapsed = - elapsed.inHours > spamReportBannerDisplayIntervalInHours; + elapsed.inHours >= spamReportBannerDisplayIntervalInHours; return isIntervalElapsed; } diff --git a/test/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor_test.dart b/test/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor_test.dart new file mode 100644 index 000000000..b0755a40a --- /dev/null +++ b/test/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor_test.dart @@ -0,0 +1,177 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/exceptions/spam_report_exception.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/repository/spam_report_repository.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/get_spam_mailbox_cached_state.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart'; + +import 'get_spam_mailbox_cached_interactor_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + final accountId = AccountId(Id('account-1')); + final userName = UserName('user@example.com'); + + late MockSpamReportRepository spamReportRepository; + late GetSpamMailboxCachedInteractor interactor; + + setUp(() { + spamReportRepository = MockSpamReportRepository(); + interactor = GetSpamMailboxCachedInteractor(spamReportRepository); + }); + + Mailbox makeSpamMailbox({required int unreadCount}) => Mailbox( + id: MailboxId(Id('spam-id')), + unreadEmails: + unreadCount > 0 ? UnreadEmails(UnsignedInt(unreadCount)) : null, + ); + + int msAgo(int hours) => + DateTime.now().subtract(Duration(hours: hours)).millisecondsSinceEpoch; + + // Predicate matchers for Left states — EquatableMixin compares exception by value, + // so isA<>() inside Left(...) breaks equality. Use predicate instead. + Matcher leftWithException() => predicate>( + (either) => either.fold( + (f) => f is GetSpamMailboxCachedFailure && f.exception is E, + (_) => false, + ), + 'Left(GetSpamMailboxCachedFailure($E))', + ); + + group('GetSpamMailboxCachedInteractor', () { + group('first-time user (no stored dismiss timestamp)', () { + test('shows banner when there are unread spam emails', () { + final spamMailbox = makeSpamMailbox(unreadCount: 5); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => 0); + when(spamReportRepository.getSpamMailboxCached(accountId, userName)) + .thenAnswer((_) async => spamMailbox); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + Right(GetSpamMailboxCachedSuccess(spamMailbox)), + ]), + ); + }); + + test('does not show banner when spam folder has no unread emails', () { + final spamMailbox = makeSpamMailbox(unreadCount: 0); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => 0); + when(spamReportRepository.getSpamMailboxCached(accountId, userName)) + .thenAnswer((_) async => spamMailbox); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + leftWithException(), + ]), + ); + }); + }); + + group('cooldown active (dismissed less than 24h ago)', () { + test('does not show banner when dismissed 1 hour ago', () { + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => msAgo(1)); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + leftWithException(), + ]), + ); + }); + }); + + group('cooldown expired (dismissed 24h+ ago)', () { + test('shows banner when dismissed 25 hours ago and unread > 0', () { + final spamMailbox = makeSpamMailbox(unreadCount: 3); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => msAgo(25)); + when(spamReportRepository.getSpamMailboxCached(accountId, userName)) + .thenAnswer((_) async => spamMailbox); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + Right(GetSpamMailboxCachedSuccess(spamMailbox)), + ]), + ); + }); + + test('does not show banner when dismissed 25 hours ago but unread = 0', + () { + final spamMailbox = makeSpamMailbox(unreadCount: 0); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => msAgo(25)); + when(spamReportRepository.getSpamMailboxCached(accountId, userName)) + .thenAnswer((_) async => spamMailbox); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + leftWithException(), + ]), + ); + }); + }); + + group('error handling', () { + test('wraps repository exception in GetSpamMailboxCachedFailure', () { + final exception = Exception('cache error'); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenThrow(exception); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + Left(GetSpamMailboxCachedFailure(exception)), + ]), + ); + }); + + test( + 'wraps getSpamMailboxCached exception in GetSpamMailboxCachedFailure', + () { + final exception = Exception('mailbox not found'); + when(spamReportRepository + .getLastTimeDismissedSpamReportedMilliseconds()) + .thenAnswer((_) async => 0); + when(spamReportRepository.getSpamMailboxCached(accountId, userName)) + .thenThrow(exception); + + expect( + interactor.execute(accountId, userName), + emitsInOrder([ + Right(GetSpamMailboxCachedLoading()), + Left(GetSpamMailboxCachedFailure(exception)), + ]), + ); + }); + }); + }); +} diff --git a/test/features/mailbox_dashboard/presentation/controller/spam_report_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/spam_report_controller_test.dart new file mode 100644 index 000000000..1e6a28fb1 --- /dev/null +++ b/test/features/mailbox_dashboard/presentation/controller/spam_report_controller_test.dart @@ -0,0 +1,196 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/presentation/utils/app_toast.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart' as jmap_mailbox; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/caching/caching_manager.dart'; +import 'package:core/data/network/config/dynamic_url_interceptors.dart'; +import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/delete_authority_oidc_interactor.dart'; +import 'package:tmail_ui_user/features/login/domain/usecases/delete_credential_interactor.dart'; +import 'package:tmail_ui_user/features/manage_account/data/local/language_cache_manager.dart'; +import 'package:tmail_ui_user/features/manage_account/domain/usecases/log_out_oidc_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/exceptions/spam_report_exception.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/get_spam_mailbox_cached_state.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_spam_mailbox_cached_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_spam_report_state_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_last_time_dismissed_spam_reported_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/store_spam_report_state_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/spam_report_controller.dart'; +import 'package:tmail_ui_user/main/bindings/network/binding_tag.dart'; +import 'package:tmail_ui_user/main/utils/toast_manager.dart'; +import 'package:tmail_ui_user/main/utils/twake_app_manager.dart'; +import 'package:uuid/uuid.dart'; + +import 'spam_report_controller_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + // BaseController dependencies + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + late MockStoreSpamReportInteractor storeSpamReportInteractor; + late MockStoreSpamReportStateInteractor storeSpamReportStateInteractor; + late MockGetSpamReportStateInteractor getSpamReportStateInteractor; + late MockGetSpamMailboxCachedInteractor getSpamMailboxCachedInteractor; + late SpamReportController controller; + + PresentationMailbox makeMailboxWithUnread(int count) => PresentationMailbox( + jmap_mailbox.MailboxId(Id('spam')), + unreadEmails: jmap_mailbox.UnreadEmails(UnsignedInt(count)), + ); + + void putBaseControllerDependencies() { + Get.put(MockCachingManager()); + Get.put(MockLanguageCacheManager()); + final authInterceptors = MockAuthorizationInterceptors(); + Get.put(authInterceptors); + Get.put(authInterceptors, tag: BindingTag.isolateTag); + Get.put(MockDynamicUrlInterceptors()); + Get.put(MockDeleteCredentialInteractor()); + Get.put(MockLogoutOidcInteractor()); + Get.put(MockDeleteAuthorityOidcInteractor()); + Get.put(MockAppToast()); + Get.put(MockImagePaths()); + Get.put(MockResponsiveUtils()); + Get.put(const Uuid()); + Get.put(MockToastManager()); + Get.put(MockTwakeAppManager()); + } + + setUp(() { + storeSpamReportInteractor = MockStoreSpamReportInteractor(); + storeSpamReportStateInteractor = MockStoreSpamReportStateInteractor(); + getSpamReportStateInteractor = MockGetSpamReportStateInteractor(); + getSpamMailboxCachedInteractor = MockGetSpamMailboxCachedInteractor(); + + when(storeSpamReportInteractor.execute(any)) + .thenAnswer((_) => const Stream.empty()); + when(getSpamReportStateInteractor.execute()) + .thenAnswer((_) => const Stream.empty()); + + putBaseControllerDependencies(); + + controller = SpamReportController( + storeSpamReportInteractor, + storeSpamReportStateInteractor, + getSpamReportStateInteractor, + getSpamMailboxCachedInteractor, + ); + Get.put(controller); + }); + + tearDown(Get.deleteAll); + + Stream> failureStream(Exception exception) => + Stream.fromIterable([ + Right(GetSpamMailboxCachedLoading()), + Left(GetSpamMailboxCachedFailure(exception)), + ]); + + final testAccountId = AccountId(Id('acc')); + final testUserName = UserName('user@example.com'); + + group('SpamReportController._validateSpamMailboxChanged', () { + group('NoUnreadSpamEmailsException', () { + test('stores dismissal time when banner was showing with unread > 0', () async { + controller.setSpamPresentationMailbox(makeMailboxWithUnread(3)); + + when(getSpamMailboxCachedInteractor.execute(any, any)) + .thenAnswer((_) => failureStream(NoUnreadSpamEmailsException())); + + controller.getSpamMailboxCached(testAccountId, testUserName); + await Future.delayed(const Duration(milliseconds: 100)); + + verify(storeSpamReportInteractor.execute(any)).called(1); + expect(controller.presentationSpamMailbox.value, isNull); + }); + + test('does not store dismissal when banner was not showing', () async { + controller.setSpamPresentationMailbox(null); + + when(getSpamMailboxCachedInteractor.execute(any, any)) + .thenAnswer((_) => failureStream(NoUnreadSpamEmailsException())); + + controller.getSpamMailboxCached(testAccountId, testUserName); + await Future.delayed(const Duration(milliseconds: 100)); + + verifyNever(storeSpamReportInteractor.execute(any)); + expect(controller.presentationSpamMailbox.value, isNull); + }); + + test('does not store dismissal when banner mailbox has 0 unread', () async { + controller.setSpamPresentationMailbox(makeMailboxWithUnread(0)); + + when(getSpamMailboxCachedInteractor.execute(any, any)) + .thenAnswer((_) => failureStream(NoUnreadSpamEmailsException())); + + controller.getSpamMailboxCached(testAccountId, testUserName); + await Future.delayed(const Duration(milliseconds: 100)); + + verifyNever(storeSpamReportInteractor.execute(any)); + expect(controller.presentationSpamMailbox.value, isNull); + }); + }); + + group('SpamDismissCooldownActiveException', () { + test('hides banner without storing dismissal time', () async { + controller.setSpamPresentationMailbox(makeMailboxWithUnread(5)); + + when(getSpamMailboxCachedInteractor.execute(any, any)) + .thenAnswer((_) => failureStream(SpamDismissCooldownActiveException())); + + controller.getSpamMailboxCached(testAccountId, testUserName); + await Future.delayed(const Duration(milliseconds: 100)); + + verifyNever(storeSpamReportInteractor.execute(any)); + expect(controller.presentationSpamMailbox.value, isNull); + }); + }); + + group('GetSpamMailboxCachedSuccess', () { + test('shows banner with the returned mailbox', () async { + final domainMailbox = jmap_mailbox.Mailbox( + id: jmap_mailbox.MailboxId(Id('spam')), + unreadEmails: jmap_mailbox.UnreadEmails(UnsignedInt(7)), + ); + when(getSpamMailboxCachedInteractor.execute(any, any)).thenAnswer((_) => + Stream.fromIterable([ + Right(GetSpamMailboxCachedLoading()), + Right(GetSpamMailboxCachedSuccess(domainMailbox)), + ])); + + controller.getSpamMailboxCached(testAccountId, testUserName); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(controller.presentationSpamMailbox.value, isNotNull); + }); + }); + }); +}