You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec...

1173 lines
62 KiB
Swift

// 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<ThreadSettingsViewModel.TableItem>
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
dependencies.forceSynchronous = true
}
@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(())
cache
.when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) }
.thenReturn(LibSession.defaultNewThreadPriority)
cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) }
.thenReturn(nil)
cache
.when { $0.isAdmin(groupSessionId: .any) }
.thenReturn(false)
cache
.when { try $0.withCustomBehaviour(.any, for: .any, variant: .any, change: { }) }
.then { args, untrackedArgs in
let callback: (() throws -> Void)? = (untrackedArgs[test: 0] as? () throws -> Void)
try? callback?()
}
.thenReturn(())
cache.when { $0.isEmpty }.thenReturn(false)
cache
.when { try $0.pendingChanges(.any, swarmPubkey: .any) }
.thenReturn(LibSession.PendingChanges())
}
)
@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(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache(
initialSetup: { cache in
var timestampMs: Int64 = 1234567890000
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<ThreadSettingsViewModel.TableItem>) -> 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,
accessibility: Accessibility(identifier: "Username input")
),
onChange: { _ in }
)
))
expect(modal?.info.confirmTitle).to(equal("save".localized()))
expect(modal?.info.cancelTitle).to(equal("remove".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 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("nicknameErrorShorter".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: "groupNameVisible".localized()),
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupNameEnter".localized(),
initialValue: "TestGroup",
accessibility: Accessibility(identifier: "Group name text field")
),
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",
accessibility: Accessibility(identifier: "Group name text field")
),
secondInfo: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupDescriptionEnter".localized(),
initialValue: nil,
accessibility: Accessibility(identifier: "Group description text field")
),
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,
behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure,
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
),
requiredConfigSyncVariant: .groupInfo
)
),
dependantJob: nil,
canStartJob: false
)
})
}
// MARK: ---- triggers a libSession change
it("triggers a libSession change") {
mockLibSessionCache
.when { $0.isAdmin(groupSessionId: .any) }
.thenReturn(true)
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: "groupNameVisible".localized()),
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupNameEnter".localized(),
initialValue: "TestGroup",
accessibility: Accessibility(identifier: "Group name text field")
),
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,
behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure,
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
),
requiredConfigSyncVariant: .groupInfo
)
),
dependantJob: nil,
canStartJob: false
)
})
}
// MARK: ---- triggers a libSession change
it("triggers a libSession change") {
mockLibSessionCache
.when { $0.isAdmin(groupSessionId: .any) }
.thenReturn(true)
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())
}
}
}
}
}