Files
IKAR/test/contact_mutations_test.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);
});
}