622 lines
18 KiB
Dart
622 lines
18 KiB
Dart
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<String, dynamic> _chat({
|
|
String type = 'CHANNEL',
|
|
Map<String, dynamic> admins = const {},
|
|
Map<String, bool> 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<String, dynamic> Function() reply;
|
|
final List<(int, Map<dynamic, dynamic>, bool)> requests = [];
|
|
|
|
_RecordingApi(this.reply);
|
|
|
|
@override
|
|
Future<Packet> sendRequest(
|
|
int opcode,
|
|
Map<dynamic, dynamic> 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<String, bool> 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);
|
|
});
|
|
});
|
|
}
|