diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 2c22609f63e8b057bf780e2c47305c76f4f6c634..be183c099b55f8840c04f0470ab00cf3fd37ffd4 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -267,6 +267,7 @@ let package = Package( name: "GroupFeature", dependencies: [ .target(name: "AppCore"), + .target(name: "ChatFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "ComposablePresentation", package: "swift-composable-presentation"), .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), diff --git a/Examples/xx-messenger/Sources/AppCore/AppDependecies.swift b/Examples/xx-messenger/Sources/AppCore/AppDependecies.swift index c67559af9d580952c14554465d356e27672b0e72..9e55ac6da53b79b6c43352a78f58c78e4020551d 100644 --- a/Examples/xx-messenger/Sources/AppCore/AppDependecies.swift +++ b/Examples/xx-messenger/Sources/AppCore/AppDependecies.swift @@ -12,6 +12,7 @@ public struct AppDependencies { public var bgQueue: AnySchedulerOf<DispatchQueue> public var now: () -> Date public var sendMessage: SendMessage + public var sendGroupMessage: SendGroupMessage public var sendImage: SendImage public var messageListener: MessageListenerHandler public var receiveFileHandler: ReceiveFileHandler @@ -46,6 +47,11 @@ extension AppDependencies { db: dbManager.getDB, now: now ), + sendGroupMessage: .live( + messenger: messenger, + db: dbManager.getDB, + now: now + ), sendImage: .live( messenger: messenger, db: dbManager.getDB, @@ -85,6 +91,7 @@ extension AppDependencies { placeholder: Date(timeIntervalSince1970: 0) ), sendMessage: .unimplemented, + sendGroupMessage: .unimplemented, sendImage: .unimplemented, messageListener: .unimplemented, receiveFileHandler: .unimplemented, diff --git a/Examples/xx-messenger/Sources/AppCore/SendMessage/SendGroupMessage.swift b/Examples/xx-messenger/Sources/AppCore/SendMessage/SendGroupMessage.swift new file mode 100644 index 0000000000000000000000000000000000000000..9427053788da3f869e9dd6b0845d581c7e8905c2 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SendMessage/SendGroupMessage.swift @@ -0,0 +1,84 @@ +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels + +public struct SendGroupMessage { + public typealias OnError = (Error) -> Void + public typealias Completion = () -> Void + + public var run: (String, Data, @escaping OnError, @escaping Completion) -> Void + + public func callAsFunction( + text: String, + to groupId: Data, + onError: @escaping OnError, + completion: @escaping Completion + ) { + run(text, groupId, onError, completion) + } +} + +extension SendGroupMessage { + public static func live( + messenger: Messenger, + db: DBManagerGetDB, + now: @escaping () -> Date + ) -> SendGroupMessage { + SendGroupMessage { text, groupId, onError, completion in + do { + let chat = try messenger.groupChat.tryGet() + let myContactId = try messenger.e2e.tryGet().getContact().getId() + var message = try db().saveMessage(.init( + senderId: myContactId, + recipientId: nil, + groupId: groupId, + date: now(), + status: .sending, + isUnread: false, + text: text + )) + let payload = MessagePayload(text: message.text) + let report = try chat.send( + groupId: groupId, + message: try payload.encode() + ) + message.networkId = report.messageId + message.roundURL = report.roundURL + message = try db().saveMessage(message) + try messenger.cMix.tryGet().waitForRoundResult( + roundList: try report.encode(), + timeoutMS: 30_000, + callback: .init { result in + let status: XXModels.Message.Status + switch result { + case .delivered(_): + status = .sent + case .notDelivered(let timedOut): + status = timedOut ? .sendingTimedOut : .sendingFailed + } + do { + try db().bulkUpdateMessages( + .init(id: [message.id]), + .init(status: status) + ) + } catch { + onError(error) + } + completion() + } + ) + } catch { + onError(error) + completion() + } + } + } +} + +extension SendGroupMessage { + public static let unimplemented = SendGroupMessage( + run: XCTUnimplemented("\(Self.self)") + ) +} diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift index 6c71789dbbb201122cfda709999ac36984363b55..def3f14cdbae265ed27077c7e88a96416b924a49 100644 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatComponent.swift @@ -10,7 +10,8 @@ import XXModels public struct ChatComponent: ReducerProtocol { public struct State: Equatable, Identifiable { public enum ID: Equatable, Hashable { - case contact(Data) + case contact(XXModels.Contact.ID) + case group(XXModels.Group.ID) } public struct Message: Equatable, Identifiable { @@ -18,6 +19,7 @@ public struct ChatComponent: ReducerProtocol { id: Int64, date: Date, senderId: Data, + senderName: String?, text: String, status: XXModels.Message.Status, fileTransfer: XXModels.FileTransfer? = nil @@ -25,6 +27,7 @@ public struct ChatComponent: ReducerProtocol { self.id = id self.date = date self.senderId = senderId + self.senderName = senderName self.text = text self.status = status self.fileTransfer = fileTransfer @@ -33,6 +36,7 @@ public struct ChatComponent: ReducerProtocol { public var id: Int64 public var date: Date public var senderId: Data + public var senderName: String? public var text: String public var status: XXModels.Message.Status public var fileTransfer: XXModels.FileTransfer? @@ -77,6 +81,7 @@ public struct ChatComponent: ReducerProtocol { @Dependency(\.app.messenger) var messenger: Messenger @Dependency(\.app.dbManager.getDB) var db: DBManagerGetDB @Dependency(\.app.sendMessage) var sendMessage: SendMessage + @Dependency(\.app.sendGroupMessage) var sendGroupMessage: SendGroupMessage @Dependency(\.app.sendImage) var sendImage: SendImage @Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue> @Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue> @@ -93,37 +98,46 @@ public struct ChatComponent: ReducerProtocol { let myContactId = try messenger.e2e.tryGet().getContact().getId() state.myContactId = myContactId let queryChat: XXModels.Message.Query.Chat - let receivedFileTransfersQuery: XXModels.FileTransfer.Query - let sentFileTransfersQuery: XXModels.FileTransfer.Query + let receivedFileTransfersPublisher: AnyPublisher<[XXModels.FileTransfer], Error> + let sentFileTransfersPublisher: AnyPublisher<[XXModels.FileTransfer], Error> switch state.id { case .contact(let contactId): queryChat = .direct(myContactId, contactId) - receivedFileTransfersQuery = .init( + receivedFileTransfersPublisher = try db().fetchFileTransfersPublisher(.init( contactId: contactId, isIncoming: true - ) - sentFileTransfersQuery = .init( + )) + sentFileTransfersPublisher = try db().fetchFileTransfersPublisher(.init( contactId: myContactId, isIncoming: false - ) + )) + case .group(let groupId): + queryChat = .group(groupId) + receivedFileTransfersPublisher = Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + sentFileTransfersPublisher = Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } let messagesQuery = XXModels.Message.Query(chat: queryChat) return Publishers.CombineLatest3( try db().fetchMessagesPublisher(messagesQuery), - try db().fetchFileTransfersPublisher(receivedFileTransfersQuery), - try db().fetchFileTransfersPublisher(sentFileTransfersQuery) + try db().fetchContactsPublisher(.init()), + Publishers.CombineLatest( + receivedFileTransfersPublisher, + sentFileTransfersPublisher + ).map(+) ) - .map { messages, receivedFileTransfers, sentFileTransfers in - (messages, receivedFileTransfers + sentFileTransfers) - } .assertNoFailure() - .map { messages, fileTransfers in - messages.compactMap { message in + .map { messages, contacts, fileTransfers -> [State.Message] in + messages.compactMap { message -> State.Message? in guard let id = message.id else { return nil } return State.Message( id: id, date: message.date, senderId: message.senderId, + senderName: contacts.first { $0.id == message.senderId }?.username, text: message.text, status: message.status, fileTransfer: fileTransfers.first { $0.id == message.fileTransferId } @@ -163,6 +177,17 @@ public struct ChatComponent: ReducerProtocol { subscriber.send(completion: .finished) } ) + case .group(let groupId): + sendGroupMessage( + text: text, + to: groupId, + onError: { error in + subscriber.send(.sendFailed(error.localizedDescription)) + }, + completion: { + subscriber.send(completion: .finished) + } + ) } return AnyCancellable {} } @@ -175,21 +200,18 @@ public struct ChatComponent: ReducerProtocol { return .none case .imagePicked(let data): - let chatId = state.id + guard case .contact(let recipientId) = state.id else { return .none } return Effect.run { subscriber in - switch chatId { - case .contact(let recipientId): - sendImage( - data, - to: recipientId, - onError: { error in - subscriber.send(.sendFailed(error.localizedDescription)) - }, - completion: { - subscriber.send(completion: .finished) - } - ) - } + sendImage( + data, + to: recipientId, + onError: { error in + subscriber.send(.sendFailed(error.localizedDescription)) + }, + completion: { + subscriber.send(completion: .finished) + } + ) return AnyCancellable {} } .subscribe(on: bgQueue) diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift index 1611815c087786954fd730fe1768760801a7ea73..a7272385a3241f5d46890a88039edefeb7b41d80 100644 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift @@ -16,6 +16,7 @@ public struct ChatView: View { var failure: String? var sendFailure: String? var text: String + var disableImagePicker: Bool init(state: ChatComponent.State) { myContactId = state.myContactId @@ -23,6 +24,12 @@ public struct ChatView: View { failure = state.failure sendFailure = state.sendFailure text = state.text + switch state.id { + case .contact(_): + disableImagePicker = false + case .group(_): + disableImagePicker = true + } } } @@ -109,6 +116,7 @@ public struct ChatView: View { } } } + .disabled(viewStore.disableImagePicker) } } .padding() @@ -139,6 +147,13 @@ public struct ChatView: View { var body: some View { VStack { + if let sender = message.senderName { + Text(sender) + .foregroundColor(.secondary) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: alignment) + } + Text("\(message.date.formatted()), \(statusText)") .foregroundColor(.secondary) .font(.footnote) @@ -208,6 +223,7 @@ public struct ChatView_Previews: PreviewProvider { id: 1, date: Date(), senderId: "contact-id".data(using: .utf8)!, + senderName: "Contact", text: "Hello!", status: .received ), @@ -215,6 +231,7 @@ public struct ChatView_Previews: PreviewProvider { id: 2, date: Date(), senderId: "my-contact-id".data(using: .utf8)!, + senderName: "Me", text: "Hi!", status: .sent ), @@ -222,6 +239,7 @@ public struct ChatView_Previews: PreviewProvider { id: 3, date: Date(), senderId: "contact-id".data(using: .utf8)!, + senderName: "Contact", text: "", status: .received, fileTransfer: .init( @@ -237,6 +255,7 @@ public struct ChatView_Previews: PreviewProvider { id: 4, date: Date(), senderId: "my-contact-id".data(using: .utf8)!, + senderName: "Me", text: "", status: .sent, fileTransfer: .init( diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift index d9b7d23306b64b6924d5ce7ff3351728667476a3..359ee8991596e56cac72c39626955366588033b0 100644 --- a/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift +++ b/Examples/xx-messenger/Sources/GroupFeature/GroupComponent.swift @@ -1,5 +1,7 @@ import AppCore +import ChatFeature import ComposableArchitecture +import ComposablePresentation import Foundation import XXMessengerClient import XXModels @@ -10,18 +12,21 @@ public struct GroupComponent: ReducerProtocol { groupId: XXModels.Group.ID, groupInfo: XXModels.GroupInfo? = nil, isJoining: Bool = false, - joinFailure: String? = nil + joinFailure: String? = nil, + chat: ChatComponent.State? = nil ) { self.groupId = groupId self.groupInfo = groupInfo self.isJoining = isJoining self.joinFailure = joinFailure + self.chat = chat } public var groupId: XXModels.Group.ID public var groupInfo: XXModels.GroupInfo? public var isJoining: Bool public var joinFailure: String? + public var chat: ChatComponent.State? } public enum Action: Equatable { @@ -30,6 +35,9 @@ public struct GroupComponent: ReducerProtocol { case joinButtonTapped case didJoin case didFailToJoin(String) + case chatButtonTapped + case didDismissChat + case chat(ChatComponent.Action) } public init() {} @@ -88,7 +96,24 @@ public struct GroupComponent: ReducerProtocol { state.isJoining = false state.joinFailure = failure return .none + + case .chatButtonTapped: + state.chat = ChatComponent.State(id: .group(state.groupId)) + return .none + + case .didDismissChat: + state.chat = nil + return .none + + case .chat(_): + return .none } } + .presenting( + state: .keyPath(\.chat), + id: .notNil(), + action: /Action.chat, + presented: { ChatComponent() } + ) } } diff --git a/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift index 67b880f4b1ad3f8e790f51f1202624f195d0d60a..a7520d53802b8fc8a31a306a4305804b94c1cb61 100644 --- a/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift +++ b/Examples/xx-messenger/Sources/GroupFeature/GroupView.swift @@ -1,5 +1,7 @@ import AppCore +import ChatFeature import ComposableArchitecture +import ComposablePresentation import SwiftUI import XXModels @@ -68,8 +70,28 @@ public struct GroupView: View { } } } + + Section { + Button { + viewStore.send(.chatButtonTapped) + } label: { + HStack { + Text("Chat") + Spacer() + Image(systemName: "chevron.forward") + } + } + } } .navigationTitle("Group") + .background(NavigationLinkWithStore( + store.scope( + state: \.chat, + action: Component.Action.chat + ), + onDeactivate: { viewStore.send(.didDismissChat) }, + destination: ChatView.init(store:) + )) .task { viewStore.send(.start) } } } diff --git a/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendGroupMessageTests.swift b/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendGroupMessageTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..1df2eaa3717344509c590a462036218c0ff416b0 --- /dev/null +++ b/Examples/xx-messenger/Tests/AppCoreTests/SendMessage/SendGroupMessageTests.swift @@ -0,0 +1,279 @@ +import CustomDump +import XCTest +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient +import XXModels +@testable import AppCore + +final class SendGroupMessageTests: XCTestCase { + enum Action: Equatable { + case didReceiveError(String) + case didComplete + case didSaveMessage(XXModels.Message) + case didSend(groupId: Data, message: Data, tag: String?) + case didWaitForRoundResults(roundList: Data, timeoutMS: Int) + case didUpdateMessage( + query: XXModels.Message.Query, + assignments: XXModels.Message.Assignments + ) + } + + var actions: [Action]! + + override func setUp() { + actions = [] + } + + override func tearDown() { + actions = nil + } + + func testSend() { + let text = "Hello!" + let groupId = "group-id".data(using: .utf8)! + let myContactId = "my-contact-id".data(using: .utf8)! + let messageId: Int64 = 321 + let sendReport = GroupSendReport( + rounds: [], + roundURL: "round-url", + timestamp: 1234, + messageId: "message-id".data(using: .utf8)! + ) + + var messageDeliveryCallback: MessageDeliveryCallback? + + var messenger: Messenger = .unimplemented + messenger.groupChat.get = { + var groupChat: GroupChat = .unimplemented + groupChat.send.run = { groupId, message, tag in + self.actions.append(.didSend(groupId: groupId, message: message, tag: tag)) + return sendReport + } + return groupChat + } + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in myContactId } + return contact + } + return e2e + } + messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.waitForRoundResult.run = { roundList, timeoutMS, callback in + self.actions.append(.didWaitForRoundResults(roundList: roundList, timeoutMS: timeoutMS)) + messageDeliveryCallback = callback + } + return cMix + } + var db: DBManagerGetDB = .unimplemented + db.run = { + var db: Database = .unimplemented + db.saveMessage.run = { message in + self.actions.append(.didSaveMessage(message)) + var message = message + message.id = messageId + return message + } + db.bulkUpdateMessages.run = { query, assignments in + self.actions.append(.didUpdateMessage(query: query, assignments: assignments)) + return 1 + } + return db + } + let now = Date() + let send: SendGroupMessage = .live( + messenger: messenger, + db: db, + now: { now } + ) + + send( + text: text, + to: groupId, + onError: { error in + self.actions.append(.didReceiveError(error.localizedDescription)) + }, + completion: { + self.actions.append(.didComplete) + } + ) + + XCTAssertNoDifference(actions, [ + .didSaveMessage(.init( + senderId: myContactId, + recipientId: nil, + groupId: groupId, + date: now, + status: .sending, + isUnread: false, + text: text + )), + .didSend( + groupId: groupId, + message: try! MessagePayload(text: text).encode(), + tag: nil + ), + .didSaveMessage(.init( + id: messageId, + networkId: sendReport.messageId, + senderId: myContactId, + recipientId: nil, + groupId: groupId, + date: now, + status: .sending, + isUnread: false, + text: text, + roundURL: sendReport.roundURL + )), + .didWaitForRoundResults( + roundList: try! sendReport.encode(), + timeoutMS: 30_000 + ), + ]) + + actions = [] + messageDeliveryCallback?.handle(.delivered(roundResults: [])) + + XCTAssertNoDifference(actions, [ + .didUpdateMessage( + query: .init(id: [messageId]), + assignments: .init(status: .sent) + ), + .didComplete, + ]) + + actions = [] + messageDeliveryCallback?.handle(.notDelivered(timedOut: true)) + + XCTAssertNoDifference(actions, [ + .didUpdateMessage( + query: .init(id: [messageId]), + assignments: .init(status: .sendingTimedOut) + ), + .didComplete, + ]) + + actions = [] + messageDeliveryCallback?.handle(.notDelivered(timedOut: false)) + + XCTAssertNoDifference(actions, [ + .didUpdateMessage( + query: .init(id: [messageId]), + assignments: .init(status: .sendingFailed) + ), + .didComplete, + ]) + } + + func testSendDatabaseFailure() { + struct Failure: Error, Equatable {} + let failure = Failure() + + var messenger: Messenger = .unimplemented + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in Data() } + return contact + } + return e2e + } + messenger.groupChat.get = { .unimplemented } + var db: DBManagerGetDB = .unimplemented + db.run = { throw failure } + let send: SendGroupMessage = .live( + messenger: messenger, + db: db, + now: XCTestDynamicOverlay.unimplemented("now", placeholder: Date()) + ) + + send( + text: "Hello", + to: "group-id".data(using: .utf8)!, + onError: { error in + self.actions.append(.didReceiveError(error.localizedDescription)) + }, + completion: { + self.actions.append(.didComplete) + } + ) + + XCTAssertNoDifference(actions, [ + .didReceiveError(failure.localizedDescription), + .didComplete + ]) + } + + func testBulkUpdateOnDeliveryFailure() { + struct Failure: Error, Equatable {} + let failure = Failure() + + var messageDeliveryCallback: MessageDeliveryCallback? + + var messenger: Messenger = .unimplemented + messenger.groupChat.get = { + var groupChat: GroupChat = .unimplemented + groupChat.send.run = { _, _, _ in + GroupSendReport( + rounds: [], + roundURL: "", + timestamp: 0, + messageId: Data() + ) + } + return groupChat + } + messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in Data() } + return contact + } + return e2e + } + messenger.cMix.get = { + var cMix: CMix = .unimplemented + cMix.waitForRoundResult.run = { _, _, callback in + messageDeliveryCallback = callback + } + return cMix + } + var db: DBManagerGetDB = .unimplemented + db.run = { + var db: Database = .unimplemented + db.saveMessage.run = { message in message } + db.bulkUpdateMessages.run = { _, _ in throw failure } + return db + } + let now = Date() + let send: SendGroupMessage = .live( + messenger: messenger, + db: db, + now: { now } + ) + + send( + text: "Hello", + to: Data(), + onError: { error in + self.actions.append(.didReceiveError(error.localizedDescription)) + }, + completion: { + self.actions.append(.didComplete) + } + ) + + messageDeliveryCallback?.handle(.delivered(roundResults: [])) + + XCTAssertNoDifference(actions, [ + .didReceiveError(failure.localizedDescription), + .didComplete, + ]) + } +} diff --git a/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift index c8f5952864c6f77f04ec42b53b5875a317ee37a4..ba7ed23792f71c169b42daffc10fec988caef3a5 100644 --- a/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift +++ b/Examples/xx-messenger/Tests/ChatFeatureTests/ChatComponentTests.swift @@ -9,7 +9,7 @@ import XXModels @testable import ChatFeature final class ChatComponentTests: XCTestCase { - func testStart() { + func testStartDirectChat() { let contactId = "contact-id".data(using: .utf8)! let myContactId = "my-contact-id".data(using: .utf8)! @@ -22,6 +22,8 @@ final class ChatComponentTests: XCTestCase { let messagesPublisher = PassthroughSubject<[XXModels.Message], Error>() var didFetchFileTransfersWithQuery: [XXModels.FileTransfer.Query] = [] let fileTransfersPublisher = PassthroughSubject<[XXModels.FileTransfer], Error>() + var didFetchContactsWithQuery: [XXModels.Contact.Query] = [] + let contactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() store.dependencies.app.mainQueue = .immediate store.dependencies.app.bgQueue = .immediate @@ -40,6 +42,10 @@ final class ChatComponentTests: XCTestCase { didFetchMessagesWithQuery.append(query) return messagesPublisher.eraseToAnyPublisher() } + db.fetchContactsPublisher.run = { query in + didFetchContactsWithQuery.append(query) + return contactsPublisher.eraseToAnyPublisher() + } db.fetchFileTransfersPublisher.run = { query in didFetchFileTransfersWithQuery.append(query) return fileTransfersPublisher.eraseToAnyPublisher() @@ -58,6 +64,9 @@ final class ChatComponentTests: XCTestCase { .init(contactId: contactId, isIncoming: true), .init(contactId: myContactId, isIncoming: false), ]) + XCTAssertNoDifference(didFetchContactsWithQuery, [ + .init(), + ]) let receivedFileTransfer = FileTransfer( id: "file-transfer-1-id".data(using: .utf8)!, @@ -111,12 +120,17 @@ final class ChatComponentTests: XCTestCase { receivedFileTransfer, sentFileTransfer, ]) + contactsPublisher.send([ + .init(id: myContactId, username: "My username"), + .init(id: contactId, username: "Contact username"), + ]) let expectedMessages = IdentifiedArrayOf<ChatComponent.State.Message>(uniqueElements: [ .init( id: 1, date: Date(timeIntervalSince1970: 1), senderId: contactId, + senderName: "Contact username", text: "Message 1", status: .received, fileTransfer: receivedFileTransfer @@ -125,6 +139,7 @@ final class ChatComponentTests: XCTestCase { id: 2, date: Date(timeIntervalSince1970: 2), senderId: myContactId, + senderName: "My username", text: "Message 2", status: .sent, fileTransfer: sentFileTransfer @@ -137,6 +152,131 @@ final class ChatComponentTests: XCTestCase { messagesPublisher.send(completion: .finished) fileTransfersPublisher.send(completion: .finished) + contactsPublisher.send(completion: .finished) + } + + func testStartGroupChat() { + let groupId = "group-id".data(using: .utf8)! + let myContactId = "my-contact-id".data(using: .utf8)! + let firstMemberId = "member-1-id".data(using: .utf8)! + let secondMemberId = "member-2-id".data(using: .utf8)! + + let store = TestStore( + initialState: ChatComponent.State(id: .group(groupId)), + reducer: ChatComponent() + ) + + var didFetchMessagesWithQuery: [XXModels.Message.Query] = [] + let messagesPublisher = PassthroughSubject<[XXModels.Message], Error>() + var didFetchContactsWithQuery: [XXModels.Contact.Query] = [] + let contactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.messenger.e2e.get = { + var e2e: E2E = .unimplemented + e2e.getContact.run = { + var contact: XXClient.Contact = .unimplemented(Data()) + contact.getIdFromContact.run = { _ in myContactId } + return contact + } + return e2e + } + store.dependencies.app.dbManager.getDB.run = { + var db: Database = .unimplemented + db.fetchMessagesPublisher.run = { query in + didFetchMessagesWithQuery.append(query) + return messagesPublisher.eraseToAnyPublisher() + } + db.fetchContactsPublisher.run = { query in + didFetchContactsWithQuery.append(query) + return contactsPublisher.eraseToAnyPublisher() + } + return db + } + + store.send(.start) { + $0.myContactId = myContactId + } + + XCTAssertNoDifference(didFetchMessagesWithQuery, [ + .init(chat: .group(groupId)) + ]) + XCTAssertNoDifference(didFetchContactsWithQuery, [ + .init(), + ]) + + messagesPublisher.send([ + .init( + id: 0, + senderId: myContactId, + recipientId: nil, + groupId: groupId, + date: Date(timeIntervalSince1970: 0), + status: .sent, + isUnread: false, + text: "Message 0" + ), + .init( + id: 1, + senderId: firstMemberId, + recipientId: nil, + groupId: groupId, + date: Date(timeIntervalSince1970: 1), + status: .received, + isUnread: false, + text: "Message 1" + ), + .init( + id: 2, + senderId: secondMemberId, + recipientId: nil, + groupId: groupId, + date: Date(timeIntervalSince1970: 2), + status: .received, + isUnread: false, + text: "Message 2" + ), + ]) + contactsPublisher.send([ + .init(id: myContactId, username: "My username"), + .init(id: firstMemberId, username: "First username"), + .init(id: secondMemberId, username: "Second username"), + ]) + + let expectedMessages = IdentifiedArrayOf<ChatComponent.State.Message>(uniqueElements: [ + .init( + id: 0, + date: Date(timeIntervalSince1970: 0), + senderId: myContactId, + senderName: "My username", + text: "Message 0", + status: .sent + ), + .init( + id: 1, + date: Date(timeIntervalSince1970: 1), + senderId: firstMemberId, + senderName: "First username", + text: "Message 1", + status: .received + ), + .init( + id: 2, + date: Date(timeIntervalSince1970: 2), + senderId: secondMemberId, + senderName: "Second username", + text: "Message 2", + status: .received + ), + ]) + + store.receive(.didFetchMessages(expectedMessages)) { + $0.messages = expectedMessages + } + + messagesPublisher.send(completion: .finished) + contactsPublisher.send(completion: .finished) } func testStartFailure() { @@ -165,7 +305,7 @@ final class ChatComponentTests: XCTestCase { } } - func testSend() { + func testSendDirectMessage() { struct SendMessageParams: Equatable { var text: String var recipientId: Data @@ -200,7 +340,7 @@ final class ChatComponentTests: XCTestCase { sendMessageCompletion?() } - func testSendFailure() { + func testSendDirectMessageFailure() { var sendMessageOnError: SendMessage.OnError? var sendMessageCompletion: SendMessage.Completion? @@ -237,6 +377,80 @@ final class ChatComponentTests: XCTestCase { } } + func testSendGroupMessage() { + let groupId = "group-id".data(using: .utf8)! + let text = "Hello" + struct SendGroupMessageParams: Equatable { + var text: String + var groupId: Data + } + var didSendGroupMessageWithParams: [SendGroupMessageParams] = [] + var sendGroupMessageCompletion: SendGroupMessage.Completion? + + let store = TestStore( + initialState: ChatComponent.State(id: .group(groupId)), + reducer: ChatComponent() + ) + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendGroupMessage.run = { text, groupId, _, completion in + didSendGroupMessageWithParams.append(.init(text: text, groupId: groupId)) + sendGroupMessageCompletion = completion + } + + store.send(.set(\.$text, text)) { + $0.text = text + } + + store.send(.sendTapped) { + $0.text = "" + } + + XCTAssertNoDifference(didSendGroupMessageWithParams, [ + .init(text: text, groupId: groupId) + ]) + + sendGroupMessageCompletion?() + } + + func testSendGroupMessageFailure() { + var sendGroupMessageOnError: SendGroupMessage.OnError? + var sendGroupMessageCompletion: SendGroupMessage.Completion? + + let store = TestStore( + initialState: ChatComponent.State( + id: .group("group-id".data(using: .utf8)!), + text: "Hello" + ), + reducer: ChatComponent() + ) + + store.dependencies.app.mainQueue = .immediate + store.dependencies.app.bgQueue = .immediate + store.dependencies.app.sendGroupMessage.run = { _, _, onError, completion in + sendGroupMessageOnError = onError + sendGroupMessageCompletion = completion + } + + store.send(.sendTapped) { + $0.text = "" + } + + let error = NSError(domain: "test", code: 123) + sendGroupMessageOnError?(error) + + store.receive(.sendFailed(error.localizedDescription)) { + $0.sendFailure = error.localizedDescription + } + + sendGroupMessageCompletion?() + + store.send(.dismissSendFailureTapped) { + $0.sendFailure = nil + } + } + func testSendImage() { struct SendImageParams: Equatable { var image: Data diff --git a/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift index 3c7193fc4ebea418f97800fae37e782d982d497f..b3791b1f107106d9935e6576e664f9ad40cf80f7 100644 --- a/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift +++ b/Examples/xx-messenger/Tests/GroupFeatureTests/GroupComponentTests.swift @@ -1,3 +1,4 @@ +import ChatFeature import Combine import ComposableArchitecture import CustomDump @@ -140,6 +141,26 @@ final class GroupComponentTests: XCTestCase { $0.joinFailure = failure.localizedDescription } } + + func testPresentChat() { + let groupInfo = GroupInfo.stub() + + let store = TestStore( + initialState: GroupComponent.State( + groupId: groupInfo.group.id, + groupInfo: groupInfo + ), + reducer: GroupComponent() + ) + + store.send(.chatButtonTapped) { + $0.chat = ChatComponent.State(id: .group(groupInfo.id)) + } + + store.send(.didDismissChat) { + $0.chat = nil + } + } } private extension XXModels.GroupInfo {