diff --git a/test/features/composer/data/html_analyzer_test.dart b/test/features/composer/data/html_analyzer_test.dart new file mode 100644 index 000000000..9be54d1db --- /dev/null +++ b/test/features/composer/data/html_analyzer_test.dart @@ -0,0 +1,288 @@ +import 'package:core/presentation/utils/html_transformer/html_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:html/parser.dart'; +import 'package:http_parser/http_parser.dart' show MediaType; +import 'package:jmap_dart_client/jmap/core/id.dart' show Id; +import 'package:model/email/attachment.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/email/data/local/html_analyzer.dart'; +import 'package:tmail_ui_user/features/upload/data/network/file_uploader.dart'; +import 'package:uuid/uuid.dart'; +import 'html_analyzer_test.mocks.dart'; + +@GenerateMocks([HtmlTransform, FileUploader, Uuid]) +void main() { + group('HtmlAnalyzer::replaceImageBase64ToImageCID::', () { + late HtmlAnalyzer htmlAnalyzer; + late MockHtmlTransform mockHtmlTransform; + late MockFileUploader mockFileUploader; + late MockUuid mockUuid; + + setUp(() { + mockHtmlTransform = MockHtmlTransform(); + mockFileUploader = MockFileUploader(); + mockUuid = MockUuid(); + htmlAnalyzer = HtmlAnalyzer(mockHtmlTransform, mockFileUploader, mockUuid); + }); + + test( + 'When HTML content has no base64 images,\n' + 'should return original content and empty attachments if no inline attachments exist', + () async { + const htmlContent = '
Hello World
'; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + expect(result.value1, htmlContent); + expect(result.value2, isEmpty); + }); + + test( + 'When HTML content has an image with existing CID,\n' + 'should update src to CID format and include attachment in result', + () async { + const htmlContent = ''; + final inlineAttachments = { + 'test123': Attachment( + blobId: Id('test123'), + type: MediaType('image', 'png'), + disposition: ContentDisposition.inline, + cid: 'test123', + ) + }; + final uploadUri = Uri.parse('https://example.com/upload'); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTag = document.querySelector('img'); + expect(imgTag?.attributes['src'], 'cid:test123'); + expect(imgTag?.attributes.containsKey('id'), isFalse); + expect(result.value2.length, 1); + expect(result.value2.first.cid, 'test123'); + }); + + test( + 'When HTML content has a base64 image and upload succeeds,\n' + 'should replace src with new CID and add attachment to result', + () async { + const base64Data = 'iVBORw0KGgo='; + const htmlContent = ''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenReturn('new-uuid-123'); + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenAnswer((_) async => Attachment( + blobId: Id('blob123'), + type: MediaType('image', 'png'), + disposition: ContentDisposition.inline, + )); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTag = document.querySelector('img'); + expect(imgTag?.attributes['src'], 'cid:new-uuid-123'); + expect(result.value2.length, 1); + expect(result.value2.first.cid, 'new-uuid-123'); + verify(mockFileUploader.uploadAttachment(any, any, uploadUri)).called(1); + }); + + test( + 'When upload URI is null and HTML has base64 images,\n' + 'should keep original src and not attempt uploads', + () async { + const htmlContent = ''; + final inlineAttachments = {}; + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: null, + ); + + expect(result.value1, htmlContent); + expect(result.value2, isEmpty); + verifyNever(mockFileUploader.uploadAttachment(any, any, any)); + }); + + test( + 'When processing multiple base64 images,\n' + 'should replace each with a unique CID and add corresponding attachments', + () async { + const htmlContent = ''' + + + '''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenAnswer((_) => 'uuid1'); + + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenAnswer((_) async => Attachment( + blobId: Id('blob123'), + type: MediaType('image', 'png'), + disposition: ContentDisposition.inline, + )); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTags = document.querySelectorAll('img'); + expect(imgTags.length, 2); + expect(imgTags[0].attributes['src'], 'cid:uuid1'); + expect(imgTags[1].attributes['src'], 'cid:uuid1'); + expect(result.value2.length, 2); + verify(mockFileUploader.uploadAttachment(any, any, uploadUri)).called(2); + }); + + test( + 'When one image upload throws an exception out of multiple base64 images,\n' + 'should replace successful upload with CID and keep original src for failed one', + () async { + const htmlContent = ''' + + + '''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenAnswer((_) => 'uuid1'); + + int uploadCallCount = 0; + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenAnswer((_) async { + uploadCallCount++; + if (uploadCallCount == 1) { + return Attachment( + blobId: Id('blob123'), + type: MediaType('image', 'png'), + disposition: ContentDisposition.inline, + ); + } else { + throw Exception('Upload failed for second image'); + } + }); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTags = document.querySelectorAll('img'); + expect(imgTags.length, 2); + expect(imgTags[0].attributes['src'], 'cid:uuid1'); + expect(imgTags[1].attributes['src'], 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='); + expect(result.value2.length, 1); + expect(result.value2.first.cid, 'uuid1'); + verify(mockFileUploader.uploadAttachment(any, any, uploadUri)).called(2); + }); + + test( + 'When all uploads throw exceptions for multiple base64 images,\n' + 'should keep original src for all images and add no attachments', + () async { + const htmlContent = ''' + + + '''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenAnswer((_) => 'uuid1'); + + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenThrow(Exception('Upload failed')); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTags = document.querySelectorAll('img'); + expect(imgTags.length, 2); + expect(imgTags[0].attributes['src'], 'data:image/png;base64,iVBORw0KGgo='); + expect(imgTags[1].attributes['src'], 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='); + expect(result.value2.length, 0); + verify(mockFileUploader.uploadAttachment(any, any, uploadUri)).called(2); + }); + + test( + 'When processing an invalid base64 image that causes upload to throw an exception,\n' + 'should keep the original invalid src and add no attachments', + () async { + const htmlContent = ''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenAnswer((_) => 'uuid1'); + + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenThrow(Exception('Invalid base64 data')); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTags = document.querySelectorAll('img'); + expect(imgTags.length, 1); + expect(imgTags[0].attributes['src'], 'data:image/png;base64,invalid-base64-data'); + expect(result.value2.length, 0); + }); + + test( + 'When upload throws an exception for a base64 image,\n' + 'should keep the original src and add no attachments', + () async { + const htmlContent = ''; + final inlineAttachments = {}; + final uploadUri = Uri.parse('https://example.com/upload'); + + when(mockUuid.v1()).thenAnswer((_) => 'uuid1'); + + when(mockFileUploader.uploadAttachment(any, any, any)) + .thenThrow(Exception('Upload failed')); + + final result = await htmlAnalyzer.replaceImageBase64ToImageCID( + emailContent: htmlContent, + inlineAttachments: inlineAttachments, + uploadUri: uploadUri, + ); + + final document = parse(result.value1); + final imgTags = document.querySelectorAll('img'); + expect(imgTags.length, 1); + expect(imgTags[0].attributes['src'], 'data:image/png;base64,iVBORw0KGgo='); + expect(result.value2.length, 0); + verify(mockFileUploader.uploadAttachment(any, any, any)).called(1); + }); + }); +} diff --git a/test/features/composer/data/repository/composer_repository_impl_test.dart b/test/features/composer/data/repository/composer_repository_impl_test.dart new file mode 100644 index 000000000..3a4a1e81e --- /dev/null +++ b/test/features/composer/data/repository/composer_repository_impl_test.dart @@ -0,0 +1,236 @@ +import 'package:core/utils/application_manager.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:jmap_dart_client/jmap/identities/identity.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_body_part.dart'; +import 'package:model/email/attachment.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/email_action_type.dart'; +import 'package:tmail_ui_user/features/composer/data/datasource/composer_datasource.dart'; +import 'package:tmail_ui_user/features/composer/data/repository/composer_repository_impl.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart'; +import 'package:tmail_ui_user/features/email/data/datasource/html_datasource.dart'; +import 'package:tmail_ui_user/features/upload/data/datasource/attachment_upload_datasource.dart'; +import 'package:uuid/uuid.dart'; + +import 'composer_repository_impl_test.mocks.dart'; + +@GenerateMocks([ + AttachmentUploadDataSource, + ComposerDataSource, + HtmlDataSource, + ApplicationManager, + Uuid, + CreateEmailRequest, + Session, + Identity, +]) +void main() { + group('ComposerRepositoryImpl::generateEmail::', () { + late ComposerRepositoryImpl repository; + late MockAttachmentUploadDataSource mockAttachmentUploadDataSource; + late MockComposerDataSource mockComposerDataSource; + late MockHtmlDataSource mockHtmlDataSource; + late MockApplicationManager mockApplicationManager; + late MockUuid mockUuid; + late MockCreateEmailRequest mockCreateEmailRequest; + late MockSession mockSession; + late MockIdentity mockIdentity; + + setUp(() { + mockAttachmentUploadDataSource = MockAttachmentUploadDataSource(); + mockComposerDataSource = MockComposerDataSource(); + mockHtmlDataSource = MockHtmlDataSource(); + mockApplicationManager = MockApplicationManager(); + mockUuid = MockUuid(); + mockCreateEmailRequest = MockCreateEmailRequest(); + mockSession = MockSession(); + mockIdentity = MockIdentity(); + + when(mockSession.username).thenReturn(UserName('test@example.com')); + when(mockIdentity.email).thenReturn('identity@example.com'); + when(mockIdentity.name).thenReturn('Test User'); + when(mockIdentity.replyTo).thenReturn({}); + + when(mockCreateEmailRequest.session).thenReturn(mockSession); + when(mockCreateEmailRequest.identity).thenReturn(mockIdentity); + when(mockCreateEmailRequest.replyToRecipients).thenReturn({}); + + repository = ComposerRepositoryImpl( + mockAttachmentUploadDataSource, + mockComposerDataSource, + mockHtmlDataSource, + mockApplicationManager, + mockUuid, + ); + }); + + test( + 'When the email content contains base64 images,\n' + 'generateEmail should replace them with CIDs and return an email without base64', + () async { + // Arrange + const base64Image = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=='; + const emailContentWithBase64 = '

Hello test image

'; + const emailContentWithCid = '

Hello test image

'; + const userAgent = 'TestUserAgent'; + const partIdValue = 'part-id-123'; + final inlineAttachments = { + 'unique-cid': Attachment(name: 'test.png'), + }; + final attachments = [Attachment(name: 'test.png')]; + final emailBodyParts = { + EmailBodyPart(partId: PartId(partIdValue), type: MediaType('image', 'png')), + }; + + when(mockCreateEmailRequest.emailContent).thenReturn(emailContentWithBase64); + when(mockCreateEmailRequest.inlineAttachments).thenReturn(inlineAttachments); + when(mockCreateEmailRequest.uploadUri).thenReturn(Uri.parse('https://example.com/upload')); + when(mockCreateEmailRequest.attachments).thenReturn(attachments); + when(mockCreateEmailRequest.toRecipients).thenReturn({}); + when(mockCreateEmailRequest.ccRecipients).thenReturn(null); + when(mockCreateEmailRequest.bccRecipients).thenReturn(null); + when(mockCreateEmailRequest.subject).thenReturn('Test Subject'); + when(mockCreateEmailRequest.draftsMailboxId).thenReturn(null); + when(mockCreateEmailRequest.outboxMailboxId).thenReturn(null); + when(mockCreateEmailRequest.emailActionType).thenReturn(EmailActionType.compose); + when(mockCreateEmailRequest.hasRequestReadReceipt).thenReturn(false); + + when(mockHtmlDataSource.replaceImageBase64ToImageCID( + emailContent: emailContentWithBase64, + inlineAttachments: inlineAttachments, + uploadUri: Uri.parse('https://example.com/upload'), + )).thenAnswer((_) async => Tuple2(emailContentWithCid, emailBodyParts)); + + when(mockHtmlDataSource.removeCollapsedExpandedSignatureEffect(emailContent: emailContentWithCid)) + .thenAnswer((_) async => emailContentWithCid); + + when(mockApplicationManager.generateApplicationUserAgent()) + .thenAnswer((_) async => userAgent); + + when(mockUuid.v1()).thenReturn(partIdValue); + + // Act + final result = await repository.generateEmail(mockCreateEmailRequest); + + // Assert + expect(result, isA()); + expect(result.from, {EmailAddress('Test User', 'identity@example.com')}); + expect(result.bodyValues![PartId(partIdValue)]!.value, emailContentWithCid); + expect(result.attachments, containsAll(emailBodyParts)); + expect(emailContentWithCid.contains('data:image'), isFalse); + verify(mockHtmlDataSource.replaceImageBase64ToImageCID( + emailContent: emailContentWithBase64, + inlineAttachments: inlineAttachments, + uploadUri: Uri.parse('https://example.com/upload'), + )).called(1); + }); + + test( + 'When replacing base64 with CID fails,\n' + 'generateEmail should still successfully create an email with the original content', + () async { + // Arrange + const emailContentWithBase64 = '

Hello test

'; + const userAgent = 'TestUserAgent'; + const partIdValue = 'part-id-123'; + final inlineAttachments = { + 'cid-1': Attachment(name: 'test.png'), + }; + final attachments = [Attachment(name: 'test.png')]; + + when(mockCreateEmailRequest.emailContent).thenReturn(emailContentWithBase64); + when(mockCreateEmailRequest.inlineAttachments).thenReturn(inlineAttachments); + when(mockCreateEmailRequest.uploadUri).thenReturn(Uri.parse('https://example.com/upload')); + when(mockCreateEmailRequest.attachments).thenReturn(attachments); + when(mockCreateEmailRequest.toRecipients).thenReturn({}); + when(mockCreateEmailRequest.ccRecipients).thenReturn(null); + when(mockCreateEmailRequest.bccRecipients).thenReturn(null); + when(mockCreateEmailRequest.subject).thenReturn('Test Subject'); + when(mockCreateEmailRequest.draftsMailboxId).thenReturn(null); + when(mockCreateEmailRequest.outboxMailboxId).thenReturn(null); + when(mockCreateEmailRequest.emailActionType).thenReturn(EmailActionType.compose); + when(mockCreateEmailRequest.hasRequestReadReceipt).thenReturn(false); + + when(mockHtmlDataSource.replaceImageBase64ToImageCID( + emailContent: emailContentWithBase64, + inlineAttachments: inlineAttachments, + uploadUri: Uri.parse('https://example.com/upload'), + )).thenThrow(Exception('Failed to replace base64')); + + when(mockHtmlDataSource.removeCollapsedExpandedSignatureEffect(emailContent: emailContentWithBase64)) + .thenAnswer((_) async => emailContentWithBase64); + + when(mockApplicationManager.generateApplicationUserAgent()) + .thenAnswer((_) async => userAgent); + + when(mockUuid.v1()).thenReturn(partIdValue); + + // Act + final result = await repository.generateEmail(mockCreateEmailRequest); + + // Assert + expect(result, isA()); + expect(result.bodyValues![PartId(partIdValue)]!.value, emailContentWithBase64); + expect(result.attachments, isNotEmpty); + verify(mockHtmlDataSource.replaceImageBase64ToImageCID( + emailContent: emailContentWithBase64, + inlineAttachments: inlineAttachments, + uploadUri: Uri.parse('https://example.com/upload'), + )).called(1); + }); + + test( + 'When the email content has no base64 images,\n' + 'generateEmail should process it correctly and return a valid email', + () async { + const plainEmailContent = '

Hello, this is a simple email

'; + const userAgent = 'TestUserAgent'; + const partIdValue = 'part-id-123'; + final attachments = [Attachment(name: 'test.png')]; + final emailBodyParts = { + EmailBodyPart(partId: PartId(partIdValue), type: MediaType('image', 'png')), + }; + + when(mockCreateEmailRequest.emailContent).thenReturn(plainEmailContent); + when(mockCreateEmailRequest.inlineAttachments).thenReturn({}); + when(mockCreateEmailRequest.uploadUri).thenReturn(Uri.parse('https://example.com/upload')); + when(mockCreateEmailRequest.attachments).thenReturn(attachments); + when(mockCreateEmailRequest.toRecipients).thenReturn({}); + when(mockCreateEmailRequest.ccRecipients).thenReturn(null); + when(mockCreateEmailRequest.bccRecipients).thenReturn(null); + when(mockCreateEmailRequest.subject).thenReturn('Test Subject'); + when(mockCreateEmailRequest.draftsMailboxId).thenReturn(null); + when(mockCreateEmailRequest.outboxMailboxId).thenReturn(null); + when(mockCreateEmailRequest.emailActionType).thenReturn(EmailActionType.compose); + when(mockCreateEmailRequest.hasRequestReadReceipt).thenReturn(false); + + when(mockHtmlDataSource.replaceImageBase64ToImageCID( + emailContent: plainEmailContent, + inlineAttachments: {}, + uploadUri: Uri.parse('https://example.com/upload'), + )).thenAnswer((_) async => Tuple2(plainEmailContent, emailBodyParts)); + + when(mockHtmlDataSource.removeCollapsedExpandedSignatureEffect(emailContent: plainEmailContent)) + .thenAnswer((_) async => plainEmailContent); + + when(mockApplicationManager.generateApplicationUserAgent()) + .thenAnswer((_) async => userAgent); + + when(mockUuid.v1()).thenReturn(partIdValue); + + final result = await repository.generateEmail(mockCreateEmailRequest); + + expect(result, isA()); + expect(result.bodyValues![PartId(partIdValue)]!.value, plainEmailContent); + expect(result.attachments, containsAll(emailBodyParts)); + }); + }); +} \ No newline at end of file