From 000a2e3985728752fcafcd7b111dcf17ceabb861 Mon Sep 17 00:00:00 2001 From: dab246 Date: Tue, 24 Jun 2025 10:37:45 +0700 Subject: [PATCH] Add unit test for combineEmailCache method Signed-off-by: dab246 --- .../upgrade_hive_database_steps_v17.dart | 4 +- ...rver.dart => fcm_isolate_name_server.dart} | 0 .../caching/config/hive_cache_config.dart | 5 +- .../email/presentation/utils/email_utils.dart | 6 +- .../repository/thread_repository_impl.dart | 40 ++- .../combine_email_cache_method_test.dart | 310 ++++++++++++++++++ 6 files changed, 350 insertions(+), 15 deletions(-) rename lib/features/caching/config/{fcm_isolate_nam_server.dart => fcm_isolate_name_server.dart} (100%) create mode 100644 test/features/thread/data/repository/combine_email_cache_method_test.dart diff --git a/lib/features/base/upgradeable/upgrade_hive_database_steps_v17.dart b/lib/features/base/upgradeable/upgrade_hive_database_steps_v17.dart index f551abceb..588ee5530 100644 --- a/lib/features/base/upgradeable/upgrade_hive_database_steps_v17.dart +++ b/lib/features/base/upgradeable/upgrade_hive_database_steps_v17.dart @@ -2,11 +2,11 @@ import 'package:tmail_ui_user/features/base/upgradeable/upgrade_database_steps.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; -class UpgradeHiveDatabaseStepsV16 extends UpgradeDatabaseSteps { +class UpgradeHiveDatabaseStepsV17 extends UpgradeDatabaseSteps { final CachingManager _cachingManager; - UpgradeHiveDatabaseStepsV16(this._cachingManager); + UpgradeHiveDatabaseStepsV17(this._cachingManager); @override Future onUpgrade(int oldVersion, int newVersion) async { diff --git a/lib/features/caching/config/fcm_isolate_nam_server.dart b/lib/features/caching/config/fcm_isolate_name_server.dart similarity index 100% rename from lib/features/caching/config/fcm_isolate_nam_server.dart rename to lib/features/caching/config/fcm_isolate_name_server.dart diff --git a/lib/features/caching/config/hive_cache_config.dart b/lib/features/caching/config/hive_cache_config.dart index 327135cfd..c411708e0 100644 --- a/lib/features/caching/config/hive_cache_config.dart +++ b/lib/features/caching/config/hive_cache_config.dart @@ -13,10 +13,11 @@ import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_st import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v14.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v15.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v16.dart'; +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v17.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v7.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; import 'package:tmail_ui_user/features/caching/config/cache_version.dart'; -import 'package:tmail_ui_user/features/caching/config/fcm_isolate_nam_server.dart'; +import 'package:tmail_ui_user/features/caching/config/fcm_isolate_name_server.dart'; import 'package:tmail_ui_user/features/caching/utils/caching_constants.dart'; import 'package:tmail_ui_user/features/home/data/model/session_hive_obj.dart'; import 'package:tmail_ui_user/features/login/data/local/encryption_key_cache_manager.dart'; @@ -82,7 +83,7 @@ class HiveCacheConfig { await UpgradeHiveDatabaseStepsV14(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV15(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV16(cachingManager).onUpgrade(oldVersion, newVersion); - await UpgradeHiveDatabaseStepsV16(cachingManager).onUpgrade(oldVersion, newVersion); + await UpgradeHiveDatabaseStepsV17(cachingManager).onUpgrade(oldVersion, newVersion); if (oldVersion != newVersion) { await cachingManager.storeCacheVersion(newVersion); diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index 3539bdabc..8a983aae1 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -15,7 +15,6 @@ import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.dart'; import 'package:model/email/attachment.dart'; import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; @@ -39,10 +38,7 @@ class EmailUtils { static Properties getPropertiesForEmailChangeMethod(Session session, AccountId accountId) { if (CapabilityIdentifier.jamesCalendarEvent.isSupported(session, accountId)) { - return Properties({ - ...ThreadConstants.propertiesUpdatedDefault.value, - IndividualHeaderIdentifier.headerCalendarEvent.value, - }); + return ThreadConstants.propertiesCalendarEvent; } else { return ThreadConstants.propertiesUpdatedDefault; } diff --git a/lib/features/thread/data/repository/thread_repository_impl.dart b/lib/features/thread/data/repository/thread_repository_impl.dart index 84983c2af..e515fb6ff 100644 --- a/lib/features/thread/data/repository/thread_repository_impl.dart +++ b/lib/features/thread/data/repository/thread_repository_impl.dart @@ -5,12 +5,13 @@ import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; import 'package:core/utils/app_logger.dart'; import 'package:dartz/dartz.dart' as dartz; +import 'package:flutter/cupertino.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/filter/filter.dart'; import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/core/sort/comparator.dart'; -import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; 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/email/email.dart'; @@ -171,6 +172,18 @@ class ThreadRepositoryImpl extends ThreadRepository { return networkEmailResponse; } + @visibleForTesting + Future?> combineEmailCache({ + List? emailUpdated, + Properties? updatedProperties, + List? emailCacheList, + }) => + _combineEmailCache( + emailUpdated: emailUpdated, + updatedProperties: updatedProperties, + emailCacheList: emailCacheList, + ); + Future?> _combineEmailCache({ List? emailUpdated, Properties? updatedProperties, @@ -197,6 +210,13 @@ class ThreadRepositoryImpl extends ThreadRepository { return combinedEmails; } + @visibleForTesting + ({Email updatedEmail, Email? oldEmail}) combineUpdatedWithEmailInCache( + Email updatedEmail, + List? emailCacheList, + ) => + _combineUpdatedWithEmailInCache(updatedEmail, emailCacheList); + ({Email updatedEmail, Email? oldEmail}) _combineUpdatedWithEmailInCache( Email updatedEmail, List? emailCacheList, @@ -227,16 +247,24 @@ class ThreadRepositoryImpl extends ThreadRepository { destroyed: newDestroyed); } - Future _updateState(AccountId accountId, UserName userName, State newState) async { + Future _updateState( + AccountId accountId, + UserName userName, + jmap.State newState, + ) async { log('ThreadRepositoryImpl::_updateState(): [MAIL] $newState'); - await stateDataSource.saveState(accountId, userName, newState.toStateCache(StateType.email)); + await stateDataSource.saveState( + accountId, + userName, + newState.toStateCache(StateType.email), + ); } @override Stream refreshChanges( Session session, AccountId accountId, - State currentState, + jmap.State currentState, { Set? sort, EmailFilter? emailFilter, @@ -371,7 +399,7 @@ class ThreadRepositoryImpl extends ThreadRepository { Future _synchronizeCacheWithChanges( Session session, AccountId accountId, - State currentState, + jmap.State currentState, { Properties? propertiesCreated, Properties? propertiesUpdated, @@ -381,7 +409,7 @@ class ThreadRepositoryImpl extends ThreadRepository { EmailChangeResponse? emailChangeResponse; bool hasMoreChanges = true; - State? sinceState = currentState; + jmap.State? sinceState = currentState; while(hasMoreChanges && sinceState != null) { log('ThreadRepositoryImpl::_synchronizeCacheWithChanges(): sinceState = $sinceState'); diff --git a/test/features/thread/data/repository/combine_email_cache_method_test.dart b/test/features/thread/data/repository/combine_email_cache_method_test.dart new file mode 100644 index 000000000..6bfaa48b0 --- /dev/null +++ b/test/features/thread/data/repository/combine_email_cache_method_test.dart @@ -0,0 +1,310 @@ +import 'package:core/data/model/source_type/data_source_type.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.dart'; +import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/email_property.dart'; +import 'package:tmail_ui_user/features/mailbox/data/datasource/state_datasource.dart'; +import 'package:tmail_ui_user/features/thread/data/datasource/thread_datasource.dart'; +import 'package:tmail_ui_user/features/thread/data/repository/thread_repository_impl.dart'; +import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; + +class MockThreadDataSource extends Mock implements ThreadDataSource {} + +class MockStateDataSource extends Mock implements StateDataSource {} + +void main() { + late ThreadRepositoryImpl repository; + late Map mockDataSources; + late MockStateDataSource mockStateDataSource; + + Email createEmail({ + String? id, + String? subject, + Map? mailboxIds, + Map? keywords, + Map? xPriorityHeader, + }) { + return Email( + id: id != null ? EmailId(Id(id)) : null, + subject: subject, + mailboxIds: mailboxIds ?? {MailboxId(Id('inbox')): true}, + keywords: keywords ?? {KeyWordIdentifier.emailSeen: true}, + xPriorityHeader: xPriorityHeader, + ); + } + + setUp(() { + mockDataSources = { + DataSourceType.network: MockThreadDataSource(), + DataSourceType.local: MockThreadDataSource(), + }; + mockStateDataSource = MockStateDataSource(); + repository = ThreadRepositoryImpl(mockDataSources, mockStateDataSource); + }); + + group('ThreadRepositoryImpl.combineEmailCache', () { + final defaultProperties = ThreadConstants.propertiesDefault; + final nonDefaultProperties = + Properties({EmailProperty.id, EmailProperty.subject}); + + final email1 = createEmail( + id: '1', + subject: 'Original 1', + keywords: {KeyWordIdentifier.emailFlagged: true}, + mailboxIds: {MailboxId(Id('inbox')): true}, + ); + + final email2 = createEmail( + id: '2', + subject: 'Original 2', + keywords: {KeyWordIdentifier.emailSeen: true}, + mailboxIds: {MailboxId(Id('archive')): true}, + ); + + final emailCacheList = [email1, email2]; + + test('should return null for null emailUpdated', () async { + expect( + await repository.combineEmailCache( + emailUpdated: null, + updatedProperties: defaultProperties, + emailCacheList: emailCacheList, + ), + isNull, + ); + }); + + test('should return empty list for empty emailUpdated', () async { + expect( + await repository.combineEmailCache( + emailUpdated: [], + updatedProperties: defaultProperties, + emailCacheList: emailCacheList, + ), + isEmpty, + ); + }); + + test('should return null for null updatedProperties', () async { + expect( + await repository.combineEmailCache( + emailUpdated: [createEmail(id: '1')], + updatedProperties: null, + emailCacheList: emailCacheList, + ), + isNull, + ); + }); + + test('should return identical list when properties are default', () async { + final updated = [createEmail(id: '1', subject: 'Updated')]; + expect( + await repository.combineEmailCache( + emailUpdated: updated, + updatedProperties: defaultProperties, + emailCacheList: emailCacheList, + ), + equals(updated), + ); + }); + + test('should merge partial updates for non-default properties', () async { + final updated = [createEmail(id: '1', subject: 'New Subject')]; + final result = await repository.combineEmailCache( + emailUpdated: updated, + updatedProperties: nonDefaultProperties, + emailCacheList: emailCacheList, + ); + + expect(result, hasLength(1)); + expect(result![0].subject, 'New Subject'); // Updated + expect( + result[0].keywords, + {KeyWordIdentifier.emailFlagged: true}, + ); // From cache + }); + + test('should skip emails not found in cache', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: '99')], + updatedProperties: nonDefaultProperties, + emailCacheList: emailCacheList, + ); + expect(result, isEmpty); + }); + + test('should handle empty cache list', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: '1')], + updatedProperties: nonDefaultProperties, + emailCacheList: [], + ); + expect(result, isEmpty); + }); + + test('should handle null cache list', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: '1')], + updatedProperties: nonDefaultProperties, + emailCacheList: null, + ); + expect(result, isEmpty); + }); + + test('should handle email with null ID', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: null)], + updatedProperties: nonDefaultProperties, + emailCacheList: emailCacheList, + ); + expect(result, isEmpty); + }); + + test('should preserve xPriorityHeader when not in updated properties', + () async { + final cached = createEmail( + id: '1', + xPriorityHeader: { + IndividualHeaderIdentifier.xPriorityHeader: 'high', + }, + ); + final updated = createEmail(id: '1', subject: 'Updated'); + + final result = await repository.combineEmailCache( + emailUpdated: [updated], + updatedProperties: nonDefaultProperties, + emailCacheList: [cached], + ); + + expect(result![0].xPriorityHeader, equals(cached.xPriorityHeader)); + }); + + test('should update xPriorityHeader when in updated properties', () async { + final newXPriorityHeader = { + IndividualHeaderIdentifier.xPriorityHeader: 'low', + }; + final updated = createEmail( + id: '1', + xPriorityHeader: newXPriorityHeader, + ); + + final result = await repository.combineEmailCache( + emailUpdated: [updated], + updatedProperties: Properties({ + EmailProperty.id, + IndividualHeaderIdentifier.xPriorityHeader.value, + }), + emailCacheList: [createEmail(id: '1')], + ); + + expect(result![0].xPriorityHeader, equals(newXPriorityHeader)); + }); + + test('should handle multiple emails update', () async { + final updated = [ + createEmail(id: '1', subject: 'Updated 1'), + createEmail(id: '2', subject: 'Updated 2'), + ]; + + final result = await repository.combineEmailCache( + emailUpdated: updated, + updatedProperties: nonDefaultProperties, + emailCacheList: emailCacheList, + ); + + expect(result, hasLength(2)); + expect(result![0].subject, 'Updated 1'); + expect(result[1].subject, 'Updated 2'); + }); + + test('should handle mix of cached and non-cached emails', () async { + final updated = [ + createEmail(id: '1'), // Exists in cache + createEmail(id: '99'), // Not in cache + createEmail(id: '2'), // Exists in cache + ]; + + final result = await repository.combineEmailCache( + emailUpdated: updated, + updatedProperties: nonDefaultProperties, + emailCacheList: emailCacheList, + ); + + expect(result, hasLength(2)); + expect(result!.map((e) => e.id?.id.value), containsAll(['1', '2'])); + }); + + test('should handle empty properties set', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: '1')], + updatedProperties: Properties({}), + emailCacheList: emailCacheList, + ); + expect(result, hasLength(1)); + expect(result![0].subject, 'Original 1'); + }); + + test('should handle properties with only non-existing fields', () async { + final result = await repository.combineEmailCache( + emailUpdated: [createEmail(id: '1')], + updatedProperties: Properties({'non_existing_field'}), + emailCacheList: emailCacheList, + ); + expect(result, hasLength(1)); + expect(result![0].subject, 'Original 1'); + }); + }); + + group('ThreadRepositoryImpl.combineUpdatedWithEmailInCache', () { + final cache = [ + createEmail(id: '1'), + createEmail(id: '2'), + ]; + + test('should find existing email in cache', () { + final result = repository.combineUpdatedWithEmailInCache( + createEmail(id: '1'), + cache, + ); + expect(result.oldEmail, isNotNull); + expect(result.updatedEmail.id?.id.value, '1'); + }); + + test('should return null for non-existing email', () { + final result = repository.combineUpdatedWithEmailInCache( + createEmail(id: '99'), + cache, + ); + expect(result.oldEmail, isNull); + }); + + test('should return null for null email ID', () { + final result = repository.combineUpdatedWithEmailInCache( + createEmail(id: null), + cache, + ); + expect(result.oldEmail, isNull); + }); + + test('should return null for empty cache', () { + final result = repository.combineUpdatedWithEmailInCache( + createEmail(id: '1'), + [], + ); + expect(result.oldEmail, isNull); + }); + + test('should return null for null cache', () { + final result = repository.combineUpdatedWithEmailInCache( + createEmail(id: '1'), + null, + ); + expect(result.oldEmail, isNull); + }); + }); +}