diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cee379e2b3..1d7b5ba11ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add `SendMessageInterceptor` to intercept send message requests [#3671](https://github.com/GetStream/stream-chat-swift/pull/3671) +- Add `ChatMessage.changing()` to allow overriding message data temporarily [#3671](https://github.com/GetStream/stream-chat-swift/pull/3671) ### 🐞 Fixed - Fix swipe to reply enabled when quoting a message is disabled [#3662](https://github.com/GetStream/stream-chat-swift/pull/3662) - Fix shadowed messages increasing the channel messages unread count [#3665](https://github.com/GetStream/stream-chat-swift/pull/3665) diff --git a/Sources/StreamChat/ChatClient.swift b/Sources/StreamChat/ChatClient.swift index 9048ea8202b..403cbcbea3a 100644 --- a/Sources/StreamChat/ChatClient.swift +++ b/Sources/StreamChat/ChatClient.swift @@ -222,6 +222,9 @@ public class ChatClient { setupConnectionRecoveryHandler(with: environment) validateIntegrity() + let interceptor = config.sendMessageInterceptorFactory?.makeSendMessageInterceptor(client: self) + messageRepository.setInterceptor(interceptor) + reconnectionTimeoutHandler = environment.reconnectionHandlerBuilder(config) reconnectionTimeoutHandler?.onChange = { [weak self] in self?.timeout() diff --git a/Sources/StreamChat/Config/ChatClientConfig.swift b/Sources/StreamChat/Config/ChatClientConfig.swift index be56244de93..7ce758736e1 100644 --- a/Sources/StreamChat/Config/ChatClientConfig.swift +++ b/Sources/StreamChat/Config/ChatClientConfig.swift @@ -67,6 +67,10 @@ public struct ChatClientConfig { /// An object that provides a way to transform Stream Chat models. public var modelsTransformer: StreamModelsTransformer? + /// A factory to create an interceptor with the goal of intercepting messages + /// before sending them to the server. + public var sendMessageInterceptorFactory: SendMessageInterceptorFactory? + /// Advanced settings for the local caching and model serialization. public var localCaching = LocalCaching() diff --git a/Sources/StreamChat/Config/SendMessageInterceptor.swift b/Sources/StreamChat/Config/SendMessageInterceptor.swift new file mode 100644 index 00000000000..77ca35e83a0 --- /dev/null +++ b/Sources/StreamChat/Config/SendMessageInterceptor.swift @@ -0,0 +1,34 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A struct that contains additional info when sending messages. +public struct SendMessageOptions { + public let skipPush: Bool + public let skipEnrichUrl: Bool +} + +/// A struct that represents the response when sending a message. +public struct SendMessageResponse { + public let message: ChatMessage + + public init(message: ChatMessage) { + self.message = message + } +} + +/// A protocol that defines a way to intercept messages before sending them to the server. +public protocol SendMessageInterceptor { + func sendMessage( + _ message: ChatMessage, + options: SendMessageOptions, + completion: @escaping ((Result) -> Void) + ) +} + +/// A factory responsible for creating message interceptors. +public protocol SendMessageInterceptorFactory { + func makeSendMessageInterceptor(client: ChatClient) -> SendMessageInterceptor +} diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index f75be99d7f1..1aaa575ec65 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -261,11 +261,132 @@ public struct ChatMessage { self.draftReply = draftReply } + /// Returns a new `ChatMessage` with the provided data changed. + /// + /// If the provided data is `nil`, it will keep the original value. + public func changing( + text: String? = nil, + type: MessageType? = nil, + state: LocalMessageState? = nil, + command: String? = nil, + arguments: String? = nil, + attachments: [AnyChatMessageAttachment]? = nil, + translations: [TranslationLanguage: String]? = nil, + originalLanguage: TranslationLanguage? = nil, + moderationDetails: MessageModerationDetails? = nil, + extraData: [String: RawJSON]? = nil + ) -> ChatMessage { + .init( + id: id, + cid: cid, + text: text ?? self.text, + type: type ?? self.type, + command: command ?? self.command, + createdAt: createdAt, + locallyCreatedAt: locallyCreatedAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + arguments: arguments ?? self.arguments, + parentMessageId: parentMessageId, + showReplyInChannel: showReplyInChannel, + replyCount: replyCount, + extraData: extraData ?? self.extraData, + quotedMessage: quotedMessage, + isBounced: isBounced, + isSilent: isSilent, + isShadowed: isShadowed, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + reactionGroups: reactionGroups, + author: author, + mentionedUsers: mentionedUsers, + threadParticipants: threadParticipants, + attachments: attachments ?? allAttachments, + latestReplies: latestReplies, + localState: state ?? localState, + isFlaggedByCurrentUser: isFlaggedByCurrentUser, + latestReactions: latestReactions, + currentUserReactions: currentUserReactions, + isSentByCurrentUser: isSentByCurrentUser, + pinDetails: pinDetails, + translations: translations ?? self.translations, + originalLanguage: originalLanguage ?? self.originalLanguage, + moderationDetails: moderationDetails ?? self.moderationDetails, + readBy: readBy, + poll: poll, + textUpdatedAt: textUpdatedAt, + draftReply: draftReply + ) + } + /// Returns a new `ChatMessage` with the provided data replaced. + /// + /// If the provided data is `nil`, it will overwrite the existing value with `nil`. + /// If you want to keep the original data, use the original object data, example: + /// + /// ```swift + /// /// Updating only the text of the message + /// let newMessage = oldMessage.replacing( + /// text: "New text" + /// extraData: oldMessage.extraData, + /// attachments: oldMessage.attachments + /// ) + /// + /// /// Updating the text and removing the attachments + /// let newMessage = oldMessage.replacing( + /// text: "New text", + /// extraData: oldMessage.extraData, + /// attachments: nil + /// ) + /// ``` public func replacing( text: String?, extraData: [String: RawJSON]?, attachments: [AnyChatMessageAttachment]? + ) -> ChatMessage { + replacing( + text: text, + type: type, + state: localState, + command: command, + arguments: arguments, + attachments: attachments, + translations: translations, + originalLanguage: originalLanguage, + moderationDetails: moderationDetails, + extraData: extraData + ) + } + + /// Returns a new `ChatMessage` with the provided data replaced. + /// + /// If the provided data is `nil`, it will overwrite the existing value with `nil`. + /// If you want to keep the original data, use the original object data. + /// + /// - Parameters: + /// - text: The new text of the message. If `nil`, the text will be empty. + /// - type: The new type of the message. If `nil`, the type will be `.regular`. + /// - state: The new local state of the message. If `nil`, the local state will be empty + /// which means the message is published to the server. + /// - command: The new command of the message. If `nil`, the command will be empty. + /// - arguments: The new arguments of the message. If `nil`, the arguments will be empty. + /// - attachments: The new attachments of the message. If `nil`, the attachments will be empty. + /// - translations: The new translations of the message. If `nil`, the translations will be empty. + /// - originalLanguage: The original language of the message. If `nil`, the original language will be empty. + /// - moderationDetails: The new moderation details of the message. If `nil`, the moderation details will be empty. + /// - extraData: The new extra data of the message. If `nil`, the extra data will be empty. + /// - Returns: The message with the updated data. + public func replacing( + text: String?, + type: MessageType, + state: LocalMessageState?, + command: String?, + arguments: String?, + attachments: [AnyChatMessageAttachment]?, + translations: [TranslationLanguage: String]?, + originalLanguage: TranslationLanguage?, + moderationDetails: MessageModerationDetails?, + extraData: [String: RawJSON]? ) -> ChatMessage { .init( id: id, @@ -294,7 +415,7 @@ public struct ChatMessage { threadParticipants: threadParticipants, attachments: attachments ?? [], latestReplies: latestReplies, - localState: localState, + localState: state, isFlaggedByCurrentUser: isFlaggedByCurrentUser, latestReactions: latestReactions, currentUserReactions: currentUserReactions, diff --git a/Sources/StreamChat/Repositories/MessageRepository.swift b/Sources/StreamChat/Repositories/MessageRepository.swift index 392dad7b482..4d11eda2838 100644 --- a/Sources/StreamChat/Repositories/MessageRepository.swift +++ b/Sources/StreamChat/Repositories/MessageRepository.swift @@ -5,7 +5,7 @@ import CoreData import Foundation -enum MessageRepositoryError: LocalizedError { +enum MessageRepositoryError: Error { case messageDoesNotExist case messageNotPendingSend case messageDoesNotHaveValidChannel @@ -15,12 +15,20 @@ enum MessageRepositoryError: LocalizedError { class MessageRepository { let database: DatabaseContainer let apiClient: APIClient + var interceptor: SendMessageInterceptor? - init(database: DatabaseContainer, apiClient: APIClient) { + init( + database: DatabaseContainer, + apiClient: APIClient + ) { self.database = database self.apiClient = apiClient } + func setInterceptor(_ interceptor: SendMessageInterceptor?) { + self.interceptor = interceptor + } + func sendMessage( with messageId: MessageId, completion: @escaping (Result) -> Void @@ -51,38 +59,40 @@ class MessageRepository { let skipEnrichUrl: Bool = dto.skipEnrichUrl // Change the message state to `.sending` and the proceed with the actual sending - self?.database.write({ - let messageDTO = $0.message(id: messageId) + self?.database.write(converting: { session in + let messageDTO = session.message(id: messageId) messageDTO?.localMessageState = .sending - }, completion: { error in - if let error = error { - log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)") - self?.markMessageAsFailedToSend(id: messageId) { - completion(.failure(.failedToSendMessage(error))) - } - return + guard let message = try messageDTO?.asModel() else { + throw MessageRepositoryError.messageDoesNotExist } - - let endpoint: Endpoint = .sendMessage( - cid: cid, - messagePayload: requestBody, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl - ) - self?.apiClient.request(endpoint: endpoint) { - switch $0 { - case let .success(payload): - self?.saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in - switch result { - case let .success(message): - completion(.success(message)) - case let .failure(error): - completion(.failure(.failedToSendMessage(error))) - } + return message + }, completion: { result in + switch result { + case let .success(message): + if let interceptor = self?.interceptor { + let options = SendMessageOptions( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl + ) + interceptor.sendMessage(message, options: options) { result in + self?.handleInterceptedMessage(result, messageId: messageId, completion: completion) } + return + } - case let .failure(error): - self?.handleSendingMessageError(error, messageId: messageId, completion: completion) + let endpoint: Endpoint = .sendMessage( + cid: cid, + messagePayload: requestBody, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl + ) + self?.apiClient.request(endpoint: endpoint) { result in + self?.handleSentMessage(result, cid: cid, messageId: messageId, completion: completion) + } + case let .failure(error): + log.error("Error changing localMessageState message with id \(messageId) to `sending`: \(error)") + self?.markMessageAsFailedToSend(id: messageId) { + completion(.failure(.failedToSendMessage(error))) } } }) @@ -158,6 +168,59 @@ class MessageRepository { }) } + /// Handles the result when sending the message to the server. + private func handleSentMessage( + _ result: Result, + cid: ChannelId, + messageId: MessageId, + completion: @escaping (Result) -> Void + ) { + switch result { + case let .success(payload): + saveSuccessfullySentMessage(cid: cid, message: payload.message) { result in + switch result { + case let .success(message): + completion(.success(message)) + case let .failure(error): + completion(.failure(.failedToSendMessage(error))) + } + } + case let .failure(error): + handleSendingMessageError( + error, + messageId: messageId, + completion: completion + ) + } + } + + /// Handles the result when the message is intercepted. + private func handleInterceptedMessage( + _ result: Result, + messageId: MessageId, + completion: @escaping (Result) -> Void + ) { + switch result { + case let .success(response): + let message = response.message + database.write { session in + guard let messageDTO = session.message(id: message.id) else { return } + // The customer changes the local state to nil in the interceptor, + // it means we should mark it as sent and not wait for message new event. + if message.localState == nil { + messageDTO.markMessageAsSent() + } + } + completion(.success(message)) + case let .failure(error): + handleSendingMessageError( + error, + messageId: messageId, + completion: completion + ) + } + } + private func handleSendingMessageError( _ error: Error, messageId: MessageId, diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index b2d9a14d7c5..c3e8de79df8 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1665,6 +1665,8 @@ AD99C909279B0E9D009DD9C5 /* MessageDateSeparatorFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */; }; AD99C90C279B136B009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; AD99C90D279B136D009DD9C5 /* UserLastActivityFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */; }; + AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; }; + AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */; }; ADA03A222D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; ADA03A232D64EFE900DFE048 /* DraftMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A212D64EFE900DFE048 /* DraftMessage.swift */; }; ADA03A252D65041B00DFE048 /* DraftMessage_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */; }; @@ -4381,6 +4383,7 @@ AD99C906279B0C9B009DD9C5 /* MessageDateSeparatorFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDateSeparatorFormatter.swift; sourceTree = ""; }; AD99C90A279B1363009DD9C5 /* UserLastActivityFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLastActivityFormatter.swift; sourceTree = ""; }; AD9BE32526680E4200A6D284 /* Stream.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = Stream.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageInterceptor.swift; sourceTree = ""; }; ADA03A212D64EFE900DFE048 /* DraftMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage.swift; sourceTree = ""; }; ADA03A242D65041300DFE048 /* DraftMessage_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftMessage_Mock.swift; sourceTree = ""; }; ADA2D6492C46B66E001D2B44 /* DemoChatChannelListErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatChannelListErrorView.swift; sourceTree = ""; }; @@ -5715,6 +5718,7 @@ 79FC85E624ACCBC500A665ED /* Token.swift */, C1B49B3C2822A7AD00F4E89E /* StreamRuntimeCheck.swift */, AD81FEEC2D3977AC00765FD4 /* StreamModelsTransformer.swift */, + AD9C926F2DD4DCF90013A7E6 /* SendMessageInterceptor.swift */, ); path = Config; sourceTree = ""; @@ -11537,6 +11541,7 @@ AD52A2192804850700D0157E /* ChannelConfigDTO.swift in Sources */, 797A756824814F0D003CF16D /* Bundle+Extensions.swift in Sources */, 7908829C2546D95A00896F03 /* FlagMessagePayload.swift in Sources */, + AD9C92712DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */, DA0BB1612513B5F200CAEFBD /* StringInterpolation+Extensions.swift in Sources */, 64C8C86E26934C6100329F82 /* UserInfo.swift in Sources */, C1FFD9F927ECC7C7008A6848 /* Filter+ChatChannel.swift in Sources */, @@ -12588,6 +12593,7 @@ C121E8C6274544B100023E4C /* ChannelWatcherListQuery.swift in Sources */, 4F05C0722C8832C40085B4B7 /* URLRequest+cURL.swift in Sources */, AD37D7CB2BC98A5300800D8C /* ThreadReadDTO.swift in Sources */, + AD9C92702DD4DD070013A7E6 /* SendMessageInterceptor.swift in Sources */, C121E8C7274544B100023E4C /* ChannelListQuery.swift in Sources */, 841BAA0B2BCE9B57000C73E4 /* CreatePollOptionRequestBody.swift in Sources */, C121E8C8274544B100023E4C /* UserListQuery.swift in Sources */, diff --git a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift index 6b0254ce825..8dadbb8760b 100644 --- a/Tests/StreamChatTests/Models/ChatMessage_Tests.swift +++ b/Tests/StreamChatTests/Models/ChatMessage_Tests.swift @@ -436,4 +436,154 @@ final class ChatMessage_Tests: XCTestCase { XCTAssertEqual(partialReplacement.extraData, [:]) XCTAssertEqual(partialReplacement.allAttachments, []) } + + func test_replacing_allParameters() { + // Create a mock message with initial values + let originalMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Original text", + type: .regular, + command: "original-command", + arguments: "original-arguments", + extraData: ["original": .string("data")], + translations: [.english: "Original text"], + originalLanguage: .french, + moderationsDetails: nil, + attachments: [ + .dummy(id: .init(cid: .unique, messageId: .unique, index: 0)) + ], + localState: .pendingSend + ) + + // Test replacing all available fields + let allFieldsReplaced = originalMessage.replacing( + text: "New text", + type: .reply, + state: .sending, + command: "new-command", + arguments: "new-arguments", + attachments: [ + .dummy(id: .init(cid: .unique, messageId: .unique, index: 99)) + ], + translations: [.spanish: "Texto nuevo"], + originalLanguage: .german, + moderationDetails: nil, + extraData: ["new": .string("data")] + ) + + // Verify all replaced fields + XCTAssertEqual(allFieldsReplaced.text, "New text") + XCTAssertEqual(allFieldsReplaced.type, .reply) + XCTAssertEqual(allFieldsReplaced.localState, .sending) + XCTAssertEqual(allFieldsReplaced.command, "new-command") + XCTAssertEqual(allFieldsReplaced.arguments, "new-arguments") + XCTAssertEqual(allFieldsReplaced.extraData["new"]?.stringValue, "data") + XCTAssertEqual(allFieldsReplaced.allAttachments.first?.id.index, 99) + XCTAssertEqual(allFieldsReplaced.translations?[.spanish], "Texto nuevo") + XCTAssertEqual(allFieldsReplaced.originalLanguage, .german) + XCTAssertNil(allFieldsReplaced.moderationDetails) + + // Verify fields that should remain unchanged + XCTAssertEqual(allFieldsReplaced.id, originalMessage.id) + XCTAssertEqual(allFieldsReplaced.cid, originalMessage.cid) + XCTAssertEqual(allFieldsReplaced.author, originalMessage.author) + XCTAssertEqual(allFieldsReplaced.createdAt, originalMessage.createdAt) + + // Test replacing with nil values (should clear the fields) + let nilValuesReplacement = originalMessage.replacing( + text: nil, + type: .regular, + state: nil, + command: nil, + arguments: nil, + attachments: nil, + translations: nil, + originalLanguage: nil, + moderationDetails: nil, + extraData: nil + ) + + // Verify fields are cleared + XCTAssertEqual(nilValuesReplacement.text, "") + XCTAssertEqual(nilValuesReplacement.command, nil) + XCTAssertEqual(nilValuesReplacement.arguments, nil) + XCTAssertEqual(nilValuesReplacement.extraData, [:]) + XCTAssertEqual(nilValuesReplacement.allAttachments, []) + XCTAssertEqual(nilValuesReplacement.translations, nil) + XCTAssertEqual(nilValuesReplacement.originalLanguage, nil) + XCTAssertNil(nilValuesReplacement.moderationDetails) + } + + func test_changing() { + // Create a mock message with initial values + let originalMessage = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Original text", + type: .regular, + command: "original-command", + arguments: "original-arguments", + extraData: ["original": .string("data")], + translations: [.english: "Original text"], + originalLanguage: .french, + moderationsDetails: nil, + attachments: [ + .dummy(id: .init(cid: .unique, messageId: .unique, index: 0)) + ] + ) + + // Test changing only some fields + let partiallyChangedMessage = originalMessage.changing( + text: "New text", + type: .reply, + command: "new-command" + ) + + // Verify changed fields + XCTAssertEqual(partiallyChangedMessage.text, "New text") + XCTAssertEqual(partiallyChangedMessage.type, .reply) + XCTAssertEqual(partiallyChangedMessage.command, "new-command") + + // Verify unchanged fields + XCTAssertEqual(partiallyChangedMessage.arguments, originalMessage.arguments) + XCTAssertEqual(partiallyChangedMessage.extraData, originalMessage.extraData) + XCTAssertEqual(partiallyChangedMessage.allAttachments, originalMessage.allAttachments) + XCTAssertEqual(partiallyChangedMessage.translations, originalMessage.translations) + XCTAssertEqual(partiallyChangedMessage.originalLanguage, originalMessage.originalLanguage) + XCTAssertNil(partiallyChangedMessage.moderationDetails) + + // Test changing all available fields + let translations: [TranslationLanguage: String] = [.spanish: "Texto nuevo"] + let fullyChangedMessage = originalMessage.changing( + text: "New text", + type: .reply, + state: .sending, + command: "new-command", + arguments: "new-arguments", + attachments: [ + .dummy(id: .init(cid: .unique, messageId: .unique, index: 99)) + ], + translations: translations, + originalLanguage: .german, + moderationDetails: nil, + extraData: ["new": .string("data")] + ) + + // Verify all changed fields + XCTAssertEqual(fullyChangedMessage.text, "New text") + XCTAssertEqual(fullyChangedMessage.type, .reply) + XCTAssertEqual(fullyChangedMessage.localState, .sending) + XCTAssertEqual(fullyChangedMessage.command, "new-command") + XCTAssertEqual(fullyChangedMessage.arguments, "new-arguments") + XCTAssertEqual(fullyChangedMessage.extraData["new"]?.stringValue, "data") + XCTAssertEqual(fullyChangedMessage.allAttachments.first?.id.index, 99) + XCTAssertEqual(fullyChangedMessage.translations?[.spanish], "Texto nuevo") + XCTAssertEqual(fullyChangedMessage.originalLanguage, .german) + + // Verify key identifiers remain unchanged + XCTAssertEqual(fullyChangedMessage.id, originalMessage.id) + XCTAssertEqual(fullyChangedMessage.cid, originalMessage.cid) + XCTAssertEqual(fullyChangedMessage.author, originalMessage.author) + } } diff --git a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift index ec9f419ad11..99f62668d79 100644 --- a/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift +++ b/Tests/StreamChatTests/Repositories/MessageRepository_Tests.swift @@ -700,6 +700,158 @@ final class MessageRepositoryTests: XCTestCase { XCTAssertEqual(reactionState, .deletingFailed) XCTAssertEqual(reactionScore, 10) } + + // MARK: - Interceptor Tests + + final class MockSendMessageInterceptor: SendMessageInterceptor { + var sendMessageCalled = false + var receivedMessage: ChatMessage? + var receivedOptions: SendMessageOptions? + var completionResult: Result = .success(.init(message: .mock())) + + func sendMessage( + _ message: ChatMessage, + options: SendMessageOptions, + completion: @escaping ((Result) -> Void) + ) { + sendMessageCalled = true + receivedMessage = message + receivedOptions = options + completion(completionResult) + } + + func simulateSuccess(message: ChatMessage) { + completionResult = .success(.init(message: message)) + } + + func simulateFailure(error: Error) { + completionResult = .failure(error) + } + } + + func test_setInterceptor_storesInterceptor() { + // Given + let interceptor = MockSendMessageInterceptor() + + // When + repository.setInterceptor(interceptor) + + // Then + XCTAssertNotNil(repository.interceptor) + } + + func test_sendMessage_withInterceptor_usesInterceptor() throws { + // Given + let id = MessageId.unique + let interceptor = MockSendMessageInterceptor() + repository.setInterceptor(interceptor) + try createMessage(id: id, localState: .pendingSend) + + // When + interceptor.simulateSuccess(message: .mock(id: id)) + let exp = expectation(description: "Send Message completes") + repository.sendMessage(with: id) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertTrue(interceptor.sendMessageCalled) + XCTAssertEqual(interceptor.receivedMessage?.id, id) + XCTAssertFalse(interceptor.receivedOptions?.skipPush ?? true) + XCTAssertFalse(interceptor.receivedOptions?.skipEnrichUrl ?? true) + XCTAssertNil(apiClient.request_endpoint) // API client should not be called + } + + func test_sendMessage_withInterceptor_passesSkipOptions() throws { + // Given + let id = MessageId.unique + let interceptor = MockSendMessageInterceptor() + repository.setInterceptor(interceptor) + try createMessage(id: id, localState: .pendingSend, skipPush: true, skipEnrichUrl: true) + + // When + interceptor.simulateSuccess(message: .mock(id: id)) + let exp = expectation(description: "Send Message completes") + repository.sendMessage(with: id) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + // Then + XCTAssertTrue(interceptor.sendMessageCalled) + XCTAssertTrue(interceptor.receivedOptions?.skipPush ?? false) + XCTAssertTrue(interceptor.receivedOptions?.skipEnrichUrl ?? false) + } + + func test_sendMessage_withInterceptor_whenLocalStatePendingSend_shouldMarkStateSending() throws { + // Given + let id = MessageId.unique + let interceptor = MockSendMessageInterceptor() + repository.setInterceptor(interceptor) + try createMessage(id: id, localState: .pendingSend) + + // When + interceptor.simulateSuccess(message: .mock(id: id, localState: .pendingSend)) + let exp = expectation(description: "Send Message completes") + repository.sendMessage(with: id) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + // Then + var messageState: LocalMessageState? + try database.writeSynchronously { session in + messageState = session.message(id: id)?.localMessageState + } + XCTAssertEqual(messageState, .sending) + } + + func test_sendMessage_withInterceptor_whenLocalStateNil_shouldMarkStatePublished() throws { + // Given + let id = MessageId.unique + let interceptor = MockSendMessageInterceptor() + repository.setInterceptor(interceptor) + try createMessage(id: id, localState: .pendingSend) + + // When + interceptor.simulateSuccess(message: .mock(id: id, localState: nil)) + let exp = expectation(description: "Send Message completes") + repository.sendMessage(with: id) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + // Then + var messageState: LocalMessageState? + try database.writeSynchronously { session in + messageState = session.message(id: id)?.localMessageState + } + XCTAssertNil(messageState) + } + + func test_sendMessage_withInterceptor_whenFailure_shouldMarkStateSendingFailed() throws { + // Given + let id = MessageId.unique + let interceptor = MockSendMessageInterceptor() + repository.setInterceptor(interceptor) + try createMessage(id: id, localState: .pendingSend) + + // When + interceptor.simulateFailure(error: TestError()) + let exp = expectation(description: "Send Message completes") + repository.sendMessage(with: id) { _ in + exp.fulfill() + } + wait(for: [exp], timeout: defaultTimeout) + + // Then + var messageState: LocalMessageState? + try database.writeSynchronously { session in + messageState = session.message(id: id)?.localMessageState + } + XCTAssertEqual(messageState, .sendingFailed) + } } extension MessageRepositoryTests {