TF-3719 Write unit test for _updateEmailCache in ThreadRepositoryImplement and getChanges in ThreadAPI

Signed-off-by: dab246 <tdvu@linagora.com>
This commit is contained in:
dab246
2025-05-15 14:56:04 +07:00
committed by Dat H. Pham
parent 2b88f07f6d
commit 21f4b4bfa0
2 changed files with 829 additions and 0 deletions
@@ -6,14 +6,19 @@ import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:jmap_dart_client/jmap/core/error/method/error_method_response.dart';
import 'package:jmap_dart_client/jmap/core/filter/filter.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/core/state.dart';
import 'package:jmap_dart_client/jmap/core/unsigned_int.dart';
import 'package:jmap_dart_client/jmap/mail/email/email.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart';
import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart';
import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
import 'package:jmap_dart_client/jmap/mail/email/query/query_email_response.dart';
import 'package:jmap_dart_client/jmap/mail/email/search_snippet/search_snippet.dart';
import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart';
import 'package:model/extensions/account_id_extensions.dart';
import 'package:model/extensions/email_id_extensions.dart';
import 'package:tmail_ui_user/features/thread/data/model/email_change_response.dart';
import 'package:tmail_ui_user/features/thread/data/network/thread_api.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_email.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_emails_response.dart';
@@ -49,12 +54,17 @@ class MockQueryEmailResponse extends QueryEmailResponse {
void main() {
final baseOption = BaseOptions(method: 'POST');
final dio = Dio(baseOption)..options.baseUrl = 'http://domain.com/jmap';
final dioAdapterHeaders = <String, dynamic>{
'accept': 'application/json;jmapVersion=rfc-8621',
};
final dioAdapter = DioAdapter(dio: dio);
final httpClient = HttpClient(dio);
final threadApi = ThreadAPI(httpClient);
final sessionState = State('some-session-state');
final state = State('some-state');
final sinceState = State('since-state');
final newState = State('new-state');
final filter = EmailFilterCondition(text: 'some-text');
final queryState = State('some-query-state');
@@ -423,5 +433,347 @@ void main() {
);
});
});
group('getChanges:', () {
int emailSize = 10;
final createdEmailIds = List.generate(
emailSize,
(index) => EmailId(Id('created_$index')),
);
final updatedEmailIds = List.generate(
emailSize,
(index) => EmailId(Id('updated_$index')),
);
final destroyedEmailIds = List.generate(
emailSize,
(index) => EmailId(Id('destroyed_$index')),
);
final createdEmails = List.generate(
emailSize,
(index) => Email(
id: EmailId(Id('created_$index')),
subject: 'Subject $index',
preview: 'Preview $index',
),
);
final updatedEmails = List.generate(
emailSize,
(index) => Email(
id: EmailId(Id('updated_$index')),
keywords: {
KeyWordIdentifier.emailSeen: true,
},
mailboxIds: {
MailboxId(Id('inbox-id')): true,
},
),
);
final defaultCreatedProperties = Properties({
'subject',
'preview',
});
final defaultUpdatedProperties = Properties({
'mailboxIds',
'keywords',
});
Map<String, dynamic> generateRequest({
Properties? propertiesCreated,
Properties? propertiesUpdated,
}) =>
{
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:apache:james:params:jmap:mail:shares"
],
"methodCalls": [
[
"Email/changes",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"sinceState": sinceState.value,
},
"c0"
],
if (propertiesUpdated != null)
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"#ids": {
"resultOf": "c0",
"name": "Email/changes",
"path": "/updated/*"
},
"properties": propertiesUpdated.value.toList()
},
"c1"
],
if (propertiesCreated != null)
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"#ids": {
"resultOf": "c0",
"name": "Email/changes",
"path": "/created/*"
},
"properties": propertiesCreated.value.toList()
},
if (propertiesUpdated != null) "c2" else "c1"
]
],
};
Map<String, dynamic> generateResponse({
Properties? propertiesCreated,
Properties? propertiesUpdated,
bool existNotFoundCreatedEmails = false,
bool existNotFoundUpdatedEmails = false,
}) =>
{
"sessionState": sessionState.value,
"methodResponses": [
[
"Email/changes",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"oldState": sinceState.value,
"newState": newState.value,
"hasMoreChanges": false,
"created": createdEmailIds
.map((emailId) => emailId.id.value)
.toList(),
"updated": updatedEmailIds
.map((emailId) => emailId.id.value)
.toList(),
"destroyed": destroyedEmailIds
.map((emailId) => emailId.id.value)
.toList(),
},
"c0"
],
if (propertiesUpdated != null)
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"state": newState.value,
"list": existNotFoundUpdatedEmails
? updatedEmails.map((email) => email.toJson()).toList().sublist(0, 5)
: updatedEmails.map((email) => email.toJson()).toList(),
"notFound": existNotFoundUpdatedEmails
? updatedEmailIds.map((emailId) => emailId.id.value).toList().sublist(5, 10)
: [],
},
"c1"
],
if (propertiesCreated != null)
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.asString,
"state": newState.value,
"list": existNotFoundUpdatedEmails
? createdEmails.map((email) => email.toJson()).toList().sublist(0, 5)
: createdEmails.map((email) => email.toJson()).toList(),
"notFound": existNotFoundUpdatedEmails
? createdEmailIds.map((emailId) => emailId.id.value).toList().sublist(5, 10)
: [],
},
if (propertiesUpdated != null) "c2" else "c1"
]
]
};
test(
'should return created, updated, destroyed emails '
'when has both created and updated properties '
'and response Email/get has list', () async {
// arrange
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
),
),
data: generateRequest(
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
),
headers: dioAdapterHeaders..addAll({'content-length': 1245}),
);
// act
final result = await threadApi.getChanges(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
sinceState,
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
);
// assert
expect(
result,
equals(
EmailChangeResponse(
updated: updatedEmails,
created: createdEmails,
destroyed: destroyedEmailIds,
newStateChanges: newState,
newStateEmail: newState,
hasMoreChanges: false,
updatedProperties: defaultUpdatedProperties,
),
),
);
});
test(
'should return created, updated, destroyed emails '
'when has both created and updated properties '
'and response Email/get has list and not found emails', () async {
// arrange
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
existNotFoundCreatedEmails: true,
existNotFoundUpdatedEmails: true,
),
),
data: generateRequest(
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
),
headers: dioAdapterHeaders..addAll({'content-length': 1245}),
);
// act
final result = await threadApi.getChanges(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
sinceState,
propertiesCreated: defaultCreatedProperties,
propertiesUpdated: defaultUpdatedProperties,
);
// assert
final newDestroyedEmailIds = destroyedEmailIds
+ updatedEmailIds.sublist(5, 10)
+ createdEmailIds.sublist(5, 10);
final newUpdatedEmails = updatedEmails.sublist(0, 5);
final newCreatedEmails = createdEmails.sublist(0, 5);
expect(
result,
equals(
EmailChangeResponse(
updated: newUpdatedEmails,
created: newCreatedEmails,
destroyed: newDestroyedEmailIds,
newStateChanges: newState,
newStateEmail: newState,
hasMoreChanges: false,
updatedProperties: defaultUpdatedProperties,
),
),
);
});
test(
'should return created, destroyed emails '
'when only has created properties '
'and response Email/get has list', () async {
// arrange
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
propertiesCreated: defaultCreatedProperties,
),
),
data: generateRequest(
propertiesCreated: defaultCreatedProperties,
),
headers: dioAdapterHeaders..addAll({'content-length': 798}),
);
// act
final result = await threadApi.getChanges(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
sinceState,
propertiesCreated: defaultCreatedProperties,
);
// assert
expect(
result,
equals(
EmailChangeResponse(
created: createdEmails,
destroyed: destroyedEmailIds,
newStateChanges: newState,
newStateEmail: newState,
hasMoreChanges: false,
),
),
);
});
test(
'should only return destroyed emails '
'when both created, updated properties are absent '
'and response Email/changes has destroyed list', () async {
// arrange
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(),
),
data: generateRequest(),
headers: dioAdapterHeaders..addAll({'content-length': 355}),
);
// act
final result = await threadApi.getChanges(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
sinceState,
);
// assert
expect(
result,
equals(
EmailChangeResponse(
destroyed: destroyedEmailIds,
newStateChanges: newState,
hasMoreChanges: false,
),
),
);
});
});
});
}
@@ -14,6 +14,7 @@ import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.
import 'package:tmail_ui_user/features/thread/domain/model/email_filter.dart';
import 'package:tmail_ui_user/features/thread/domain/model/email_response.dart';
import 'package:tmail_ui_user/features/thread/domain/model/filter_message_option.dart';
import 'package:tmail_ui_user/features/thread/domain/model/get_email_request.dart';
import '../../../../fixtures/account_fixtures.dart';
import '../../../../fixtures/session_fixtures.dart';
@@ -531,4 +532,480 @@ void main() {
));
});
});
group('The functions regard to updateEmailCache:', () {
test(
'when local cache is empty should fetch from network '
'and network has mail not found '
'should delete mail not found in cache', () async {
// Arrange
when(threadDataSource.getAllEmailCache(
any,
any,
filterOption: anyNamed('filterOption'),
inMailboxId: anyNamed('inMailboxId'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
)).thenAnswer((_) => Future.value([]));
when(stateDataSource.getState(any, any, any))
.thenAnswer((_) => Future.value(null));
final networkEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('network_$index'))),
);
final networkNotFoundEmailIds = List.generate(
5,
(index) => EmailId(Id('network_not_found_$index')),
);
when(threadDataSource.getAllEmail(
any,
any,
position: anyNamed('position'),
filter: anyNamed('filter'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
properties: anyNamed('properties'),
)).thenAnswer((_) => Future.value(EmailsResponse(
emailList: networkEmails,
notFoundEmailIds: networkNotFoundEmailIds,
state: State('network_state'),
)));
// Act
final responses = await threadRepository
.getAllEmail(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
)
.toList();
// Assert
expect(responses.length, 2);
expect(responses[0].emailList, networkEmails);
expect(responses[0].notFoundEmailIds, networkNotFoundEmailIds);
verify(threadDataSource.update(
any,
any,
created: networkEmails,
destroyed: networkNotFoundEmailIds,
));
verify(stateDataSource.saveState(
any,
any,
any,
));
});
test(
'when filter option is not "all" '
'and no need getLatestChanges '
'then fetch first page of all messages for this folder '
'and network has mail not found '
'should delete mail not found in cache', () async {
// Arrange
final mailboxId = MailboxId(Id('mailbox_id'));
final localEmails = List.generate(
10,
(index) => Email(id: EmailId(Id('local_$index'))),
);
when(threadDataSource.getAllEmailCache(
any,
any,
filterOption: anyNamed('filterOption'),
inMailboxId: anyNamed('inMailboxId'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
)).thenAnswer((_) => Future.value(localEmails));
when(stateDataSource.getState(any, any, any))
.thenAnswer((_) => Future.value(State('local_state')));
final networkEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('network_$index'))),
);
final networkNotFoundEmailIds = List.generate(
5,
(index) => EmailId(Id('network_not_found_$index')),
);
when(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
position: anyNamed('position'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
)).thenAnswer((_) => Future.value(EmailsResponse(
emailList: networkEmails,
notFoundEmailIds: networkNotFoundEmailIds,
state: State('network_state'),
)));
// Act
final responses = await threadRepository
.getAllEmail(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
emailFilter: EmailFilter(
filterOption: FilterMessageOption.unread,
mailboxId: mailboxId,
),
getLatestChanges: false,
)
.toList();
// Assert
expect(responses.length, 2);
verify(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
filter: anyNamed('filter'),
)).called(2); // Once for initial fetch, once for first page
verify(threadDataSource.update(
any,
any,
created: networkEmails,
destroyed: networkNotFoundEmailIds,
)).called(2);
verifyNever(stateDataSource.saveState(
any,
any,
any,
));
});
test(
'when local has mail in cache and getLatestChanges is true '
'and email change has destroyed mail '
'should delete destroyed mail in cache', () async {
// Arrange
final localEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('local_$index'))),
);
when(threadDataSource.getAllEmailCache(
any,
any,
filterOption: anyNamed('filterOption'),
inMailboxId: anyNamed('inMailboxId'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
)).thenAnswer((_) => Future.value(localEmails));
when(stateDataSource.getState(any, any, any))
.thenAnswer((_) => Future.value(State('local_state')));
final changedEmails = List.generate(
5,
(index) => Email(id: EmailId(Id('changed_$index'))),
);
final destroyedEmailIds = List.generate(
5,
(index) => EmailId(Id('destroyed_mail_$index')),
);
when(threadDataSource.getChanges(
any,
any,
any,
propertiesCreated: anyNamed('propertiesCreated'),
propertiesUpdated: anyNamed('propertiesUpdated'),
)).thenAnswer((_) => Future.value(EmailChangeResponse(
hasMoreChanges: false,
created: changedEmails,
destroyed: destroyedEmailIds,
newStateEmail: State('new_state'))));
// Act
final responses = await threadRepository
.getAllEmail(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
getLatestChanges: true,
)
.toList();
// Assert
expect(responses.length, 2);
verifyNever(threadDataSource.getAllEmail(any, any));
verify(threadDataSource.getChanges(
any,
any,
any,
propertiesCreated: anyNamed('propertiesCreated'),
propertiesUpdated: anyNamed('propertiesUpdated'),
));
verify(threadDataSource.update(
any,
any,
created: changedEmails,
destroyed: destroyedEmailIds,
));
verify(stateDataSource.saveState(any, any, any));
});
test(
'when getChanges has more changes should fetch all changes '
'and first email change has destroyed mail '
'should delete destroyed mail in cache', () async {
// Arrange
final localEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('local_$index'))),
);
when(threadDataSource.getAllEmailCache(
any,
any,
filterOption: anyNamed('filterOption'),
inMailboxId: anyNamed('inMailboxId'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
)).thenAnswer((_) => Future.value(localEmails));
when(stateDataSource.getState(any, any, any))
.thenAnswer((_) => Future.value(State('local_state')));
final firstChanges = List.generate(
5,
(index) => Email(id: EmailId(Id('change1_$index'))),
);
final secondChanges = List.generate(
5,
(index) => Email(id: EmailId(Id('change2_$index'))),
);
var callCount = 0;
final firstDestroyedEmailIds = List.generate(
5,
(index) => EmailId(Id('destroyed_mail_$index')),
);
when(threadDataSource.getChanges(
any,
any,
any,
propertiesCreated: anyNamed('propertiesCreated'),
propertiesUpdated: anyNamed('propertiesUpdated'),
)).thenAnswer((_) {
callCount++;
if (callCount == 1) {
return Future.value(EmailChangeResponse(
hasMoreChanges: true,
created: firstChanges,
destroyed: firstDestroyedEmailIds,
newStateChanges: State('intermediate_state'),
newStateEmail: State('intermediate_state_email'),
));
} else {
return Future.value(EmailChangeResponse(
hasMoreChanges: false,
created: secondChanges,
newStateChanges: State('final_state'),
newStateEmail: State('final_state_email'),
));
}
});
// Act
final responses = await threadRepository
.getAllEmail(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
getLatestChanges: true,
)
.toList();
// Assert
expect(responses.length, 2);
verify(threadDataSource.getChanges(
any,
any,
any,
propertiesCreated: anyNamed('propertiesCreated'),
propertiesUpdated: anyNamed('propertiesUpdated'),
)).called(2);
verify(threadDataSource.update(
any,
any,
created: anyNamed('created'),
destroyed: anyNamed('destroyed'),
));
verify(stateDataSource.saveState(any, any, any));
});
test(
'when local has mail in cache but network errors '
'and return email from local '
'should not call update email cache', () async {
// Arrange
final localEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('local_$index'))),
);
when(threadDataSource.getAllEmailCache(
any,
any,
filterOption: anyNamed('filterOption'),
inMailboxId: anyNamed('inMailboxId'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
)).thenAnswer((_) => Future.value(localEmails));
when(stateDataSource.getState(any, any, any))
.thenAnswer((_) => Future.value(State('local_state')));
when(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
position: anyNamed('position'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
)).thenThrow(Exception('Network error'));
// Act
final responses = await threadRepository
.getAllEmail(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
getLatestChanges: false,
)
.toList();
// Assert
expect(responses.length, 2);
expect(responses[0].emailList, localEmails);
verifyNever(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
position: anyNamed('position'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
));
verifyNever(threadDataSource.update(
any,
any,
created: anyNamed('created'),
updated: anyNamed('updated'),
destroyed: anyNamed('created'),
));
verifyNever(stateDataSource.saveState(
any,
any,
any,
));
});
test(
'when fetching more emails '
'and network has mails and not found mails '
'should delete mail not found in cache', () async {
// Arrange
final networkEmails = List.generate(
ThreadConstants.defaultLimit.value.toInt(),
(index) => Email(id: EmailId(Id('network_$index'))),
);
final networkNotFoundEmailIds = List.generate(
5,
(index) => EmailId(Id('network_not_found_$index')),
);
when(threadDataSource.getAllEmail(
any,
any,
position: anyNamed('position'),
filter: anyNamed('filter'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
properties: anyNamed('properties'),
)).thenAnswer((_) => Future.value(EmailsResponse(
emailList: networkEmails,
notFoundEmailIds: networkNotFoundEmailIds,
state: State('network_state'),
)));
// Act
final responses = await threadRepository
.loadMoreEmails(GetEmailRequest(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
))
.toList();
// Assert
expect(responses.length, 1);
expect(responses[0].emailList, networkEmails);
expect(responses[0].notFoundEmailIds, networkNotFoundEmailIds);
verify(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
position: anyNamed('position'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
));
verify(threadDataSource.update(
any,
any,
created: networkEmails,
destroyed: networkNotFoundEmailIds,
));
});
test(
'when fetching more emails and network errors '
'should not call update mail cache', () async {
// Arrange
when(threadDataSource.getAllEmail(
any,
any,
position: anyNamed('position'),
filter: anyNamed('filter'),
limit: anyNamed('limit'),
sort: anyNamed('sort'),
properties: anyNamed('properties'),
)).thenThrow(Exception('Network error'));
// Assert
expectLater(
() => threadRepository
.loadMoreEmails(GetEmailRequest(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
))
.toList(),
throwsA(isA<Exception>().having((e) => e.toString(), 'description', contains('Network error'))),
);
verifyNever(threadDataSource.getAllEmail(
any,
any,
limit: anyNamed('limit'),
position: anyNamed('position'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
));
verifyNever(threadDataSource.update(
any,
any,
created: anyNamed('created'),
updated: anyNamed('updated'),
destroyed: anyNamed('created'),
));
});
});
}