fixup! TF-4385 Fix spam banner show it once per day
This commit is contained in:
+5
-1
@@ -49,8 +49,12 @@ class GetSpamMailboxCachedInteractor {
|
|||||||
final lastTime = DateTime.fromMillisecondsSinceEpoch(lastTimeDismissedMs);
|
final lastTime = DateTime.fromMillisecondsSinceEpoch(lastTimeDismissedMs);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final elapsed = now.difference(lastTime);
|
final elapsed = now.difference(lastTime);
|
||||||
|
if (elapsed.isNegative) {
|
||||||
|
if (elapsed.abs() < const Duration(days: 1)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
final isIntervalElapsed =
|
final isIntervalElapsed =
|
||||||
elapsed.inHours > spamReportBannerDisplayIntervalInHours;
|
elapsed.inHours >= spamReportBannerDisplayIntervalInHours;
|
||||||
|
|
||||||
return isIntervalElapsed;
|
return isIntervalElapsed;
|
||||||
}
|
}
|
||||||
|
|||||||
+177
@@ -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<SpamReportRepository>()])
|
||||||
|
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<E>() => predicate<Either<Failure, Success>>(
|
||||||
|
(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<NoUnreadSpamEmailsException>(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<SpamDismissCooldownActiveException>(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<NoUnreadSpamEmailsException>(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+196
@@ -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<StoreSpamReportInteractor>(),
|
||||||
|
MockSpec<StoreSpamReportStateInteractor>(),
|
||||||
|
MockSpec<GetSpamReportStateInteractor>(),
|
||||||
|
MockSpec<GetSpamMailboxCachedInteractor>(),
|
||||||
|
// BaseController dependencies
|
||||||
|
MockSpec<CachingManager>(),
|
||||||
|
MockSpec<LanguageCacheManager>(),
|
||||||
|
MockSpec<AuthorizationInterceptors>(),
|
||||||
|
MockSpec<DynamicUrlInterceptors>(),
|
||||||
|
MockSpec<DeleteCredentialInteractor>(),
|
||||||
|
MockSpec<LogoutOidcInteractor>(),
|
||||||
|
MockSpec<DeleteAuthorityOidcInteractor>(),
|
||||||
|
MockSpec<AppToast>(),
|
||||||
|
MockSpec<ImagePaths>(),
|
||||||
|
MockSpec<ResponsiveUtils>(),
|
||||||
|
MockSpec<ToastManager>(),
|
||||||
|
MockSpec<TwakeAppManager>(),
|
||||||
|
])
|
||||||
|
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<CachingManager>(MockCachingManager());
|
||||||
|
Get.put<LanguageCacheManager>(MockLanguageCacheManager());
|
||||||
|
final authInterceptors = MockAuthorizationInterceptors();
|
||||||
|
Get.put<AuthorizationInterceptors>(authInterceptors);
|
||||||
|
Get.put<AuthorizationInterceptors>(authInterceptors, tag: BindingTag.isolateTag);
|
||||||
|
Get.put<DynamicUrlInterceptors>(MockDynamicUrlInterceptors());
|
||||||
|
Get.put<DeleteCredentialInteractor>(MockDeleteCredentialInteractor());
|
||||||
|
Get.put<LogoutOidcInteractor>(MockLogoutOidcInteractor());
|
||||||
|
Get.put<DeleteAuthorityOidcInteractor>(MockDeleteAuthorityOidcInteractor());
|
||||||
|
Get.put<AppToast>(MockAppToast());
|
||||||
|
Get.put<ImagePaths>(MockImagePaths());
|
||||||
|
Get.put<ResponsiveUtils>(MockResponsiveUtils());
|
||||||
|
Get.put<Uuid>(const Uuid());
|
||||||
|
Get.put<ToastManager>(MockToastManager());
|
||||||
|
Get.put<TwakeAppManager>(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<Either<Failure, Success>> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user