// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Combine import GRDB import Quick import Nimble import SessionUIKit import SessionSnodeKit import SessionUtilitiesKit @testable import SessionUIKit @testable import SessionMessagingKit @testable import Session class ThreadSettingsViewModelSpec: QuickSpec { private typealias Item = SessionCell.Info override class func spec() { // MARK: Configuration @TestState var userPubkey: String! = "05\(TestConstants.publicKey)" @TestState var user2Pubkey: String! = "05\(TestConstants.publicKey.replacingOccurrences(of: "8", with: "7"))" @TestState var legacyGroupPubkey: String! = "05\(TestConstants.publicKey.replacingOccurrences(of: "8", with: "6"))" @TestState var groupPubkey: String! = "03\(TestConstants.publicKey.replacingOccurrences(of: "8", with: "5"))" @TestState var communityId: String! = "testserver.testRoom" @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in dependencies[singleton: .scheduler] = .immediate } @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), migrationTargets: [ SNUtilitiesKit.self, SNSnodeKit.self, SNMessagingKit.self, DeprecatedUIKitMigrationTarget.self ], using: dependencies, initialData: { db in try Identity( variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey) ).insert(db) try Profile(id: userPubkey, name: "TestMe").insert(db) try Profile(id: user2Pubkey, name: "TestUser").insert(db) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) } ) @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( initialSetup: { jobRunner in jobRunner .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } .thenReturn(nil) jobRunner .when { $0.upsert(.any, job: .any, canStartJob: .any) } .thenReturn(nil) jobRunner .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } .thenReturn([:]) } ) @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( initialSetup: { cache in cache .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } .thenReturn(()) } ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in crypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) } ) @TestState var timestampMs: Int64! = 1234567890000 @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( initialSetup: { cache in cache.when { $0.clockOffsetMs }.thenReturn(0) cache .when { $0.currentOffsetTimestampMs() } .thenReturn { _, _ in /// **Note:** We need to increment this value every time it's accessed because otherwise any functions which /// insert multiple `Interaction` values can end up running into unique constraint conflicts due to the timestamp /// being identical between different interactions timestampMs += 1 return timestampMs } } ) @TestState var threadVariant: SessionThread.Variant! = .contact @TestState var didTriggerSearchCallbackTriggered: Bool! = false @TestState var viewModel: ThreadSettingsViewModel! @TestState var disposables: [AnyCancellable]! = [] @TestState var screenTransitions: [(destination: UIViewController, transition: TransitionType)]! = [] func item(section: ThreadSettingsViewModel.Section, id: ThreadSettingsViewModel.TableItem) -> Item? { return viewModel.tableData .first(where: { (sectionModel: ThreadSettingsViewModel.SectionModel) -> Bool in sectionModel.model == section })? .elements .first(where: { (item: SessionCell.Info) -> Bool in item.id == id }) } func setupTestSubscriptions() { viewModel.tableDataPublisher .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { viewModel.updateTableData($0) } ) .store(in: &disposables) viewModel.navigatableState.transitionToScreen .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { screenTransitions.append($0) } ) .store(in: &disposables) } // MARK: - a ThreadSettingsViewModel describe("a ThreadSettingsViewModel") { beforeEach { mockStorage.write { db in try SessionThread( id: user2Pubkey, variant: .contact, creationDateTimestamp: 0, using: dependencies ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: user2Pubkey, threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: -- with any conversation type context("with any conversation type") { // MARK: ---- triggers the search callback when tapping search it("triggers the search callback when tapping search") { item(section: .content, id: .searchConversation)?.onTap?() expect(didTriggerSearchCallbackTriggered).to(beTrue()) } // MARK: ---- mutes a conversation it("mutes a conversation") { item(section: .content, id: .notificationMute)?.onTap?() expect( mockStorage .read { db in try SessionThread.fetchOne(db, id: user2Pubkey) }? .mutedUntilTimestamp ) .toNot(beNil()) } // MARK: ---- unmutes a conversation it("unmutes a conversation") { mockStorage.write { db in try SessionThread .updateAll( db, SessionThread.Columns.mutedUntilTimestamp.set(to: 1234567890) ) } expect( mockStorage .read { db in try SessionThread.fetchOne(db, id: user2Pubkey) }? .mutedUntilTimestamp ) .toNot(beNil()) item(section: .content, id: .notificationMute)?.onTap?() expect( mockStorage .read { db in try SessionThread.fetchOne(db, id: user2Pubkey) }? .mutedUntilTimestamp ) .to(beNil()) } } // MARK: -- with a note-to-self conversation context("with a note-to-self conversation") { beforeEach { mockStorage.write { db in try SessionThread( id: userPubkey, variant: .contact, creationDateTimestamp: 0, using: dependencies ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: userPubkey, threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name it("has the correct display name") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.title?.text).to(equal("noteToSelf".localized())) } // MARK: ---- has no edit icon it("has no edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).to(beNil()) } // MARK: ---- does nothing when tapped it("does nothing when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions).to(beEmpty()) } // MARK: ---- has no mute button it("has no mute button") { expect(item(section: .content, id: .notificationMute)).to(beNil()) } } // MARK: -- with a one-to-one conversation context("with a one-to-one conversation") { beforeEach { mockStorage.write { db in try SessionThread( id: user2Pubkey, variant: .contact, creationDateTimestamp: 0, using: dependencies ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: user2Pubkey, threadVariant: .contact, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name it("has the correct display name") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.title?.text).to(equal("TestUser")) } // MARK: ---- has an edit icon it("has an edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).toNot(beNil()) } // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions.first?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) } // MARK: ---- when updating the nickname context("when updating the nickname") { @TestState var onChange: ((String) -> ())? @TestState var modal: ConfirmationModal? beforeEach { item(section: .conversationInfo, id: .displayName)?.onTap?() modal = (screenTransitions.first?.destination as? ConfirmationModal) switch modal?.info.body { case .input(_, _, let onChange_): onChange = onChange_ default: break } } // MARK: ---- has the correct content it("has the correct content") { expect(modal?.info.title).to(equal("nicknameSet".localized())) expect(modal?.info.body).to(equal( .input( explanation: "nicknameDescription" .put(key: "name", value: "TestUser") .localizedFormatted(baseFont: ConfirmationModal.explanationFont), info: ConfirmationModal.Info.Body.InputInfo( placeholder: "nicknameEnter".localized(), initialValue: nil ), onChange: { _ in } ) )) expect(modal?.info.confirmTitle).to(equal("save".localized())) expect(modal?.info.cancelTitle).to(equal("Reset")) } // MARK: ---- does nothing if the name contains only white space it("does nothing if the name contains only white space") { onChange?(" ") modal?.confirmationPressed() expect(screenTransitions.count).to(equal(1)) } // MARK: ---- shows an error modal when the updated nickname is too long it("shows an error modal when the updated nickname is too long") { onChange?([String](Array(repeating: "1", count: 101)).joined()) modal?.confirmationPressed() expect(screenTransitions.count).to(equal(2)) expect(screenTransitions.last?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.last?.transition).to(equal(TransitionType.present)) let modal2: ConfirmationModal? = (screenTransitions.last?.destination as? ConfirmationModal) expect(modal2?.info.title).to(equal("theError".localized())) expect(modal2?.info.body).to(equal(.text("displayNameErrorDescriptionShorter".localized()))) expect(modal2?.info.confirmTitle).to(beNil()) expect(modal2?.info.cancelTitle).to(equal("okay".localized())) } // MARK: ---- updates the contacts nickname when valid it("updates the contacts nickname when valid") { onChange?("TestNickname") modal?.confirmationPressed() let profiles: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } expect(profiles?.map { $0.nickname }.asSet()).to(equal([nil, "TestNickname"])) } // MARK: ---- removes the nickname when cancel is pressed it("removes the nickname when cancel is pressed") { mockStorage.write { db in try Profile .filter(id: "TestId") .updateAll(db, Profile.Columns.nickname.set(to: "TestOldNickname")) } modal?.cancel() let profiles: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } expect(profiles?.map { $0.nickname }.asSet()).to(equal([nil, nil])) } } } // MARK: -- with a legacy group conversation context("with a legacy group conversation") { beforeEach { mockStorage.write { db in try SessionThread( id: legacyGroupPubkey, variant: .legacyGroup, creationDateTimestamp: 0, using: dependencies ).insert(db) try DisappearingMessagesConfiguration .defaultWith(legacyGroupPubkey) .insert(db) try ClosedGroup( threadId: legacyGroupPubkey, name: "TestGroup", groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, lastDisplayPictureUpdate: nil, shouldPoll: false, groupIdentityPrivateKey: nil, authData: nil, invited: nil ).insert(db) try ClosedGroupKeyPair( threadId: legacyGroupPubkey, publicKey: Data([1, 2, 3]), secretKey: Data([3, 2, 1]), receivedTimestamp: 1234567890 ).insert(db) try GroupMember( groupId: legacyGroupPubkey, profileId: userPubkey, role: .standard, roleStatus: .accepted, isHidden: false ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: legacyGroupPubkey, threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name it("has the correct display name") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.title?.text).to(equal("TestGroup")) } // MARK: ---- when the user is a standard member context("when the user is a standard member") { // MARK: ---- has no edit icon it("has no edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).to(beNil()) } // MARK: ---- does nothing when tapped it("does nothing when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions).to(beEmpty()) } } // MARK: ---- when the user is an admin context("when the user is an admin") { beforeEach { mockStorage.write { db in try GroupMember.deleteAll(db) try GroupMember( groupId: legacyGroupPubkey, profileId: userPubkey, role: .admin, roleStatus: .accepted, isHidden: false ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: legacyGroupPubkey, threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has an edit icon it("has an edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).toNot(beNil()) } // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions.first?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) } // MARK: ---- when updating the group name context("when updating the group name") { @TestState var onChange: ((String) -> ())? @TestState var modal: ConfirmationModal? beforeEach { item(section: .conversationInfo, id: .displayName)?.onTap?() modal = (screenTransitions.first?.destination as? ConfirmationModal) switch modal?.info.body { case .input(_, _, let onChange_): onChange = onChange_ default: break } } // MARK: ---- has the correct content it("has the correct content") { expect(modal?.info.title).to(equal("groupInformationSet".localized())) expect(modal?.info.body).to(equal( .input( explanation: NSAttributedString(string: "Group name is visible to all group members."), info: ConfirmationModal.Info.Body.InputInfo( placeholder: "groupNameEnter".localized(), initialValue: "TestGroup" ), onChange: { _ in } ) )) expect(modal?.info.confirmTitle).to(equal("save".localized())) expect(modal?.info.cancelTitle).to(equal("cancel".localized())) } // MARK: ---- does nothing if the name contains only white space it("does nothing if the name contains only white space") { onChange?(" ") modal?.confirmationPressed() expect(screenTransitions.count).to(equal(1)) } // MARK: ---- shows an error modal when the updated name is too long it("shows an error modal when the updated name is too long") { onChange?([String](Array(repeating: "1", count: 101)).joined()) modal?.confirmationPressed() expect(screenTransitions.count).to(equal(2)) expect(screenTransitions.last?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.last?.transition).to(equal(TransitionType.present)) let modal2: ConfirmationModal? = (screenTransitions.last?.destination as? ConfirmationModal) expect(modal2?.info.title).to(equal("theError".localized())) expect(modal2?.info.body).to(equal(.text("groupNameEnterShorter".localized()))) expect(modal2?.info.confirmTitle).to(beNil()) expect(modal2?.info.cancelTitle).to(equal("okay".localized())) } // MARK: ---- updates the group name when valid it("updates the group name when valid") { onChange?("TestNewGroupName") modal?.confirmationPressed() let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.map { $0.name }.asSet()).to(equal(["TestNewGroupName"])) } // MARK: ---- inserts a control message it("inserts a control message") { onChange?("TestNewGroupName") modal?.confirmationPressed() let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.first?.variant).to(equal(.infoLegacyGroupUpdated)) expect(interactions?.first?.body) .to(equal( "groupNameNew" .put(key: "group_name", value: "TestNewGroupName") .localized() )) } // MARK: ---- schedules a control message to be sent it("schedules a control message to be sent") { onChange?("TestNewGroupName") modal?.confirmationPressed() expect(mockJobRunner) .to(call(matchingParameters: .all) { $0.add( .any, job: Job( variant: .messageSend, threadId: legacyGroupPubkey, interactionId: 1, details: MessageSendJob.Details( destination: .closedGroup(groupPublicKey: legacyGroupPubkey), message: ClosedGroupControlMessage( kind: .nameChange(name: "TestNewGroupName") ) ) ), dependantJob: nil, canStartJob: true ) }) } // MARK: ---- triggers a libSession change it("triggers a libSession change") { onChange?("TestNewGroupName") modal?.confirmationPressed() expect(mockLibSessionCache) .to(call(matchingParameters: .all) { try $0.performAndPushChange( .any, for: .userGroups, sessionId: SessionId(.standard, hex: userPubkey), change: { _ in } ) }) } } } } // MARK: -- with a group conversation context("with a group conversation") { beforeEach { mockStorage.write { db in try SessionThread( id: groupPubkey, variant: .group, creationDateTimestamp: 0, using: dependencies ).insert(db) try ClosedGroup( threadId: groupPubkey, name: "TestGroup", groupDescription: nil, formationTimestamp: 1234567890, displayPictureUrl: nil, displayPictureFilename: nil, displayPictureEncryptionKey: nil, lastDisplayPictureUpdate: nil, shouldPoll: false, groupIdentityPrivateKey: nil, authData: nil, invited: nil ).insert(db) try GroupMember( groupId: groupPubkey, profileId: userPubkey, role: .standard, roleStatus: .accepted, isHidden: false ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name it("has the correct display name") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.title?.text).to(equal("TestGroup")) } // MARK: ---- when the user is a standard member context("when the user is a standard member") { // MARK: ---- has no edit icon it("has no edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).to(beNil()) } // MARK: ---- does nothing when tapped it("does nothing when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions).to(beEmpty()) } } // MARK: ---- when the user is an admin context("when the user is an admin") { beforeEach { mockStorage.write { db in try GroupMember.deleteAll(db) try ClosedGroup .updateAll( db, ClosedGroup.Columns.groupIdentityPrivateKey.set(to: Data([1, 2, 3])) ) try GroupMember( groupId: groupPubkey, profileId: userPubkey, role: .admin, roleStatus: .accepted, isHidden: false ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has an edit icon it("has an edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).toNot(beNil()) } // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions.first?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) } // MARK: ---- when updating the group info context("when updating the group info") { @TestState var onChange: ((String) -> ())? @TestState var onChange2: ((String, String) -> ())? @TestState var modal: ConfirmationModal? // MARK: ------ and editing the group description is enabled context("and editing the group description is enabled") { beforeEach { dependencies[feature: .updatedGroupsAllowDescriptionEditing] = true viewModel = ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() item(section: .conversationInfo, id: .displayName)?.onTap?() modal = (screenTransitions.first?.destination as? ConfirmationModal) switch modal?.info.body { case .input(_, _, let onChange_): onChange = onChange_ case .dualInput(_, _, _, let onChange2_): onChange2 = onChange2_ default: break } } // MARK: ---- has the correct content it("has the correct content") { expect(modal?.info.title).to(equal("groupInformationSet".localized())) expect(modal?.info.body).to(equal( .dualInput( explanation: NSAttributedString(string: "Group name and description are visible to all group members."), firstInfo: ConfirmationModal.Info.Body.InputInfo( placeholder: "groupNameEnter".localized(), initialValue: "TestGroup" ), secondInfo: ConfirmationModal.Info.Body.InputInfo( placeholder: "groupDescriptionEnter".localized(), initialValue: nil ), onChange: { _, _ in } ) )) expect(modal?.info.confirmTitle).to(equal("save".localized())) expect(modal?.info.cancelTitle).to(equal("cancel".localized())) } // MARK: ---- does nothing if the name contains only white space it("does nothing if the name contains only white space") { onChange2?(" ", "Test") modal?.confirmationPressed() expect(screenTransitions.count).to(equal(1)) } // MARK: ---- shows an error modal when the updated name is too long it("shows an error modal when the updated name is too long") { onChange2?([String](Array(repeating: "1", count: 101)).joined(), "Test") modal?.confirmationPressed() expect(screenTransitions.count).to(equal(2)) expect(screenTransitions.last?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.last?.transition).to(equal(TransitionType.present)) let modal2: ConfirmationModal? = (screenTransitions.last?.destination as? ConfirmationModal) expect(modal2?.info.title).to(equal("theError".localized())) expect(modal2?.info.body).to(equal(.text("groupNameEnterShorter".localized()))) expect(modal2?.info.confirmTitle).to(beNil()) expect(modal2?.info.cancelTitle).to(equal("okay".localized())) } // MARK: ---- shows an error modal when the updated description is too long it("shows an error modal when the updated description is too long") { onChange2?("Test", [String](Array(repeating: "1", count: 2001)).joined()) modal?.confirmationPressed() expect(screenTransitions.count).to(equal(2)) expect(screenTransitions.last?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.last?.transition).to(equal(TransitionType.present)) let modal2: ConfirmationModal? = (screenTransitions.last?.destination as? ConfirmationModal) expect(modal2?.info.title).to(equal("theError".localized())) expect(modal2?.info.body).to(equal(.text("Please enter a shorter group description."))) expect(modal2?.info.confirmTitle).to(beNil()) expect(modal2?.info.cancelTitle).to(equal("okay".localized())) } // MARK: ---- updates the group name when valid it("updates the group name when valid") { onChange2?("TestNewGroupName", "Test") modal?.confirmationPressed() let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.map { $0.name }.asSet()).to(equal(["TestNewGroupName"])) } // MARK: ---- updates the group description when valid it("updates the group description when valid") { onChange2?("Test", "TestNewGroupDescription") modal?.confirmationPressed() let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.map { $0.groupDescription }.asSet()).to(equal(["TestNewGroupDescription"])) } // MARK: ---- inserts a control message it("inserts a control message") { onChange2?("TestNewGroupName", "") modal?.confirmationPressed() let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.first?.variant).to(equal(.infoGroupInfoUpdated)) expect(interactions?.first?.body) .to(equal( ClosedGroup.MessageInfo .updatedName("TestNewGroupName") .infoString(using: dependencies) )) } // MARK: ---- schedules a control message to be sent it("schedules a control message to be sent") { onChange2?("TestNewGroupName", "") modal?.confirmationPressed() expect(mockJobRunner) .to(call(matchingParameters: .all) { $0.add( .any, job: Job( variant: .messageSend, threadId: groupPubkey, interactionId: nil, details: MessageSendJob.Details( destination: .closedGroup(groupPublicKey: groupPubkey), message: try GroupUpdateInfoChangeMessage( changeType: .name, updatedName: "TestNewGroupName", sentTimestampMs: UInt64(1234567890002), authMethod: Authentication.groupAdmin( groupSessionId: SessionId(.group, hex: groupPubkey), ed25519SecretKey: [1, 2, 3] ), using: dependencies ) ) ), dependantJob: nil, canStartJob: true ) }) } // MARK: ---- triggers a libSession change it("triggers a libSession change") { onChange2?("Test", "TestNewGroupDescription") modal?.confirmationPressed() expect(mockLibSessionCache) .to(call(matchingParameters: .all) { try $0.performAndPushChange( .any, for: .userGroups, sessionId: SessionId(.standard, hex: userPubkey), change: { _ in } ) }) expect(mockLibSessionCache) .to(call(matchingParameters: .all) { try $0.performAndPushChange( .any, for: .groupInfo, sessionId: SessionId(.group, hex: groupPubkey), change: { _ in } ) }) } } // MARK: ------ and editing the group description is disabled context("and editing the group description is disabled") { beforeEach { dependencies[feature: .updatedGroupsAllowDescriptionEditing] = false viewModel = ThreadSettingsViewModel( threadId: groupPubkey, threadVariant: .group, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() item(section: .conversationInfo, id: .displayName)?.onTap?() modal = (screenTransitions.first?.destination as? ConfirmationModal) switch modal?.info.body { case .input(_, _, let onChange_): onChange = onChange_ default: break } } // MARK: ---- has the correct content it("has the correct content") { expect(modal?.info.title).to(equal("groupInformationSet".localized())) expect(modal?.info.body).to(equal( .input( explanation: NSAttributedString(string: "Group name is visible to all group members."), info: ConfirmationModal.Info.Body.InputInfo( placeholder: "groupNameEnter".localized(), initialValue: "TestGroup" ), onChange: { _ in } ) )) expect(modal?.info.confirmTitle).to(equal("save".localized())) expect(modal?.info.cancelTitle).to(equal("cancel".localized())) } // MARK: ---- does nothing if the name contains only white space it("does nothing if the name contains only white space") { onChange?(" ") modal?.confirmationPressed() expect(screenTransitions.count).to(equal(1)) } // MARK: ---- shows an error modal when the updated name is too long it("shows an error modal when the updated name is too long") { onChange?([String](Array(repeating: "1", count: 101)).joined()) modal?.confirmationPressed() expect(screenTransitions.count).to(equal(2)) expect(screenTransitions.last?.destination).to(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.last?.transition).to(equal(TransitionType.present)) let modal2: ConfirmationModal? = (screenTransitions.last?.destination as? ConfirmationModal) expect(modal2?.info.title).to(equal("theError".localized())) expect(modal2?.info.body).to(equal(.text("groupNameEnterShorter".localized()))) expect(modal2?.info.confirmTitle).to(beNil()) expect(modal2?.info.cancelTitle).to(equal("okay".localized())) } // MARK: ---- updates the group name when valid it("updates the group name when valid") { onChange?("TestNewGroupName") modal?.confirmationPressed() let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } expect(groups?.map { $0.name }.asSet()).to(equal(["TestNewGroupName"])) } // MARK: ---- inserts a control message it("inserts a control message") { onChange?("TestNewGroupName") modal?.confirmationPressed() let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) } expect(interactions?.first?.variant).to(equal(.infoGroupInfoUpdated)) expect(interactions?.first?.body) .to(equal( ClosedGroup.MessageInfo .updatedName("TestNewGroupName") .infoString(using: dependencies) )) } // MARK: ---- schedules a control message to be sent it("schedules a control message to be sent") { onChange?("TestNewGroupName") modal?.confirmationPressed() expect(mockJobRunner) .to(call(matchingParameters: .all) { $0.add( .any, job: Job( variant: .messageSend, threadId: groupPubkey, interactionId: nil, details: MessageSendJob.Details( destination: .closedGroup(groupPublicKey: groupPubkey), message: try GroupUpdateInfoChangeMessage( changeType: .name, updatedName: "TestNewGroupName", sentTimestampMs: UInt64(1234567890002), authMethod: Authentication.groupAdmin( groupSessionId: SessionId(.group, hex: groupPubkey), ed25519SecretKey: [1, 2, 3] ), using: dependencies ) ) ), dependantJob: nil, canStartJob: true ) }) } // MARK: ---- triggers a libSession change it("triggers a libSession change") { onChange?("TestNewGroupName") modal?.confirmationPressed() expect(mockLibSessionCache) .to(call(matchingParameters: .all) { try $0.performAndPushChange( .any, for: .userGroups, sessionId: SessionId(.standard, hex: userPubkey), change: { _ in } ) }) expect(mockLibSessionCache) .to(call(matchingParameters: .all) { try $0.performAndPushChange( .any, for: .groupInfo, sessionId: SessionId(.group, hex: groupPubkey), change: { _ in } ) }) } } } } } // MARK: -- with a community conversation context("with a community conversation") { beforeEach { mockStorage.write { db in try SessionThread.deleteAll(db) try SessionThread( id: communityId, variant: .community, creationDateTimestamp: 0, using: dependencies ).insert(db) try OpenGroup( server: "testServer", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, isActive: false, name: "TestCommunity", userCount: 1, infoUpdates: 1 ).insert(db) } viewModel = ThreadSettingsViewModel( threadId: communityId, threadVariant: .community, didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, using: dependencies ) setupTestSubscriptions() } // MARK: ---- has the correct title it("has the correct title") { expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name it("has the correct display name") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.title?.text).to(equal("TestCommunity")) } // MARK: ---- has no edit icon it("has no edit icon") { let item: Item? = item(section: .conversationInfo, id: .displayName) expect(item?.leadingAccessory).to(beNil()) } // MARK: ---- does nothing when tapped it("does nothing when tapped") { item(section: .conversationInfo, id: .displayName)?.onTap?() expect(screenTransitions).to(beEmpty()) } } } } }