358 lines
10 KiB
Dart
358 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ikar/backend/api.dart';
|
|
import 'package:ikar/backend/modules/contacts.dart';
|
|
import 'package:ikar/core/protocol/opcode_map.dart';
|
|
import 'package:ikar/core/protocol/packet.dart';
|
|
import 'package:ikar/core/storage/app_database.dart';
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|
|
|
class _PathProvider extends PathProviderPlatform
|
|
with MockPlatformInterfaceMixin {
|
|
_PathProvider(this.directory);
|
|
final String directory;
|
|
|
|
@override
|
|
Future<String?> getApplicationSupportPath() async => directory;
|
|
}
|
|
|
|
class _ContactApi extends Api {
|
|
_ContactApi(this.responses);
|
|
final List<Object> responses;
|
|
final List<({int opcode, Map<dynamic, dynamic> payload, bool silent})> calls =
|
|
[];
|
|
Future<void> Function(int)? onRequest;
|
|
Future<void> Function(int)? beforeDispatch;
|
|
int attempts = 0;
|
|
int epoch = 0;
|
|
|
|
@override
|
|
int get sessionEpoch => epoch;
|
|
|
|
@override
|
|
Future<Packet> sendRequest(
|
|
int opcode,
|
|
Map<dynamic, dynamic> payload, {
|
|
void Function()? beforeSend,
|
|
bool silent = false,
|
|
}) async {
|
|
await beforeDispatch?.call(++attempts);
|
|
beforeSend?.call();
|
|
calls.add((opcode: opcode, payload: Map.of(payload), silent: silent));
|
|
await onRequest?.call(calls.length);
|
|
final result = responses.removeAt(0);
|
|
if (result is Packet) return result;
|
|
throw result;
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> _contact(String first, {String last = '', int id = 11}) =>
|
|
{
|
|
'id': id,
|
|
'phone': 79000000011,
|
|
'names': [
|
|
{'type': 'CUSTOM', 'firstName': first, 'lastName': last},
|
|
],
|
|
};
|
|
|
|
Packet _success(String first, {String last = '', int id = 11}) => Packet(
|
|
cmd: CmdType.ok,
|
|
payload: {'contact': _contact(first, last: last, id: id)},
|
|
);
|
|
|
|
Future<void> _saveOtherProfile() => AppDatabase.saveProfile(
|
|
ProfileData(
|
|
id: 2,
|
|
firstName: 'Other owner',
|
|
phone: 79000000002,
|
|
country: 'RU',
|
|
accountStatus: 0,
|
|
updateTime: 1,
|
|
),
|
|
isActive: false,
|
|
);
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() async {
|
|
final directory = Directory.systemTemp.createTempSync('ikar_contacts_test');
|
|
PathProviderPlatform.instance = _PathProvider(directory.path);
|
|
addTearDown(() async {
|
|
await AppDatabase.close();
|
|
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
|
});
|
|
await AppDatabase.init();
|
|
await AppDatabase.saveProfile(
|
|
ProfileData(
|
|
id: 1,
|
|
firstName: 'Owner',
|
|
phone: 79000000001,
|
|
country: 'RU',
|
|
accountStatus: 0,
|
|
updateTime: 1,
|
|
),
|
|
);
|
|
await ContactsModule.syncFromLoginPayload({
|
|
'contacts': [_contact('Stale name')],
|
|
}, 1);
|
|
});
|
|
|
|
test('adding an ID sends ADD and persists the confirmed contact', () async {
|
|
final api = _ContactApi([_success('Alice')]);
|
|
addTearDown(api.dispose);
|
|
final contact = await ContactsModule.addContact(api, 11, 'Alice');
|
|
expect(api.calls.single.opcode, Opcode.contactUpdate);
|
|
expect(api.calls.single.payload['action'], 'ADD');
|
|
expect(api.calls.single.silent, isTrue);
|
|
expect(contact?.firstName, 'Alice');
|
|
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Alice');
|
|
});
|
|
|
|
test(
|
|
'saving a stale local contact recreates the relation with its name',
|
|
() async {
|
|
final api = _ContactApi([
|
|
const PacketError(
|
|
'Contact was not found.',
|
|
errorKey: 'contact.not.found',
|
|
),
|
|
_success('Alice', last: 'Local'),
|
|
]);
|
|
addTearDown(api.dispose);
|
|
final contact = await ContactsModule.updateContact(
|
|
api,
|
|
contactId: 11,
|
|
firstName: 'Alice',
|
|
lastName: 'Local',
|
|
);
|
|
expect(api.calls.map((c) => c.payload['action']), ['UPDATE', 'ADD']);
|
|
expect(api.calls.every((c) => c.silent), isTrue);
|
|
expect(api.calls.last.payload['contactId'], 11);
|
|
expect(api.calls.last.payload['firstName'], 'Alice');
|
|
expect(api.calls.last.payload['lastName'], 'Local');
|
|
expect(contact?.lastName, 'Local');
|
|
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Alice');
|
|
},
|
|
);
|
|
|
|
for (final errorKey in ['user.not.found', 'CONTACT_NAME_INVALID', 'limit']) {
|
|
test('saving does not retry an unrelated $errorKey failure', () async {
|
|
final api = _ContactApi([PacketError('failed', errorKey: errorKey)]);
|
|
addTearDown(api.dispose);
|
|
expect(
|
|
await ContactsModule.updateContact(
|
|
api,
|
|
contactId: 11,
|
|
firstName: 'Alice',
|
|
lastName: '',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(api.calls, hasLength(1));
|
|
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
|
});
|
|
}
|
|
|
|
test(
|
|
'account switching prevents restoring a contact to another account',
|
|
() async {
|
|
await AppDatabase.saveProfile(
|
|
ProfileData(
|
|
id: 2,
|
|
firstName: 'Other owner',
|
|
phone: 79000000002,
|
|
country: 'RU',
|
|
accountStatus: 0,
|
|
updateTime: 1,
|
|
),
|
|
isActive: false,
|
|
);
|
|
final api = _ContactApi([
|
|
const PacketError(
|
|
'Contact was not found.',
|
|
errorKey: 'contact.not.found',
|
|
),
|
|
])..onRequest = (_) => AppDatabase.setActiveAccount(2);
|
|
addTearDown(api.dispose);
|
|
expect(
|
|
await ContactsModule.updateContact(
|
|
api,
|
|
contactId: 11,
|
|
firstName: 'Alice',
|
|
lastName: '',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(api.calls, hasLength(1));
|
|
expect(await ContactsModule.getContact(2, 11), isNull);
|
|
},
|
|
);
|
|
|
|
test('a rejected add does not create a fake saved contact', () async {
|
|
final api = _ContactApi([Packet(cmd: CmdType.error)]);
|
|
addTearDown(api.dispose);
|
|
expect(await ContactsModule.addContact(api, 12, 'Absent'), isNull);
|
|
expect(await ContactsModule.getContact(1, 12), isNull);
|
|
});
|
|
|
|
test('switching sessions while UPDATE waits prevents dispatch', () async {
|
|
final api = _ContactApi([_success('Alice')]);
|
|
api.beforeDispatch = (_) async => api.epoch++;
|
|
addTearDown(api.dispose);
|
|
expect(
|
|
await ContactsModule.updateContact(
|
|
api,
|
|
contactId: 11,
|
|
firstName: 'Alice',
|
|
lastName: '',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(api.calls, isEmpty);
|
|
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
|
});
|
|
|
|
test(
|
|
'switching sessions while the ADD retry waits prevents dispatch',
|
|
() async {
|
|
final api = _ContactApi([
|
|
const PacketError(
|
|
'Contact was not found.',
|
|
errorKey: 'contact.not.found',
|
|
),
|
|
_success('Alice'),
|
|
]);
|
|
api.beforeDispatch = (attempt) async {
|
|
if (attempt == 2) api.epoch++;
|
|
};
|
|
addTearDown(api.dispose);
|
|
expect(
|
|
await ContactsModule.updateContact(
|
|
api,
|
|
contactId: 11,
|
|
firstName: 'Alice',
|
|
lastName: '',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(api.attempts, 2);
|
|
expect(api.calls.map((call) => call.payload['action']), ['UPDATE']);
|
|
expect((await ContactsModule.getContact(1, 11))?.firstName, 'Stale name');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'full sync for an inactive account does not request or delete contacts',
|
|
() async {
|
|
await _saveOtherProfile();
|
|
final api = _ContactApi([]);
|
|
addTearDown(api.dispose);
|
|
await ContactsModule.syncFromServer(api, 2);
|
|
expect(api.calls, isEmpty);
|
|
expect(
|
|
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
|
[11],
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'switching sessions before a full sync dispatch cancels the request',
|
|
() async {
|
|
final api = _ContactApi([
|
|
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
|
]);
|
|
api.beforeDispatch = (_) async => api.epoch++;
|
|
addTearDown(api.dispose);
|
|
await expectLater(
|
|
ContactsModule.syncFromServer(api, 1),
|
|
throwsA(isA<StateError>()),
|
|
);
|
|
expect(api.calls, isEmpty);
|
|
expect(
|
|
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
|
[11],
|
|
);
|
|
},
|
|
);
|
|
|
|
for (final changeAccount in [true, false]) {
|
|
test(
|
|
'an in-flight full sync is ignored after ${changeAccount ? 'account' : 'session'} switching',
|
|
() async {
|
|
await _saveOtherProfile();
|
|
final requested = Completer<void>();
|
|
final response = Completer<void>();
|
|
final api =
|
|
_ContactApi([
|
|
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
|
])
|
|
..onRequest = (_) {
|
|
requested.complete();
|
|
return response.future;
|
|
};
|
|
addTearDown(api.dispose);
|
|
final revision = ContactsModule.revision.value;
|
|
final sync = ContactsModule.syncFromServer(api, 1);
|
|
await requested.future;
|
|
if (changeAccount) {
|
|
await AppDatabase.setActiveAccount(2);
|
|
} else {
|
|
api.epoch++;
|
|
}
|
|
response.complete();
|
|
await sync;
|
|
expect(
|
|
(await ContactsModule.getContacts(1)).map((contact) => contact.id),
|
|
[11],
|
|
);
|
|
expect(ContactsModule.revision.value, revision);
|
|
},
|
|
);
|
|
}
|
|
|
|
test(
|
|
'an older full snapshot does not erase a contact added during sync',
|
|
() async {
|
|
final requested = Completer<void>();
|
|
final response = Completer<void>();
|
|
final api =
|
|
_ContactApi([
|
|
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
|
])
|
|
..onRequest = (_) {
|
|
requested.complete();
|
|
return response.future;
|
|
};
|
|
final addingApi = _ContactApi([_success('New contact', id: 12)]);
|
|
addTearDown(api.dispose);
|
|
addTearDown(addingApi.dispose);
|
|
final sync = ContactsModule.syncFromServer(api, 1);
|
|
await requested.future;
|
|
await ContactsModule.addContact(addingApi, 12, 'New contact');
|
|
response.complete();
|
|
await sync;
|
|
expect(
|
|
(await ContactsModule.getContacts(
|
|
1,
|
|
)).map((contact) => contact.id).toSet(),
|
|
{11, 12},
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a current full snapshot still removes stale contacts', () async {
|
|
final api = _ContactApi([
|
|
Packet(cmd: CmdType.ok, payload: {'contacts': []}),
|
|
]);
|
|
addTearDown(api.dispose);
|
|
await ContactsModule.syncFromServer(api, 1);
|
|
expect(api.calls.single.opcode, Opcode.contactsGet);
|
|
expect(await ContactsModule.getContacts(1), isEmpty);
|
|
});
|
|
}
|