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 getApplicationSupportPath() async => directory; } class _ContactApi extends Api { _ContactApi(this.responses); final List responses; final List<({int opcode, Map payload, bool silent})> calls = []; Future Function(int)? onRequest; Future Function(int)? beforeDispatch; int attempts = 0; int epoch = 0; @override int get sessionEpoch => epoch; @override Future sendRequest( int opcode, Map 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 _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 _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()), ); 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(); final response = Completer(); 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(); final response = Completer(); 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); }); }