diff --git a/lib/features/base/mixin/batch_set_email_processing_mixin.dart b/lib/features/base/mixin/batch_set_email_processing_mixin.dart index 9c3dcc239..95c2cf6c2 100644 --- a/lib/features/base/mixin/batch_set_email_processing_mixin.dart +++ b/lib/features/base/mixin/batch_set_email_processing_mixin.dart @@ -48,52 +48,67 @@ mixin BatchSetEmailProcessingMixin on HandleSetErrorMixin, MailAPIMixin { for (int start = 0; start < totalEmails; start += batchSize) { int end = (start + batchSize < totalEmails) ? start + batchSize : totalEmails; - - log('EmailAPI::$debugLabel:emails from ${start + 1} to $end'); - final currentListEmailIds = emailIds.sublist(start, end); - final setEmailMethod = SetEmailMethod(accountId) - ..addUpdates(onGenerateUpdates(currentListEmailIds)); + log('BatchSetEmailProcessingMixin::$debugLabel: processing batch ${start + 1} to $end'); - final requestBuilder = JmapRequestBuilder( - httpClient, - ProcessingInvocation(), - ); - final setEmailInvocation = requestBuilder.invocation(setEmailMethod); - - final capabilities = setEmailMethod.requiredCapabilities - .toCapabilitiesSupportTeamMailboxes(session, accountId); - - final response = - await (requestBuilder..usings(capabilities)).build().execute(); - - SetEmailResponse? setEmailResponse; try { - setEmailResponse = response.parse( - setEmailInvocation.methodCallId, - SetEmailResponse.deserialize, + final response = await _executeSetEmailBatch( + session: session, + accountId: accountId, + httpClient: httpClient, + emailIds: currentListEmailIds, + onGenerateUpdates: onGenerateUpdates, ); + + if (response != null) { + final listEmailIds = response.updated?.keys.toEmailIds() ?? []; + updatedEmailIds.addAll(listEmailIds); + listSetResponse.add(response); + } else { + throw Exception('SetEmailResponse is null'); + } } catch (e) { - log('EmailAPI::$debugLabel: Failed to parse response: $e'); - setEmailResponse = null; + logWarning( + 'BatchSetEmailProcessingMixin::$debugLabel: Error processing batch ${start + 1}-$end', + ); } - - if (setEmailResponse == null) { - log('EmailAPI::$debugLabel: Batch from ${start + 1} to $end returned null response'); - continue; - } - - final listEmailIds = setEmailResponse.updated?.keys.toEmailIds() ?? []; - updatedEmailIds.addAll(listEmailIds); - listSetResponse.add(setEmailResponse); } - Map mapErrors = {}; + Map finalMapErrors = {}; if (listSetResponse.isNotEmpty) { - mapErrors = handleSetResponse(listSetResponse); + finalMapErrors = handleSetResponse(listSetResponse); } - return (emailIdsSuccess: updatedEmailIds, mapErrors: mapErrors); + return (emailIdsSuccess: updatedEmailIds, mapErrors: finalMapErrors); + } + + Future _executeSetEmailBatch({ + required Session session, + required AccountId accountId, + required HttpClient httpClient, + required List emailIds, + required OnGeneratePatchObjectUpdates onGenerateUpdates, + }) async { + final setEmailMethod = SetEmailMethod(accountId) + ..addUpdates(onGenerateUpdates(emailIds)); + + final requestBuilder = JmapRequestBuilder( + httpClient, + ProcessingInvocation(), + ); + + final setEmailInvocation = requestBuilder.invocation(setEmailMethod); + + final capabilities = setEmailMethod.requiredCapabilities + .toCapabilitiesSupportTeamMailboxes(session, accountId); + + final response = + await (requestBuilder..usings(capabilities)).build().execute(); + + return response.parse( + setEmailInvocation.methodCallId, + SetEmailResponse.deserialize, + ); } } diff --git a/lib/features/email/domain/usecases/add_a_label_to_a_thread_interactor.dart b/lib/features/email/domain/usecases/add_a_label_to_a_thread_interactor.dart index 294e6bf08..c09d82d8d 100644 --- a/lib/features/email/domain/usecases/add_a_label_to_a_thread_interactor.dart +++ b/lib/features/email/domain/usecases/add_a_label_to_a_thread_interactor.dart @@ -5,6 +5,7 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/composer/domain/exceptions/set_method_exception.dart'; import 'package:tmail_ui_user/features/email/domain/exceptions/email_exceptions.dart'; import 'package:tmail_ui_user/features/email/domain/repository/email_repository.dart'; import 'package:tmail_ui_user/features/email/domain/state/add_a_label_to_a_thread_state.dart'; @@ -37,7 +38,9 @@ class AddALabelToAThreadInteractor { )); } else if (result.emailIdsSuccess.isEmpty) { yield Left(AddALabelToAThreadFailure( - exception: EmailIdsSuccessIsEmptyException(), + exception: result.mapErrors.isNotEmpty + ? SetMethodException(result.mapErrors) + : EmailIdsSuccessIsEmptyException(), labelDisplay: labelDisplay, )); } else { diff --git a/lib/features/email/domain/usecases/labels/remove_a_label_from_a_thread_interactor.dart b/lib/features/email/domain/usecases/labels/remove_a_label_from_a_thread_interactor.dart index 094a26e5d..65b63b72b 100644 --- a/lib/features/email/domain/usecases/labels/remove_a_label_from_a_thread_interactor.dart +++ b/lib/features/email/domain/usecases/labels/remove_a_label_from_a_thread_interactor.dart @@ -5,6 +5,7 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; +import 'package:tmail_ui_user/features/composer/domain/exceptions/set_method_exception.dart'; import 'package:tmail_ui_user/features/email/domain/exceptions/email_exceptions.dart'; import 'package:tmail_ui_user/features/email/domain/repository/email_repository.dart'; import 'package:tmail_ui_user/features/email/domain/state/labels/remove_a_label_from_a_thread_state.dart'; @@ -37,7 +38,9 @@ class RemoveALabelFromAThreadInteractor { )); } else if (result.emailIdsSuccess.isEmpty) { yield Left(RemoveALabelFromAThreadFailure( - exception: EmailIdsSuccessIsEmptyException(), + exception: result.mapErrors.isNotEmpty + ? SetMethodException(result.mapErrors) + : EmailIdsSuccessIsEmptyException(), labelDisplay: labelDisplay, )); } else { diff --git a/test/features/base/mixin/batch_set_email_processing_mixin_test.dart b/test/features/base/mixin/batch_set_email_processing_mixin_test.dart index b1d9d897a..b7bfe0b1e 100644 --- a/test/features/base/mixin/batch_set_email_processing_mixin_test.dart +++ b/test/features/base/mixin/batch_set_email_processing_mixin_test.dart @@ -1,229 +1,30 @@ -import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jmap_dart_client/http/http_client.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/patch_object.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:tmail_ui_user/features/base/mixin/batch_set_email_processing_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/mail_api_mixin.dart'; -import 'package:jmap_dart_client/jmap/mail/email/email.dart'; -import 'package:jmap_dart_client/jmap/core/user_name.dart'; -import 'package:model/account/authentication_type.dart'; -import 'package:model/account/personal_account.dart'; -import 'package:jmap_dart_client/jmap/core/account/account.dart'; -import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; -import 'package:jmap_dart_client/jmap/core/capability/core_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/default_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/mail_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/mdn_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/submission_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/vacation_capability.dart'; -import 'package:jmap_dart_client/jmap/core/capability/websocket_capability.dart'; -import 'package:jmap_dart_client/jmap/core/session/session.dart'; -import 'package:jmap_dart_client/jmap/core/sort/collation_identifier.dart'; -import 'package:jmap_dart_client/jmap/core/state.dart'; -import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; -// --- Fixtures Cloned from Integration Tests --- - -class AccountFixtures { - static final aliceAccountId = AccountId(Id('29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6')); - static final aliceAccount = PersonalAccount( - 'dab', - AuthenticationType.oidc, - isSelected: true, - accountId: aliceAccountId, - apiUrl: 'https://domain.com/jmap', - userName: UserName('Alice') - ); -} - -class SessionFixtures { - static final aliceSession = Session( - { - CapabilityIdentifier.jmapSubmission: SubmissionCapability( - maxDelayedSend: UnsignedInt(0), - submissionExtensions: {} - ), - CapabilityIdentifier.jmapCore: CoreCapability( - maxSizeUpload: UnsignedInt(20971520), - maxConcurrentUpload: UnsignedInt(4), - maxSizeRequest: UnsignedInt(10000000), - maxConcurrentRequests: UnsignedInt(4), - maxCallsInRequest: UnsignedInt(16), - maxObjectsInGet: UnsignedInt(500), - maxObjectsInSet: UnsignedInt(500), - collationAlgorithms: {CollationIdentifier("i;unicode-casemap")} - ), - CapabilityIdentifier.jmapMail: MailCapability( - maxMailboxesPerEmail: UnsignedInt(10000000), - maxSizeAttachmentsPerEmail: UnsignedInt(20000000), - emailQuerySortOptions: {"receivedAt", "sentAt", "size", "from", "to", "subject"}, - mayCreateTopLevelMailbox: true - ), - CapabilityIdentifier.jmapWebSocket: WebSocketCapability( - supportsPush: true, - url: Uri.parse('ws://domain.com/jmap/ws') - ), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): DefaultCapability({}), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): DefaultCapability({}), - CapabilityIdentifier.jmapVacationResponse: VacationCapability(), - CapabilityIdentifier.jmapMdn: MdnCapability(), - }, - { - AccountFixtures.aliceAccountId: Account( - AccountName('alice@domain.tld'), - true, - false, - { - CapabilityIdentifier.jmapSubmission: SubmissionCapability( - maxDelayedSend: UnsignedInt(0), - submissionExtensions: {} - ), - CapabilityIdentifier.jmapWebSocket: WebSocketCapability( - supportsPush: true, - url: Uri.parse('ws://domain.com/jmap/ws') - ), - CapabilityIdentifier.jmapCore: CoreCapability( - maxSizeUpload: UnsignedInt(20971520), - maxConcurrentUpload: UnsignedInt(4), - maxSizeRequest: UnsignedInt(10000000), - maxConcurrentRequests: UnsignedInt(4), - maxCallsInRequest: UnsignedInt(16), - maxObjectsInGet: UnsignedInt(500), - maxObjectsInSet: UnsignedInt(500), - collationAlgorithms: {CollationIdentifier("i;unicode-casemap")} - ), - CapabilityIdentifier.jmapMail: MailCapability( - maxMailboxesPerEmail: UnsignedInt(10000000), - maxSizeAttachmentsPerEmail: UnsignedInt(20000000), - emailQuerySortOptions: {"receivedAt", "sentAt", "size", "from", "to", "subject"}, - mayCreateTopLevelMailbox: true - ), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): DefaultCapability({}), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): DefaultCapability({}), - CapabilityIdentifier.jmapVacationResponse: VacationCapability(), - CapabilityIdentifier.jmapMdn: MdnCapability() - } - ) - }, - { - CapabilityIdentifier.jmapSubmission: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapWebSocket: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapCore: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapMail: AccountFixtures.aliceAccountId, - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): AccountFixtures.aliceAccountId, - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapVacationResponse: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapMdn: AccountFixtures.aliceAccountId, - }, - UserName('alice@domain.tld'), - Uri.parse('http://domain.com/jmap'), - Uri.parse('http://domain.com/download/{accountId}/{blobId}/?type={type}&name={name}'), - Uri.parse('http://domain.com/upload/{accountId}'), - Uri.parse('http://domain.com/eventSource?types={types}&closeAfter={closeafter}&ping={ping}'), - State('2c9f1b12-b35a-43e6-9af2-0106fb53a943') - ); - - static getAliceSessionWithMaxObjectsInSet(int maxObjectsInSet) => Session( - { - CapabilityIdentifier.jmapSubmission: SubmissionCapability( - maxDelayedSend: UnsignedInt(0), - submissionExtensions: {} - ), - CapabilityIdentifier.jmapCore: CoreCapability( - maxSizeUpload: UnsignedInt(20971520), - maxConcurrentUpload: UnsignedInt(4), - maxSizeRequest: UnsignedInt(10000000), - maxConcurrentRequests: UnsignedInt(4), - maxCallsInRequest: UnsignedInt(16), - maxObjectsInGet: UnsignedInt(500), - maxObjectsInSet: UnsignedInt(maxObjectsInSet), - collationAlgorithms: {CollationIdentifier("i;unicode-casemap")} - ), - CapabilityIdentifier.jmapMail: MailCapability( - maxMailboxesPerEmail: UnsignedInt(10000000), - maxSizeAttachmentsPerEmail: UnsignedInt(20000000), - emailQuerySortOptions: {"receivedAt", "sentAt", "size", "from", "to", "subject"}, - mayCreateTopLevelMailbox: true - ), - CapabilityIdentifier.jmapWebSocket: WebSocketCapability( - supportsPush: true, - url: Uri.parse('ws://domain.com/jmap/ws') - ), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): DefaultCapability({}), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): DefaultCapability({}), - CapabilityIdentifier.jmapVacationResponse: VacationCapability(), - CapabilityIdentifier.jmapMdn: MdnCapability(), - }, - { - AccountFixtures.aliceAccountId: Account( - AccountName('alice@domain.tld'), - true, - false, - { - CapabilityIdentifier.jmapSubmission: SubmissionCapability( - maxDelayedSend: UnsignedInt(0), - submissionExtensions: {} - ), - CapabilityIdentifier.jmapWebSocket: WebSocketCapability( - supportsPush: true, - url: Uri.parse('ws://domain.com/jmap/ws') - ), - CapabilityIdentifier.jmapCore: CoreCapability( - maxSizeUpload: UnsignedInt(20971520), - maxConcurrentUpload: UnsignedInt(4), - maxSizeRequest: UnsignedInt(10000000), - maxConcurrentRequests: UnsignedInt(4), - maxCallsInRequest: UnsignedInt(16), - maxObjectsInGet: UnsignedInt(500), - maxObjectsInSet: UnsignedInt(maxObjectsInSet), - collationAlgorithms: {CollationIdentifier("i;unicode-casemap")} - ), - CapabilityIdentifier.jmapMail: MailCapability( - maxMailboxesPerEmail: UnsignedInt(10000000), - maxSizeAttachmentsPerEmail: UnsignedInt(20000000), - emailQuerySortOptions: {"receivedAt", "sentAt", "size", "from", "to", "subject"}, - mayCreateTopLevelMailbox: true - ), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): DefaultCapability({}), - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): DefaultCapability({}), - CapabilityIdentifier.jmapVacationResponse: VacationCapability(), - CapabilityIdentifier.jmapMdn: MdnCapability() - } - ) - }, - { - CapabilityIdentifier.jmapSubmission: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapWebSocket: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapCore: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapMail: AccountFixtures.aliceAccountId, - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:quota')): AccountFixtures.aliceAccountId, - CapabilityIdentifier(Uri.parse('urn:apache:james:params:jmap:mail:shares')): AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapVacationResponse: AccountFixtures.aliceAccountId, - CapabilityIdentifier.jmapMdn: AccountFixtures.aliceAccountId, - }, - UserName('alice@domain.tld'), - Uri.parse('http://domain.com/jmap'), - Uri.parse('http://domain.com/download/{accountId}/{blobId}/?type={type}&name={name}'), - Uri.parse('http://domain.com/upload/{accountId}'), - Uri.parse('http://domain.com/eventSource?types={types}&closeAfter={closeafter}&ping={ping}'), - State('2c9f1b12-b35a-43e6-9af2-0106fb53a943') - ); -} - -// --- End Fixtures --- +import '../../../fixtures/account_fixtures.dart'; +import '../../../fixtures/session_fixtures.dart'; class ManualMockHttpClient implements HttpClient { int postCallCount = 0; final Map _responses = {}; + final Map _errors = {}; // Store errors/exceptions to throw void setResponse(int callIndex, dynamic response) { _responses[callIndex] = response; } + /// Simulate a crash for a specific batch + void setError(int callIndex, Object error) { + _errors[callIndex] = error; + } + @override Future> post( String url, { @@ -234,19 +35,25 @@ class ManualMockHttpClient implements HttpClient { ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, }) async { - final response = _responses[postCallCount]; + final currentCall = postCallCount; postCallCount++; + + // If an error is registered for this call, throw it immediately + if (_errors.containsKey(currentCall)) { + throw _errors[currentCall]!; + } + + final response = _responses[currentCall]; if (response is Map) { - return response; + return response; } return {}; } - + @override noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } - class TestBatchSetEmailProcessing with HandleSetErrorMixin, MailAPIMixin, BatchSetEmailProcessingMixin {} @@ -258,7 +65,7 @@ void main() { setUp(() { httpClient = ManualMockHttpClient(); testMixin = TestBatchSetEmailProcessing(); - httpClient.postCallCount = 0; // Explicit reset or ensure new instance + httpClient.postCallCount = 0; }); test( @@ -287,73 +94,64 @@ void main() { final List emailIds = List.generate( totalEmails, (index) => EmailId(Id('email_$index'))); - // Mock response for 3 batches (10, 10, 5) - // Call 0: 10 success + // Batch 1 (0-9): Success httpClient.setResponse(0, { - "sessionState": "session_state", - "methodResponses": [ - [ - "Email/set", - { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 0; i < 10; i++) "email_$i": null - } - }, - "c0" - ] + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "oldState": "oldState", + "newState": "newState", + "updated": {for (var i = 0; i < 10; i++) "email_$i": null} + }, + "c0" ] - }); - - // Call 1: 10 success + ] + }); + + // Batch 2 (10-19): Success httpClient.setResponse(1, { - "sessionState": "session_state", - "methodResponses": [ - [ - "Email/set", - { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 10; i < 20; i++) "email_$i": null - } - }, - "c0" - ] + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "oldState": "oldState", + "newState": "newState", + "updated": {for (var i = 10; i < 20; i++) "email_$i": null} + }, + "c0" ] - }); + ] + }); - // Call 2: 5 success + // Batch 3 (20-24): Success httpClient.setResponse(2, { - "sessionState": "session_state", - "methodResponses": [ - [ - "Email/set", - { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 20; i < 25; i++) "email_$i": null - } - }, - "c0" - ] + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "oldState": "oldState", + "newState": "newState", + "updated": {for (var i = 20; i < 25; i++) "email_$i": null} + }, + "c0" ] - }); - + ] + }); final result = await testMixin.executeBatchSetEmail( session: session, accountId: AccountFixtures.aliceAccountId, emailIds: emailIds, httpClient: httpClient, - onGenerateUpdates: (batchIds) => { - for (final id in batchIds) id.id: PatchObject({}) - }, + onGenerateUpdates: (batchIds) => + {for (final id in batchIds) id.id: PatchObject({})}, ); expect(result.emailIdsSuccess.length, totalEmails); @@ -369,74 +167,7 @@ void main() { final List emailIds = List.generate( totalEmails, (index) => EmailId(Id('email_$index'))); - // Batch 1: Success (0-9) - httpClient.setResponse(0, { - "sessionState": "session_state", - "methodResponses": [ - [ - "Email/set", - { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 0; i < 10; i++) "email_$i": null - } - }, - "c0" - ] - ] - }); - - // Batch 2: Partial failure (10-19, 19 fails) - httpClient.setResponse(1, { - "sessionState": "session_state", - "methodResponses": [ - [ - "Email/set", - { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 10; i < 19; i++) "email_$i": null - }, - "notUpdated": { - "email_19": { - "type": "someError", - "description": "error description" - } - } - }, - "c0" - ] - ] - }); - - final result = await testMixin.executeBatchSetEmail( - session: session, - accountId: AccountFixtures.aliceAccountId, - emailIds: emailIds, - httpClient: httpClient, - onGenerateUpdates: (batchIds) => { - for (final id in batchIds) id.id: PatchObject({}) - }, - ); - - expect(result.emailIdsSuccess.length, 19); - expect(result.mapErrors.length, 1); - expect(result.mapErrors[Id('email_19')], isNotNull); - expect(httpClient.postCallCount, 2); - }); - test('executeBatchSetEmail should handle null response gracefully from a batch', () async { - const maxObjects = 10; - final session = - SessionFixtures.getAliceSessionWithMaxObjectsInSet(maxObjects); - const totalEmails = 20; // 2 batches - final List emailIds = List.generate( - totalEmails, (index) => EmailId(Id('email_$index'))); - - // Batch 1: Success (0-9) + // Batch 1: Success httpClient.setResponse(0, { "sessionState": "session_state", "methodResponses": [ @@ -446,30 +177,32 @@ void main() { "accountId": AccountFixtures.aliceAccountId.id.value, "oldState": "oldState", "newState": "newState", - "updated": { - for (var i = 0; i < 10; i++) "email_$i": null - } + "updated": {for (var i = 0; i < 10; i++) "email_$i": null} }, "c0" ] ] }); - // Batch 2: Null response (simulated by mismatching methodCallId) - // Request uses 'c0' (default in tests usually), so we return 'c999' - // This causes response.parse('c0') to not find the response and should return null. + // Batch 2: Partial failure (ID email_19 fails) httpClient.setResponse(1, { "sessionState": "session_state", "methodResponses": [ [ "Email/set", { - "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": {} + "accountId": AccountFixtures.aliceAccountId.id.value, + "oldState": "oldState", + "newState": "newState", + "updated": {for (var i = 10; i < 19; i++) "email_$i": null}, + "notUpdated": { + "email_19": { + "type": "someError", + "description": "error description" + } + } }, - "c999" // Mismatched ID + "c0" ] ] }); @@ -479,23 +212,23 @@ void main() { accountId: AccountFixtures.aliceAccountId, emailIds: emailIds, httpClient: httpClient, - onGenerateUpdates: (batchIds) => { - for (final id in batchIds) id.id: PatchObject({}) - }, + onGenerateUpdates: (batchIds) => + {for (final id in batchIds) id.id: PatchObject({})}, ); - // Should have processed 10 successfully from first batch - expect(result.emailIdsSuccess.length, 10); - // And 0 from second batch (silently ignored/logged) - expect(result.mapErrors, isEmpty); + expect(result.emailIdsSuccess.length, 19); + expect(result.mapErrors.length, 1); + expect(result.mapErrors[Id('email_19')], isNotNull); expect(httpClient.postCallCount, 2); }); - test('executeBatchSetEmail should handle exact match of maxObjectsInSet boundary', () async { + test( + 'executeBatchSetEmail should handle null response gracefully from a batch', + () async { const maxObjects = 10; final session = SessionFixtures.getAliceSessionWithMaxObjectsInSet(maxObjects); - const totalEmails = 20; // Exactly 2 batches + const totalEmails = 20; final List emailIds = List.generate( totalEmails, (index) => EmailId(Id('email_$index'))); @@ -509,9 +242,61 @@ void main() { "accountId": AccountFixtures.aliceAccountId.id.value, "oldState": "oldState", "newState": "newState", - "updated": { - for (var i = 0; i < 10; i++) "email_$i": null - } + "updated": {for (var i = 0; i < 10; i++) "email_$i": null} + }, + "c0" + ] + ] + }); + + // Batch 2: Null response simulation (wrong methodCallId) + httpClient.setResponse(1, { + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "updated": {} + }, + "c999" // Mismatched ID causes parse to return null + ] + ] + }); + + final result = await testMixin.executeBatchSetEmail( + session: session, + accountId: AccountFixtures.aliceAccountId, + emailIds: emailIds, + httpClient: httpClient, + onGenerateUpdates: (batchIds) => + {for (final id in batchIds) id.id: PatchObject({})}, + ); + + expect(result.emailIdsSuccess.length, 10); + expect(result.mapErrors, isEmpty); + expect(httpClient.postCallCount, 2); + }); + + test( + 'executeBatchSetEmail should handle exact match of maxObjectsInSet boundary', + () async { + const maxObjects = 10; + final session = + SessionFixtures.getAliceSessionWithMaxObjectsInSet(maxObjects); + const totalEmails = 20; + final List emailIds = List.generate( + totalEmails, (index) => EmailId(Id('email_$index'))); + + // Batch 1: Success + httpClient.setResponse(0, { + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "updated": {for (var i = 0; i < 10; i++) "email_$i": null} }, "c0" ] @@ -526,11 +311,7 @@ void main() { "Email/set", { "accountId": AccountFixtures.aliceAccountId.id.value, - "oldState": "oldState", - "newState": "newState", - "updated": { - for (var i = 10; i < 20; i++) "email_$i": null - } + "updated": {for (var i = 10; i < 20; i++) "email_$i": null} }, "c0" ] @@ -542,14 +323,90 @@ void main() { accountId: AccountFixtures.aliceAccountId, emailIds: emailIds, httpClient: httpClient, - onGenerateUpdates: (batchIds) => { - for (final id in batchIds) id.id: PatchObject({}) - }, + onGenerateUpdates: (batchIds) => + {for (final id in batchIds) id.id: PatchObject({})}, ); expect(result.emailIdsSuccess.length, 20); - expect(result.mapErrors, isEmpty); expect(httpClient.postCallCount, 2); }); + + test( + 'executeBatchSetEmail should continue processing subsequent batches even when one batch throws an exception', + () async { + // Setup: 3 batches of 10 emails each (Total 30) + const maxObjects = 10; + final session = + SessionFixtures.getAliceSessionWithMaxObjectsInSet(maxObjects); + const totalEmails = 30; + final List emailIds = List.generate( + totalEmails, (index) => EmailId(Id('email_$index'))); + + // Batch 1 (0-9): SUCCESS + httpClient.setResponse(0, { + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "updated": {for (var i = 0; i < 10; i++) "email_$i": null} + }, + "c0" + ] + ] + }); + + // Batch 2 (10-19): FAILURE (Network Exception) + // This simulates a DioException or any error during the API call + httpClient.setError(1, Exception('Network connection failed')); + + // Batch 3 (20-29): SUCCESS + // This ensures the loop didn't break after Batch 2 failed + httpClient.setResponse(2, { + "sessionState": "session_state", + "methodResponses": [ + [ + "Email/set", + { + "accountId": AccountFixtures.aliceAccountId.id.value, + "updated": {for (var i = 20; i < 30; i++) "email_$i": null} + }, + "c0" + ] + ] + }); + + final result = await testMixin.executeBatchSetEmail( + session: session, + accountId: AccountFixtures.aliceAccountId, + emailIds: emailIds, + httpClient: httpClient, + onGenerateUpdates: (batchIds) => + {for (final id in batchIds) id.id: PatchObject({})}, + ); + + // Assertions: + // We expect emails from Batch 1 and Batch 3 to be successful (10 + 10 = 20) + expect(result.emailIdsSuccess.length, 20); + + // Ensure Batch 1 IDs are present + expect(result.emailIdsSuccess.contains(EmailId(Id('email_0'))), isTrue); + + // Ensure Batch 3 IDs are present + expect( + result.emailIdsSuccess.contains(EmailId(Id('email_20'))), isTrue); + + // Ensure Batch 2 IDs are NOT present + expect( + result.emailIdsSuccess.contains(EmailId(Id('email_10'))), isFalse); + + // Ensure the httpClient was called 3 times (it tried all batches) + expect(httpClient.postCallCount, 3); + + // mapErrors should be empty + expect(result.mapErrors, isEmpty); + }, + ); }); }