import 'package:flutter_test/flutter_test.dart'; import 'package:ikar/backend/api.dart'; import 'package:ikar/backend/modules/chat_admin.dart'; import 'package:ikar/core/cache/info_cache.dart'; import 'package:ikar/core/protocol/opcode_map.dart'; import 'package:ikar/core/protocol/packet.dart'; import 'package:ikar/frontend/screens/chats/chat_admin/chat_admin_state.dart'; import 'package:ikar/models/admin_rights.dart'; import 'package:ikar/models/chat_info.dart'; import 'package:ikar/models/chat_reaction_settings.dart'; import 'package:ikar/models/member_permission.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _chatId = -1000; const _ownerId = 11; const _adminId = 22; const _followerId = 33; Map _chat({ String type = 'CHANNEL', Map admins = const {}, Map options = const {}, String link = 'https://example.test/join/synthetic', String? title, }) => { 'id': _chatId, 'type': type, 'owner': _ownerId, 'access': 'PRIVATE', 'link': link, 'title': ?title, 'participantsCount': 3, 'adminParticipants': { '$_ownerId': {'id': _ownerId, 'permissions': 4095}, ...admins, }, 'options': options, }; class _RecordingApi extends Api { final Map Function() reply; final List<(int, Map, bool)> requests = []; _RecordingApi(this.reply); @override Future sendRequest( int opcode, Map payload, { bool silent = false, void Function()? beforeSend, }) async { beforeSend?.call(); requests.add((opcode, payload, silent)); return Packet(cmd: CmdType.ok, opcode: opcode, payload: reply()); } } void main() { group('channel admin rights', () { test('read the captured appoint request', () { const rights = AdminRights(1958); expect(rights.has(AdminRight.createPosts), isTrue); expect(rights.has(AdminRight.editPosts), isTrue); expect(rights.has(AdminRight.deletePosts), isTrue); expect(rights.has(AdminRight.manageFollowers), isTrue); expect(rights.has(AdminRight.manageAdmins), isTrue); expect(rights.has(AdminRight.editInfo), isFalse); expect(rights.has(AdminRight.pinMessages), isFalse); expect(rights.has(AdminRight.viewStats), isFalse); }); test('dropping followers reproduces the captured edit request', () { final edited = const AdminRights( 1958, ).withRight(AdminRight.manageFollowers, false); expect(edited.bits, 1828); }); test('a new channel admin starts with every switch on', () { final defaults = AdminRights.appointDefaults(AdminChatKind.channel); for (final right in AdminRights.layoutOf( AdminChatKind.channel, ).expand((group) => group)) { expect(defaults.has(right), isTrue, reason: right.name); } expect(defaults.bits & (1 | 64), 0); }); }); group('group admin rights', () { test('a new group admin matches the plain captured appoint', () { expect(AdminRights.appointDefaults(AdminChatKind.group).bits, 123); }); test('granting admins on top gives the captured 127', () { final rights = AdminRights.appointDefaults( AdminChatKind.group, ).withRight(AdminRight.manageAdmins, true); expect(rights.bits, 127); }); test('dropping edit chat and then deleting reproduces 119 and 118', () { final withoutEdit = const AdminRights( 127, ).withRight(AdminRight.editInfo, false); final withoutDelete = withoutEdit.withRight( AdminRight.deleteMessages, false, ); expect(withoutEdit.bits, 119); expect(withoutDelete.bits, 118); }); test('the link switch is separate from members', () { const rights = AdminRights(127); expect(rights.has(AdminRight.manageMembers), isTrue); expect(rights.has(AdminRight.editLink), isFalse); }); test('is empty only when no group switch is on', () { expect( const AdminRights(32 | 64).isEmptyFor(AdminChatKind.group), isTrue, ); expect(const AdminRights(1).isEmptyFor(AdminChatKind.group), isFalse); }); }); group('rights toggling', () { test('keeps bits no switch controls', () { final rights = const AdminRights( 1 | 64, ).withRight(AdminRight.pinMessages, true); expect(rights.bits, 1 | 64 | 16); expect(rights.withRight(AdminRight.pinMessages, false).bits, 1 | 64); }); }); group('ChatInfo rights', () { final info = ChatInfo.fromMap( _chat( admins: { '$_adminId': {'id': _adminId, 'permissions': 1828}, }, options: {'JOIN_REQUEST': true}, ), ); test('the owner holds every right', () { expect(info.rightsOf(_ownerId).bits, AdminRights.ownerBits); expect(info.can(_ownerId, AdminRight.manageAdmins), isTrue); }); test('an admin holds what the server granted', () { expect(info.rightsOf(_adminId).bits, 1828); expect(info.can(_adminId, AdminRight.manageAdmins), isTrue); expect(info.can(_adminId, AdminRight.manageFollowers), isFalse); }); test('a follower holds nothing', () { expect(info.rightsOf(_followerId).bits, 0); expect(info.can(_followerId, AdminRight.createPosts), isFalse); }); test('reads join requests and the chat kind', () { expect(info.joinRequests, isTrue); expect(info.adminKind, AdminChatKind.channel); expect( ChatInfo.fromMap(_chat(type: 'CHAT')).adminKind, AdminChatKind.group, ); }); }); group('MemberPermission', () { test('reads inverted and direct options', () { final info = ChatInfo.fromMap( _chat( type: 'CHAT', options: { 'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true, 'ONLY_ADMIN_CAN_ADD_MEMBER': false, 'ALL_CAN_PIN_MESSAGE': true, 'MEMBERS_CAN_SEE_PRIVATE_LINK': false, 'ONLY_ADMIN_CAN_CALL': true, }, ), ); expect(MemberPermission.editInfo.allowedIn(info), isFalse); expect(MemberPermission.addMembers.allowedIn(info), isTrue); expect(MemberPermission.pinMessages.allowedIn(info), isTrue); expect(MemberPermission.inviteByLink.allowedIn(info), isFalse); expect(MemberPermission.call.allowedIn(info), isFalse); }); test('writes the captured option values', () { expect(MemberPermission.editInfo.optionFor(false), { 'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true, }); expect(MemberPermission.pinMessages.optionFor(false), { 'ALL_CAN_PIN_MESSAGE': false, }); expect(MemberPermission.call.optionFor(false), { 'ONLY_ADMIN_CAN_CALL': true, }); expect(MemberPermission.addMembers.optionFor(false), { 'ONLY_ADMIN_CAN_ADD_MEMBER': true, }); expect(MemberPermission.pinMessages.optionFor(true), { 'ALL_CAN_PIN_MESSAGE': true, }); }); }); group('ChatReactionSettings', () { const catalog = ['👍', '❤️', '⚡️', '😴']; test('treats listed reactions as forbidden by default', () { final settings = ChatReactionSettings.fromMap({ 'isActive': true, 'count': 8, 'included': false, 'reactionIds': ['😴', '⚡'], })!; expect(settings.allowedOf(catalog), {'👍', '❤️'}); expect(settings.restricted, isTrue); }); test('treats listed reactions as the only allowed when included', () { final settings = ChatReactionSettings.fromMap({ 'isActive': true, 'count': 4, 'included': true, 'reactionIds': ['👍'], })!; expect(settings.allowedOf(catalog), {'👍'}); }); test('an empty forbidden list allows everything', () { final settings = ChatReactionSettings.fromMap({ 'isActive': true, 'count': 4, 'included': false, 'reactionIds': const [], })!; expect(settings.allowedOf(catalog), catalog.toSet()); expect(settings.restricted, isFalse); }); test('forbids whatever is not allowed', () { expect(ChatReactionSettings.forbiddenOf(catalog, {'👍', '❤️'}), [ '⚡️', '😴', ]); }); }); group('ChatAdminState', () { ChatAdminState state( int myId, { String type = 'CHANNEL', int adminPermissions = 1828, Map options = const {}, }) => ChatAdminState( chatId: _chatId, myId: myId, name: 'Synthetic chat', imageUrl: '', info: ChatInfo.fromMap( _chat( type: type, options: options, admins: { '$_adminId': {'id': _adminId, 'permissions': adminPermissions}, }, ), ), ); test('lists the owner first', () { expect(state(_ownerId).adminIds, [_ownerId, _adminId]); }); test('the owner manages everyone but themselves', () { final owner = state(_ownerId); expect(owner.isOwner, isTrue); expect(owner.canEditAdmin(_adminId), isTrue); expect(owner.canEditAdmin(_ownerId), isFalse); expect(owner.canManageFollowers, isTrue); }); test('an admin follows the rights the owner granted', () { final admin = state(_adminId); expect(admin.isAdmin, isTrue); expect(admin.canManageAdmins, isTrue); expect(admin.canManageFollowers, isFalse); expect(admin.canEditAdmin(_ownerId), isFalse); expect(admin.canEditAdmin(_adminId), isFalse); }); test('only channel admins get the channel settings', () { expect(state(_ownerId).hasSettings, isTrue); expect(state(_followerId).hasSettings, isFalse); }); test('a group admin with edit rights gets the settings', () { final admin = state(_adminId, type: 'CHAT', adminPermissions: 123); expect(admin.hasSettings, isTrue); expect(admin.canManageChat, isTrue); expect(admin.canEditInfo, isTrue); }); test('a member edits the group only when members may', () { final locked = state( _followerId, type: 'CHAT', options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}, ); final open = state( _followerId, type: 'CHAT', options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': false}, ); expect(locked.hasSettings, isFalse); expect(open.hasSettings, isTrue); expect(open.canEditInfo, isTrue); expect(open.canManageChat, isFalse); }); test('keeps the opened avatar until the server changes it', () { final opened = ChatAdminState( chatId: _chatId, myId: _ownerId, name: 'Synthetic chat', imageUrl: 'https://example.test/list.jpg', info: ChatInfo.fromMap({ ..._chat(type: 'CHAT', title: 'Synthetic chat'), 'baseIconUrl': 'https://example.test/server.jpg', }), ); expect(opened.imageUrl, 'https://example.test/list.jpg'); expect(opened.name, 'Synthetic chat'); opened.adopt( ChatInfo.fromMap({ ..._chat(type: 'CHAT', title: 'Synthetic chat'), 'baseIconUrl': 'https://example.test/fresh.jpg', }), ); expect(opened.imageUrl, 'https://example.test/fresh.jpg'); }); test('takes the name from the server answer', () { final owner = state(_ownerId); var notified = 0; owner.addListener(() => notified++); owner.adopt(ChatInfo.fromMap(_chat(title: 'Synthetic renamed'))); expect(notified, 1); expect(owner.name, 'Synthetic renamed'); expect(owner.adminIds, [_ownerId]); }); }); group('ChatAdminModule', () { setUp(() => SharedPreferences.setMockInitialValues({})); tearDown(ChatInfoFetch.clear); test('appoints an admin with the chosen permissions', () async { final api = _RecordingApi( () => { 'chat': _chat( type: 'CHAT', admins: { '$_followerId': {'id': _followerId, 'permissions': 123}, }, ), }, ); final info = await ChatAdminModule( api, ).setAdmin(_chatId, _followerId, const AdminRights(123)); final (opcode, payload, silent) = api.requests.single; expect(opcode, Opcode.chatMembersUpdate); expect(payload, { 'chatId': _chatId, 'userIds': [_followerId], 'type': 'ADMIN', 'operation': 'add', 'permissions': 123, }); expect(silent, isTrue); expect(info.isAdmin(_followerId), isTrue); expect(ChatInfoFetch.peek(_chatId)?.isAdmin(_followerId), isTrue); }); test('removes an admin', () async { final api = _RecordingApi(() => {'chat': _chat()}); final info = await ChatAdminModule(api).removeAdmin(_chatId, _adminId); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatMembersUpdate); expect(payload, { 'chatId': _chatId, 'userIds': [_adminId], 'type': 'ADMIN', 'operation': 'remove', }); expect(info.isAdmin(_adminId), isFalse); }); test('removes a follower like the captured request', () async { final api = _RecordingApi(() => {'chat': _chat()}); await ChatAdminModule(api).removeMember(_chatId, _followerId); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatMembersUpdate); expect(payload, { 'chatId': _chatId, 'userIds': [_followerId], 'operation': 'remove', 'cleanMsgPeriod': 0, }); }); test('turns channel comments on through the options', () async { final api = _RecordingApi( () => { 'chat': _chat(options: {'COMMENTS': true}), }, ); final info = await ChatAdminModule( api, ).setOptions(_chatId, {'COMMENTS': true}); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, { 'chatId': _chatId, 'options': {'COMMENTS': true}, }); expect(info.commentsEnabled, isTrue); }); test('transfers ownership', () async { final api = _RecordingApi(() => {'chat': _chat()}); await ChatAdminModule(api).transferOwnership(_chatId, _adminId); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, {'chatId': _chatId, 'changeOwnerId': _adminId}); }); test('revokes the invite link and adopts the new one', () async { final api = _RecordingApi( () => {'chat': _chat(link: 'https://example.test/join/fresh')}, ); final info = await ChatAdminModule(api).revokeInviteLink(_chatId); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, {'chatId': _chatId, 'revokePrivateLink': true}); expect(info.link, 'https://example.test/join/fresh'); }); test('switches chat options', () async { final api = _RecordingApi( () => { 'chat': _chat(type: 'CHAT', options: {'ALL_CAN_PIN_MESSAGE': false}), }, ); final info = await ChatAdminModule( api, ).setOptions(_chatId, {'ALL_CAN_PIN_MESSAGE': false}); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, { 'chatId': _chatId, 'options': {'ALL_CAN_PIN_MESSAGE': false}, }); expect(MemberPermission.pinMessages.allowedIn(info), isFalse); }); test('renames the chat with its description', () async { final api = _RecordingApi( () => {'chat': _chat(type: 'CHAT', title: 'Synthetic title')}, ); final info = await ChatAdminModule(api).updateInfo( _chatId, title: 'Synthetic title', description: 'Synthetic about', ); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, { 'chatId': _chatId, 'theme': 'Synthetic title', 'description': 'Synthetic about', }); expect(info.title, 'Synthetic title'); }); test('sets the chat photo by token', () async { final api = _RecordingApi(() => {'chat': _chat(type: 'CHAT')}); await ChatAdminModule(api).setPhoto(_chatId, 'synthetic-photo-token'); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatUpdate); expect(payload, { 'chatId': _chatId, 'photoToken': 'synthetic-photo-token', }); }); test('reads reaction settings for the chat', () async { final api = _RecordingApi( () => { 'chatReactionsSettings': [ { 'chatId': _chatId, 'isActive': true, 'count': 8, 'included': false, 'reactionIds': ['😴'], }, ], }, ); final settings = await ChatAdminModule(api).reactionSettingsFor(_chatId); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.reactionsSettingsGetByChatId); expect(payload, { 'chatIds': [_chatId], }); expect(settings?.count, 8); expect(settings?.reactionIds, ['😴']); }); test('forbids reactions in the captured shape', () async { final api = _RecordingApi( () => { 'chatReactionsSettings': { 'chatId': _chatId, 'isActive': true, 'count': 8, 'included': false, 'reactionIds': ['😴', '😈'], }, }, ); final settings = await ChatAdminModule( api, ).setReactions(_chatId, count: 8, forbidden: ['😴', '😈']); final (opcode, payload, _) = api.requests.single; expect(opcode, Opcode.chatReactionsSettingsSet); expect(payload, { 'chatId': _chatId, 'value': true, 'count': 8, 'reactionIds': ['😴', '😈'], 'included': false, }); expect(settings.reactionIds, ['😴', '😈']); }); test('turns reactions off', () async { final api = _RecordingApi( () => { 'chatReactionsSettings': { 'chatId': _chatId, 'isActive': false, 'count': 4, 'included': false, 'reactionIds': const [], }, }, ); final settings = await ChatAdminModule(api).disableReactions(_chatId); final (_, payload, _) = api.requests.single; expect(payload, {'chatId': _chatId, 'value': false}); expect(settings.isActive, isFalse); }); }); }