diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index ebfba2f3..d124c476 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -11,6 +11,7 @@ internal final class DefaultRoomReactions: RoomReactions { try await implementation.send(params: params) } + @discardableResult internal func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionProtocol { implementation.subscribe(callback) } diff --git a/Sources/AblyChat/InternalError.swift b/Sources/AblyChat/InternalError.swift index 8f956fd9..08833adf 100644 --- a/Sources/AblyChat/InternalError.swift +++ b/Sources/AblyChat/InternalError.swift @@ -31,6 +31,12 @@ internal enum InternalError: Error { } } +extension InternalError: Equatable { + internal static func == (lhs: InternalError, rhs: InternalError) -> Bool { + lhs.toARTErrorInfo() == rhs.toARTErrorInfo() + } +} + internal extension ARTErrorInfo { func toInternalError() -> InternalError { .errorInfo(self) diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift index 94d88081..1dc0fb06 100644 --- a/Tests/AblyChatTests/DefaultMessagesTests.swift +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -4,119 +4,381 @@ import Testing @MainActor struct DefaultMessagesTests { - @Test - func subscribe_whenChannelIsAttachedAndNoChannelSerial_throwsError() async throws { - // roomId and clientId values are arbitrary + // MARK: CHA-M3 + // @spec CHA-M3a + // @spec CHA-M3b + // @spec CHA-M3f + @Test + func clientMaySendMessageViaRESTChatAPI() async throws { // Given - let realtime = MockRealtime() + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successSendMessage + } let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel(initialState: .attached) let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + // When + _ = try await defaultMessages.send(params: .init(text: "hey")) + + // Then + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "POST", "path": "/chat/v3/rooms/basketball/messages", "body": ["text": "hey"], "params": [:], "headers": [:]] + ) + ) + } + + // @spec CHA-M3e + @Test + func errorShouldBeThrownIfErrorIsReturnedFromRESTChatAPI() async throws { + // Given + let realtime = MockRealtime { @Sendable () throws(ARTErrorInfo) in + throw ARTErrorInfo(domain: "SomeDomain", code: 123) + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel() + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + // Then // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released let doIt = { - // When - try await defaultMessages.subscribe() + _ = try await defaultMessages.send(params: .init(text: "hey")) } - await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: { + await #expect { try await doIt() - }) + } throws: { error in + error as? ARTErrorInfo == ARTErrorInfo(domain: "SomeDomain", code: 123) + } } + // @spec CHA-M5a @Test - func get_getMessagesIsExposedFromChatAPI() async throws { - // Message response of succcess with no items, and roomId are arbitrary + func subscriptionPointIsChannelSerialWhenUnderlyingRealtimeChannelIsAttached() async throws { + // Given + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successSendMessageWithNoItems + } + let channelSerial = "123" + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: nil, channelSerial: channelSerial), + initialState: .attached + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let subscription = try await defaultMessages.subscribe() + _ = try await subscription.getPreviousMessages(params: .init()) + // Then: subscription point is the current channelSerial of the realtime channel + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(channelSerial)"], "headers": [:]] + ) + ) + } + + // @spec CHA-M5b + @Test + func subscriptionPointIsAttachSerialWhenUnderlyingRealtimeChannelIsNotAttached() async throws { // Given - let realtime = MockRealtime { MockHTTPPaginatedResponse.successGetMessagesWithNoItems } + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successSendMessageWithNoItems + } + let attachSerial = "123" let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: nil), + initialState: .attaching, + stateChangeToEmitForListener: ARTChannelStateChange(current: .attached, previous: .attaching, event: .attached, reason: nil) + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + + // When: subscription is added when the underlying realtime channel is ATTACHING + let subscription = try await defaultMessages.subscribe() + _ = try await subscription.getPreviousMessages(params: .init()) + + // Then: subscription point is the attachSerial of the realtime channel + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(attachSerial)"], "headers": [:]] + ) + ) + } + + // @spec CHA-M5c + @Test + func whenChannelReentersATTACHEDWithResumedFalseThenSubscriptionPointResetsToAttachSerial() async throws { + // Given + let attachSerial = "attach123" + let channelSerial = "channel456" + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successSendMessageWithNoItems + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: channelSerial), + initialState: .attached + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + + // When: subscription is added when the underlying realtime channel is ATTACHING + let subscription = try await defaultMessages.subscribe() + _ = try await subscription.getPreviousMessages(params: .init()) + + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(channelSerial)"], "headers": [:]] + ) + ) + + channel.emitEvent( + ARTChannelStateChange(current: .detached, previous: .attached, event: .detached, reason: ARTErrorInfo(domain: "Some", code: 123)) + ) + + channel.emitEvent( + ARTChannelStateChange(current: .attached, previous: .detached, event: .attached, reason: nil, resumed: false) + ) + + _ = try await subscription.getPreviousMessages(params: .init()) + + // Then: subscription point is the attachSerial of the realtime channel + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(attachSerial)"], "headers": [:]] + ) + ) + } + + // @spec CHA-M5d + @Test + func whenChannelUPDATEReceivedWithResumedFalseThenSubscriptionPointResetsToAttachSerial() async throws { + // Given + let attachSerial = "attach123" + let channelSerial = "channel456" + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successSendMessageWithNoItems + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: channelSerial), + initialState: .attached + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + + // When: subscription is added when the underlying realtime channel is ATTACHING + let subscription = try await defaultMessages.subscribe() + _ = try await subscription.getPreviousMessages(params: .init()) + + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(channelSerial)"], "headers": [:]] + ) + ) + + channel.emitEvent( + ARTChannelStateChange(current: .attached, previous: .attached, event: .update, reason: nil, resumed: false) + ) + + _ = try await subscription.getPreviousMessages(params: .init()) + + // Then: subscription point is the attachSerial of the realtime channel + #expect(realtime.callRecorder.hasRecord( + matching: "request(_:path:params:body:headers:)", + arguments: ["method": "GET", "path": "/chat/v3/rooms/basketball/messages", "body": [:], "params": ["direction": "backwards", "fromSerial": "\(attachSerial)"], "headers": [:]] + ) + ) + } + + // @spec CHA-M5f + // @spec CHA-M5g + // @spec CHA-M5h + @available(iOS 16.0.0, tvOS 16.0.0, *) // To avoid "Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer" compile error + @Test + func subscriptionGetPreviousMessagesAcceptsStandardHistoryQueryOptionsExceptForDirection() async throws { + // Given + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successGetMessagesWithItems + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: nil, channelSerial: "123"), + initialState: .attached + ) let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + // When: subscription is added when the underlying realtime channel is ATTACHED + let subscription = try await defaultMessages.subscribe() + let paginatedResult = try await subscription.getPreviousMessages(params: .init(orderBy: .oldestFirst)) // CHA-M5f, try to set unsupported direction + + let requestParams = try #require(realtime.requestArguments.first?.params) + + // Then + + // CHA-M5g: the subscription point must be additionally specified (internally, by us) in the "fromSerial" query parameter + #expect(requestParams["fromSerial"] == "123") + + // CHA-M5f: method must accept any of the standard history query options, except for direction, which must always be backwards (`OrderBy.newestFirst` is equivalent to "backwards", see `getBeforeSubscriptionStart` func) + #expect(requestParams["direction"] == "backwards") + + // CHA-M5h: The method must return a standard PaginatedResult + #expect(paginatedResult.items.count == 2) + #expect(paginatedResult.hasNext == true) + + // CHA-M5h: which can be further inspected to paginate across results + let nextPage = try #require(await paginatedResult.next) + #expect(nextPage.hasNext == false) + } + + // @spec CHA-M5i + @Test + func subscriptionGetPreviousMessagesThrowsErrorInfoInCaseOfServerError() async throws { + // Given + let artError = ARTErrorInfo.create(withCode: 50000, message: "Internal server error") + let realtime = MockRealtime { @Sendable () throws(ARTErrorInfo) in + throw artError + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: nil, channelSerial: "123"), + initialState: .attached + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + + // When + let subscription = try await defaultMessages.subscribe() + // Then // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released let doIt = { - // When - // `_ =` is required to avoid needing iOS 16 to run this test - // Error: Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer - _ = try await defaultMessages.get(options: .init()) + _ = try await subscription.getPreviousMessages(params: .init()) } - await #expect(throws: Never.self, performing: { + // Then + await #expect { try await doIt() - }) + } throws: { error in + error as? ARTErrorInfo == artError + } } + // @spec CHA-M6a + @available(iOS 16.0.0, tvOS 16.0.0, *) // To avoid "Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer" compile error @Test - func subscribe_returnsSubscription() async throws { - // all setup values here are arbitrary - + func getMessagesAcceptsStandardHistoryQueryOptions() async throws { // Given - let realtime = MockRealtime { MockHTTPPaginatedResponse.successGetMessagesWithNoItems } + let realtime = MockRealtime { + MockHTTPPaginatedResponse.successGetMessagesWithItems + } let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel( - properties: .init( - attachSerial: "001", - channelSerial: "001" - ), + properties: ARTChannelProperties(attachSerial: nil, channelSerial: "123"), initialState: .attached ) let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) - let subscription = try await defaultMessages.subscribe() - let expectedPaginatedResult = PaginatedResultWrapper( - paginatedResponse: MockHTTPPaginatedResponse.successGetMessagesWithNoItems, - items: [] - ) // When - let previousMessages = try await subscription.getPreviousMessages(params: .init()) as? PaginatedResultWrapper + let paginatedResult = try await defaultMessages.get(options: .init()) // Then - #expect(previousMessages == expectedPaginatedResult) + // CHA-M6a: The method return a PaginatedResult containing messages + #expect(paginatedResult.items.count == 2) + #expect(paginatedResult.hasNext == true) + + // Then + // CHA-M6a: which can then be paginated through + let nextPage = try #require(await paginatedResult.next) + #expect(nextPage.hasNext == false) + } + + // @spec CHA-M6b + @Test + func getMessagesThrowsErrorInfoInCaseOfServerError() async throws { + // Given + let artError = ARTErrorInfo.create(withCode: 50000, message: "Internal server error") + let realtime = MockRealtime { @Sendable () throws(ARTErrorInfo) in + throw artError + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel( + properties: ARTChannelProperties(attachSerial: nil, channelSerial: "123"), + initialState: .attached + ) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + + // When + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + _ = try await defaultMessages.get(options: .init()) + } + // Then + await #expect { + try await doIt() + } throws: { error in + error as? ARTErrorInfo == artError + } } + // CHA-M4d is currently untestable due to not subscribing to those events on lower level + // @spec CHA-M4a + // @spec CHA-M4m + // @spec CHA-M4b @Test - func subscribe_extractsHeadersFromChannelMessage() async throws { + func subscriptionCanBeRegisteredToReceiveIncomingMessages() async throws { // Given let realtime = MockRealtime() let chatAPI = ChatAPI(realtime: realtime) + func generateMessage(serial: String, numberKey: Int, stringKey: String) -> ARTMessage { + let message = ARTMessage() + message.action = .create // arbitrary + message.serial = serial // arbitrary + message.clientId = "" // arbitrary + message.data = [ + "text": "", // arbitrary + "metadata": ["numberKey": numberKey, "stringKey": stringKey], + ] + message.extras = [ + "headers": ["numberKey": numberKey, "stringKey": stringKey], + ] as any ARTJsonCompatible + message.version = "0" + return message + } + let channel = MockRealtimeChannel( properties: .init( attachSerial: "001", channelSerial: "001" ), initialState: .attached, - messageToEmitOnSubscribe: { - let message = ARTMessage() - message.action = .create // arbitrary - message.serial = "" // arbitrary - message.clientId = "" // arbitrary - message.data = [ - "text": "", // arbitrary - ] - message.extras = [ - "headers": ["numberKey": 10, "stringKey": "hello"], - ] as ARTJsonCompatible - message.operation = nil - message.version = "" - - return message - }() + messageToEmitOnSubscribe: generateMessage(serial: "1", numberKey: 10, stringKey: "hello") ) let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + // Notes: + // When using `AsyncSequence` variant of `subscribe` it gives a compile error (Xcode 16.2): "sending main actor-isolated value of type '(MessageSubscription.Element) async -> Bool' (aka '(Message) async -> Bool') with later accesses to nonisolated context risks causing data races". So I used callback one. + // When the expectation are not met test crashes with "Fatal error: Internal inconsistency: No test reporter for test AblyChatTests.DefaultMessagesTests/subscriptionCanBeRegisteredToReceiveIncomingMessages()/DefaultMessagesTests.swift:326:6 and test case argumentIDs: Optional([])". I guess this could be avoided by using `withCheckedContinuation`, but it doesn't accept async functions in its closure body (await subscribe). + // When - let messagesSubscription = try await defaultMessages.subscribe() + let subscriptionHandle = try await defaultMessages.subscribe { message in + // Then + #expect(message.headers == ["numberKey": .number(10), "stringKey": .string("hello")]) + #expect(message.metadata == ["numberKey": .number(10), "stringKey": .string("hello")]) + } - // Then - let receivedMessage = try #require(await messagesSubscription.first { @Sendable _ in true }) - #expect(receivedMessage.headers == ["numberKey": .number(10), "stringKey": .string("hello")]) + // CHA-M4b + subscriptionHandle.unsubscribe() + + // will not be received and expectations above will not fail + channel.simulateIncomingMessage( + generateMessage(serial: "2", numberKey: 11, stringKey: "hello there"), + for: RealtimeMessageName.chatMessage.rawValue + ) } + // Wrong name, should be CHA-M4k + // @spec CHA-M5k @Test - func subscribe_extractsMetadataFromChannelMessage() async throws { + func malformedRealtimeEventsShallNotBeEmittedToSubscribers() async throws { // Given let realtime = MockRealtime() let chatAPI = ChatAPI(realtime: realtime) @@ -130,26 +392,28 @@ struct DefaultMessagesTests { messageToEmitOnSubscribe: { let message = ARTMessage() message.action = .create // arbitrary - message.serial = "" // arbitrary + message.serial = "123" // arbitrary message.clientId = "" // arbitrary message.data = [ "text": "", // arbitrary - "metadata": ["numberKey": 10, "stringKey": "hello"], ] - message.extras = [:] as ARTJsonCompatible - message.operation = nil - message.version = "" - + message.extras = [:] as any ARTJsonCompatible + message.version = "0" return message }() ) let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // When - let messagesSubscription = try await defaultMessages.subscribe() + _ = try await defaultMessages.subscribe { message in + // Then + #expect(message.serial == "123") + } - // Then - let receivedMessage = try #require(await messagesSubscription.first { @Sendable _ in true }) - #expect(receivedMessage.metadata == ["numberKey": .number(10), "stringKey": .string("hello")]) + // will not be received and expectations above will not fail + channel.simulateIncomingMessage( + ARTMessage(), // malformed message + for: RealtimeMessageName.chatMessage.rawValue + ) } } diff --git a/Tests/AblyChatTests/DefaultPresenceTests.swift b/Tests/AblyChatTests/DefaultPresenceTests.swift index 5094f1a7..c1bc9890 100644 --- a/Tests/AblyChatTests/DefaultPresenceTests.swift +++ b/Tests/AblyChatTests/DefaultPresenceTests.swift @@ -485,6 +485,4 @@ struct DefaultPresenceTests { #expect(leaveEvent.action == .leave) #expect(leaveEvent.clientID == "client1") } - - // @specNotApplicable CHA-PR7c - Untestable due to `AsyncSequence` subscription used which is removed once the object is removed from memory. } diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift index 8350df36..133933cb 100644 --- a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -29,20 +29,73 @@ struct DefaultRoomReactionsTests { #expect(channel.publishedMessages.last?.extras == ["headers": ["someHeadersKey": "someHeadersValue"], "ephemeral": true]) } - // @spec CHA-ER4 + // @spec CHA-ER4a + // @spec CHA-ER4b @Test - func subscribe_returnsSubscription() async throws { - // all setup values here are arbitrary + func subscriptionCanBeRegisteredToReceiveReactionEvents() async throws { // Given - let channel = MockRealtimeChannel(name: "basketball::$chat") + func generateMessage(serial: String, reactionType: String) -> ARTMessage { + let message = ARTMessage() + message.action = .create // arbitrary + message.serial = serial // arbitrary + message.clientId = "" // arbitrary + message.data = [ + "type": reactionType, + ] + message.version = "0" + return message + } - // When + let channel = MockRealtimeChannel( + messageToEmitOnSubscribe: generateMessage(serial: "1", reactionType: ":like:") + ) let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) // When - let subscription: SubscriptionAsyncSequence? = defaultRoomReactions.subscribe() + let subscription = defaultRoomReactions.subscribe { reaction in + // Then + #expect(reaction.type == ":like:") + } - // Then - #expect(subscription != nil) + // CHA-ER4b + subscription.unsubscribe() + + // will not be received and expectations above will not fail + channel.simulateIncomingMessage( + generateMessage(serial: "2", reactionType: ":dislike:"), + for: RoomReactionEvents.reaction.rawValue + ) + } + + // CHA-ER4c is currently untestable due to not subscribing to those events on lower level + // @spec CHA-ER4d + @Test + func malformedRealtimeEventsShallNotBeEmittedToSubscribers() async throws { + // Given + let channel = MockRealtimeChannel( + messageToEmitOnSubscribe: { + let message = ARTMessage() + message.action = .create // arbitrary + message.serial = "123" // arbitrary + message.clientId = "" // arbitrary + message.data = [ + "type": ":like:", + ] + message.extras = [:] as any ARTJsonCompatible + message.version = "0" + return message + }() + ) + let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + + // When + defaultRoomReactions.subscribe { reaction in + #expect(reaction.type == ":like:") + } + // will not be received and expectations above will not fail + channel.simulateIncomingMessage( + ARTMessage(), // malformed message + for: RealtimeMessageName.chatMessage.rawValue + ) } } diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 25cba3b0..e9aa5d84 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -144,9 +144,9 @@ class MockMethodCallRecorder: @unchecked Sendable { private var records = [CallRecord]() func addRecord(signature: String, arguments: [String: Any?]) { // chose Any to not to deal with types in the test's code - mutex.withLock { - records.append(CallRecord(signature: signature, arguments: arguments.map { MethodArgument(name: $0.key, value: $0.value) })) - } + mutex.lock() + records.append(CallRecord(signature: signature, arguments: arguments.map { MethodArgument(name: $0.key, value: $0.value) })) + mutex.unlock() } func hasRecord(matching signature: String, arguments: [String: Any]) -> Bool { diff --git a/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift index 6df9330b..86a36b7e 100644 --- a/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift +++ b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift @@ -5,20 +5,17 @@ final class MockHTTPPaginatedResponse: ARTHTTPPaginatedResponse, @unchecked Send private let _statusCode: Int private let _headers: [String: String] private let _hasNext: Bool - private let _isLast: Bool init( items: [NSDictionary], statusCode: Int = 200, headers: [String: String] = [:], - hasNext: Bool = false, - isLast: Bool = true + hasNext: Bool = false ) { _items = items _statusCode = statusCode _headers = headers _hasNext = hasNext - _isLast = isLast super.init() } @@ -43,7 +40,7 @@ final class MockHTTPPaginatedResponse: ARTHTTPPaginatedResponse, @unchecked Send } override var isLast: Bool { - _isLast + !_hasNext } override func next(_ callback: @escaping ARTHTTPPaginatedCallback) { @@ -118,7 +115,8 @@ extension MockHTTPPaginatedResponse { ], ], statusCode: 200, - headers: [:] + headers: [:], + hasNext: true ) } @@ -128,21 +126,29 @@ extension MockHTTPPaginatedResponse { static let nextPage = MockHTTPPaginatedResponse( items: [ [ - "serial": "3446450", + "clientId": "random", + "serial": "3446458", + "action": "message.create", + "createdAt": 1_730_943_049_269, "roomId": "basketball", - "text": "previous message", + "text": "next hello", "metadata": [:], "headers": [:], + "version": "3446458", ], [ - "serial": "3446451", + "clientId": "random", + "serial": "3446459", + "action": "message.create", "roomId": "basketball", - "text": "previous response", + "text": "next hello response", "metadata": [:], "headers": [:], + "version": "3446459", ], ], statusCode: 200, - headers: [:] + headers: [:], + hasNext: false ) } diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index f876215e..7305a7e2 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -4,6 +4,8 @@ import Foundation /// A mock implementation of `InternalRealtimeClientProtocol`. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5. final class MockRealtime: InternalRealtimeClientProtocol { + let callRecorder = MockMethodCallRecorder() + let connection: MockConnection let channels: MockChannels let paginatedCallback: (@Sendable () throws(ARTErrorInfo) -> ARTHTTPPaginatedResponse)? @@ -34,6 +36,10 @@ final class MockRealtime: InternalRealtimeClientProtocol { fatalError("Paginated callback not set") } do { + callRecorder.addRecord( + signature: "request(_:path:params:body:headers:)", + arguments: ["method": method, "path": path, "params": params, "body": body == nil ? [:] : body as? [String: Any], "headers": headers] + ) return try paginatedCallback() } catch { throw error.toInternalError() diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index cc92cd3d..634cf5c9 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -11,6 +11,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { nonisolated var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } private var _state: ARTRealtimeChannelState? + private let stateChangeToEmitForListener: ARTChannelStateChange? var errorReason: ARTErrorInfo? var publishedMessages: [TestMessage] = [] @@ -28,7 +29,8 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { initialErrorReason: ARTErrorInfo? = nil, attachBehavior: AttachOrDetachBehavior? = nil, detachBehavior: AttachOrDetachBehavior? = nil, - messageToEmitOnSubscribe: ARTMessage? = nil + messageToEmitOnSubscribe: ARTMessage? = nil, + stateChangeToEmitForListener: ARTChannelStateChange? = nil ) { _name = name _state = initialState @@ -38,6 +40,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { self.messageToEmitOnSubscribe = messageToEmitOnSubscribe attachSerial = properties.attachSerial channelSerial = properties.channelSerial + self.stateChangeToEmitForListener = stateChangeToEmitForListener } var state: ARTRealtimeChannelState { @@ -134,9 +137,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { // Added the ability to emit a message whenever we want instead of just on subscribe... I didn't want to dig into what the messageToEmitOnSubscribe is too much so just if/else between the two. func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener? { if let messageToEmitOnSubscribe { - Task { - callback(messageToEmitOnSubscribe) - } + callback(messageToEmitOnSubscribe) } else { channelSubscriptions.append((name, callback)) } @@ -150,18 +151,21 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { } func unsubscribe(_: ARTEventListener?) { - // no-op; revisit if we need to test something that depends on this method actually stopping `subscribe` from emitting more events + channelSubscriptions.removeAll() // make more strict when needed } private var stateSubscriptionCallbacks: [@MainActor (ARTChannelStateChange) -> Void] = [] - func on(_: ARTChannelEvent, callback _: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { - ARTEventListener() + func on(_: ARTChannelEvent, callback: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { + stateSubscriptionCallbacks.append(callback) + return ARTEventListener() } func on(_ callback: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { stateSubscriptionCallbacks.append(callback) - + if let stateChangeToEmitForListener { + callback(stateChangeToEmitForListener) + } return ARTEventListener() }