From 6cf282106ee513b856dd93ced9bafa2869579ed5 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Fri, 28 Mar 2025 02:44:06 +0100 Subject: [PATCH 1/7] Add presence tests. --- Sources/AblyChat/RoomLifecycleManager.swift | 6 +- .../AblyChatTests/DefaultPresenceTests.swift | 487 +++++++++++++++++- .../DefaultRoomLifecycleManagerTests.swift | 24 +- Tests/AblyChatTests/Helpers/Helpers.swift | 101 ++++ .../Mocks/MockRealtimePresence.swift | 49 +- .../Mocks/MockRoomLifecycleManager.swift | 20 +- 6 files changed, 656 insertions(+), 31 deletions(-) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index e9a92dd5..3362843c 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -12,9 +12,9 @@ internal protocol RoomLifecycleManager: Sendable { /// /// Implements the checks described by CHA-PR3d, CHA-PR3e, and CHA-PR3h (and similar ones described by other functionality that performs channel presence operations). Namely: /// - /// - CHA-RL9, which is invoked by CHA-PR3d, CHA-PR10d, CHA-PR6c, CHA-T2c: If the room is in the ATTACHING status, it waits for the next room status change. If the new status is ATTACHED, it returns. Else, it throws an `ARTErrorInfo` derived from ``ChatError/roomTransitionedToInvalidStateForPresenceOperation(cause:)``. - /// - CHA-PR3e, CHA-PR10e, CHA-PR6d, CHA-T2d: If the room is in the ATTACHED status, it returns immediately. - /// - CHA-PR3h, CHA-PR10h, CHA-PR6h, CHA-T2g: If the room is in any other status, it throws an `ARTErrorInfo` derived from ``ChatError/presenceOperationRequiresRoomAttach(feature:)``. + /// - CHA-RL9, which is invoked by CHA-PR3d, CHA-PR10d, CHA-PR6c: If the room is in the ATTACHING status, it waits for the next room status change. If the new status is ATTACHED, it returns. Else, it throws an `ARTErrorInfo` derived from ``ChatError/roomTransitionedToInvalidStateForPresenceOperation(cause:)``. + /// - CHA-PR3e, CHA-PR10e, CHA-PR6d: If the room is in the ATTACHED status, it returns immediately. + /// - CHA-PR3h, CHA-PR10h, CHA-PR6h: If the room is in any other status, it throws an `ARTErrorInfo` derived from ``ChatError/presenceOperationRequiresRoomAttach(feature:)``. /// /// - Parameters: /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. diff --git a/Tests/AblyChatTests/DefaultPresenceTests.swift b/Tests/AblyChatTests/DefaultPresenceTests.swift index 94bc886f..c1bc9890 100644 --- a/Tests/AblyChatTests/DefaultPresenceTests.swift +++ b/Tests/AblyChatTests/DefaultPresenceTests.swift @@ -1,3 +1,488 @@ +import Ably +@testable import AblyChat +import Testing + struct DefaultPresenceTests { - // @specUntested CHA-PR7d - We chose to implement this failure with an idiomatic fatalError instead of throwing, but we can’t test this. + // MARK: CHA-PR3 + + // @spec CHA-PR3a + // @specOneOf(2/2) CHA-PR3e + @Test + func usersMayEnterPresence() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: MockRoomLifecycleManager(), + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + try await defaultPresence.enter(data: ["status": "Online"]) + + // Then + #expect(channel.presence.callRecorder.hasRecord( + matching: "enterClient(_:data:)", + arguments: ["name": "client1", "data": JSONValue.object(["userCustomData": ["status": "Online"]])] + ) + ) + } + + // @specOneOf(3/4) CHA-PR3d + @Test + func usersMayEnterPresenceWhileAttaching() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + try await defaultPresence.enter() + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // @specOneOf(4/4) CHA-PR3d + @Test + func usersMayEnterPresenceWhileAttachingWithFailure() async throws { + // Given + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let error = ARTErrorInfo(chatError: .roomTransitionedToInvalidStateForPresenceOperation(cause: attachError)) + + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + // When + try await defaultPresence.enter() + } + await #expect { + try await doIt() + } /* Then */ throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 500), cause: attachError) + } + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // @specOneOf(2/2) CHA-PR3h + @Test + func failToEnterPresenceWhenRoomInInvalidState() async throws { + // Given + let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // Then + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + _ = try await defaultPresence.enter() + } + await #expect { + try await doIt() + } throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 400)) + } + } + + // MARK: CHA-PR10 + + // @spec CHA-PR10a + // @specOneOf(2/2) CHA-PR10e + @Test + func usersMayUpdatePresence() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + // When + try await defaultPresence.update(data: ["status": "Online"]) + + // Then + #expect(channel.presence.callRecorder.hasRecord( + matching: "update(_:)", + arguments: ["data": JSONValue.object(["userCustomData": ["status": "Online"]])] + ) + ) + } + + // @specOneOf(3/4) CHA-PR10d + @Test + func usersMayUpdatePresenceWhileAttaching() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + try await defaultPresence.update() + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // @specOneOf(4/4) CHA-PR10d + @Test + func usersMayUpdatePresenceWhileAttachingWithFailure() async throws { + // Given + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let error = ARTErrorInfo(chatError: .roomTransitionedToInvalidStateForPresenceOperation(cause: attachError)) + + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + // When + try await defaultPresence.update() + } + await #expect { + try await doIt() + } /* Then */ throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 500), cause: attachError) + } + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // @specOneOf(2/2) CHA-PR10h + @Test + func failToUpdatePresenceWhenRoomInInvalidState() async throws { + // Given + let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // Then + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + _ = try await defaultPresence.update() + } + await #expect { + try await doIt() + } throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 400)) + } + } + + // MARK: CHA-PR4 + + // @spec CHA-PR4a + @Test + func usersMayLeavePresence() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + try await defaultPresence.leave() + + // Then + #expect(channel.presence.callRecorder.hasRecord( + matching: "leave(_:)", + arguments: ["data": JSONValue.object([:])] + ) + ) + } + + // MARK: CHA-PR5 + + // @spec CHA-PR5 + @Test + func ifUserIsPresent() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + _ = try await defaultPresence.isUserPresent(clientID: "client1") + + // Then + #expect(channel.presence.callRecorder.hasRecord( + matching: "get(_:)", + arguments: ["query": "\(ARTRealtimePresenceQuery(clientId: "client1", connectionId: "").callRecorderDescription)"] + ) + ) + } + + // MARK: CHA-PR6 + + // @spec CHA-PR6 + // @specOneOf(2/2) CHA-PR6d + @Test + func retrieveAllTheMembersOfThePresenceSet() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + _ = try await defaultPresence.get() + + // Then + #expect(channel.presence.callRecorder.hasRecord( + matching: "get()", + arguments: [:] + ) + ) + } + + // @specOneOf(2/2) CHA-PR6h + @Test + func failToRetrieveAllTheMembersOfThePresenceSetWhenRoomInInvalidState() async throws { + // Given + let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // Then + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + _ = try await defaultPresence.get() + } + await #expect { + try await doIt() + } throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 400)) + } + } + + // @specOneOf(3/4) CHA-PR6c + @Test + func retrieveAllTheMembersOfThePresenceSetWhileAttaching() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // When + _ = try await defaultPresence.get() + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // @specOneOf(4/4) CHA-PR6c + @Test + func retrieveAllTheMembersOfThePresenceSetWhileAttachingWithFailure() async throws { + // Given + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let error = ARTErrorInfo(chatError: .roomTransitionedToInvalidStateForPresenceOperation(cause: attachError)) + + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager(resultOfWaitToBeAbleToPerformPresenceOperations: .failure(error)) + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released + let doIt = { + // When + try await defaultPresence.get() + } + await #expect { + try await doIt() + } /* Then */ throws: { error in + isChatError(error, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 500), cause: attachError) + } + + // Then + #expect(roomLifecycleManager.callRecorder.hasRecord( + matching: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "presence"] + ) + ) + } + + // MARK: CHA-PR7 + + // @spec CHA-PR7a + // @spec CHA-PR7b + @Test + func usersMaySubscribeToAllPresenceEvents() async throws { + // Given + let channel = await MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let logger = TestLogger() + let roomLifecycleManager = await MockRoomLifecycleManager() + let defaultPresence = await DefaultPresence( + channel: channel, + roomLifecycleManager: roomLifecycleManager, + roomID: "basketball", + clientID: "client1", + logger: logger, + options: .init() + ) + + // Given + let subscription = await defaultPresence.subscribe(events: .all) // CHA-PR7a and CHA-PR7b since `all` is just a selection of all events + + // When + subscription.emit(PresenceEvent(action: .present, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let presentEvent = try #require(await subscription.first { _ in true }) + #expect(presentEvent.action == .present) + #expect(presentEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .enter, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let enterEvent = try #require(await subscription.first { _ in true }) + #expect(enterEvent.action == .enter) + #expect(enterEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .update, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let updateEvent = try #require(await subscription.first { _ in true }) + #expect(updateEvent.action == .update) + #expect(updateEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .leave, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let leaveEvent = try #require(await subscription.first { _ in true }) + #expect(leaveEvent.action == .leave) + #expect(leaveEvent.clientID == "client1") + } } diff --git a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift index 4cca02f8..40029e2c 100644 --- a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift @@ -942,9 +942,9 @@ struct DefaultRoomLifecycleManagerTests { // @specOneOf(1/2) CHA-RL9a // @spec CHA-RL9b // - // @specPartial CHA-PR3d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR10d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR6c - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented + // @specOneOf(1/4) CHA-PR3d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(1/4) CHA-PR10d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(1/4) CHA-PR6c - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. @Test func waitToBeAbleToPerformPresenceOperations_whenAttaching_whenTransitionsToAttached() async throws { // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status @@ -981,9 +981,9 @@ struct DefaultRoomLifecycleManagerTests { // @specOneOf(2/2) CHA-RL9a // @spec CHA-RL9c // - // @specPartial CHA-PR3d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR10d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR6c - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented + // @specOneOf(2/4) CHA-PR3d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(2/4) CHA-PR10d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(2/4) CHA-PR6c - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. @Test func waitToBeAbleToPerformPresenceOperations_whenAttaching_whenTransitionsToNonAttachedStatus() async throws { // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status @@ -1026,9 +1026,9 @@ struct DefaultRoomLifecycleManagerTests { #expect(isChatError(caughtError, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 500), cause: expectedCause)) } - // @specPartial CHA-PR3e - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR10e - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR6d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. TODO change this to a specOneOf once the feature is implemented + // @specOneOf(1/2) CHA-PR3e - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(1/2) CHA-PR10e - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. + // @specOneOf(1/2) CHA-PR6d - Tests the wait described in the spec point, but not that the feature actually performs this wait nor the side effect. @Test func waitToBeAbleToPerformPresenceOperations_whenAttached() async throws { // Given: A DefaultRoomLifecycleManager in the ATTACHED status @@ -1041,9 +1041,9 @@ struct DefaultRoomLifecycleManagerTests { try await manager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .messages /* arbitrary */ ) } - // @specPartial CHA-PR3h - Tests the wait described in the spec point, but not that the feature actually performs this wait. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR10h - Tests the wait described in the spec point, but not that the feature actually performs this wait. TODO change this to a specOneOf once the feature is implemented - // @specPartial CHA-PR6h - Tests the wait described in the spec point, but not that the feature actually performs this wait. TODO change this to a specOneOf once the feature is implemented + // @specOneOf(1/2) CHA-PR3h - Tests the wait described in the spec point, but not that the feature actually performs this wait. + // @specOneOf(1/2) CHA-PR10h - Tests the wait described in the spec point, but not that the feature actually performs this wait. + // @specOneOf(1/2) CHA-PR6h - Tests the wait described in the spec point, but not that the feature actually performs this wait. @Test func waitToBeAbleToPerformPresenceOperations_whenAnyOtherStatus() async throws { // Given: A DefaultRoomLifecycleManager in a status other than ATTACHING or ATTACHED diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index b0ec8b1c..f69df1c4 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -75,3 +75,104 @@ func isInternalErrorWithCase(_ error: any Error, _ expectedCase: InternalError.C false } } + +extension ARTPresenceMessage { + convenience init(clientId: String, data: Any? = [:], timestamp: Date = Date()) { + self.init() + self.clientId = clientId + self.data = data + self.timestamp = timestamp + } +} + +extension [PresenceEventType] { + static let all = [ + PresenceEventType.present, + PresenceEventType.enter, + PresenceEventType.leave, + PresenceEventType.update, + ] +} + +/// Compares Any to another Any which is unavailable by default in swift for type safety, but useful to have in tests. +func compareAny(_ any1: Any?, with any2: Any?) -> Bool { + guard let any1, let any2 else { + return any1 == nil && any2 == nil + } + if let any1 = any1 as? Int, let any2 = any2 as? Int { + return any1 == any2 + } else if let any1 = any1 as? Bool, let any2 = any2 as? Bool { + return any1 == any2 + } else if let any1 = any1 as? String, let any2 = any2 as? String { + return any1 == any2 + } else if let any1 = any1 as? JSONValue, let any2 = any2 as? JSONValue { + return any1 == any2 + } else if let any1 = any1 as? [String: Any], let any2 = any2 as? [String: Any] { + return NSDictionary(dictionary: any1).isEqual(to: any2) + } else if let any1 = any1 as? [Any], let any2 = any2 as? [Any] { + guard any1.count == any2.count else { + return false + } + for i in 0 ..< any1.count where !compareAny(any1[i], with: any2[i]) { + return false + } + return true + } + return false +} + +/// A threadsafe mock methods call logger. +class MockMethodCallRecorder: @unchecked Sendable { + struct MethodArgument: Equatable { + let name: String + let value: Any? + + static func == (lhs: Self, rhs: Self) -> Bool { + guard lhs.name == rhs.name else { + return false + } + return compareAny(lhs.value, with: rhs.value) + } + } + + struct CallRecord { + let signature: String + let arguments: [MethodArgument] + } + + private var mutex = NSLock() + 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.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 { + mutex.lock() + let result = records.contains { record in + guard record.signature == signature else { + return false + } + let args1 = record.arguments.sorted() + let args2 = arguments.map { MethodArgument(name: $0.key, value: $0.value) }.sorted() + return args1 == args2 + } + mutex.unlock() + return result + } +} + +private extension [MockMethodCallRecorder.MethodArgument] { + func sorted() -> [MockMethodCallRecorder.MethodArgument] { + sorted { $0.name < $1.name } + } +} + +extension [String: Any] { + func toAblyCocoaData() -> Any { + // Probaly there is a better way of doing this + PresenceDataDTO(userCustomData: JSONValue(ablyCocoaData: self)).toJSONValue.toAblyCocoaData + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift index 81dd3a61..9a14221a 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -2,16 +2,18 @@ import Ably @testable import AblyChat final class MockRealtimePresence: InternalRealtimePresenceProtocol { + let callRecorder = MockMethodCallRecorder() + func subscribe(_: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? { - fatalError("Not implemented") + ARTEventListener() } func subscribe(_: ARTPresenceAction, callback _: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? { - fatalError("Not implemented") + ARTEventListener() } func unsubscribe(_: ARTEventListener) { - fatalError("Not implemented") + // no-op since it's called automatically } func leaveClient(_: String, data _: JSONValue?) { @@ -19,22 +21,45 @@ final class MockRealtimePresence: InternalRealtimePresenceProtocol { } func get() async throws(InternalError) -> [PresenceMessage] { - fatalError("Not implemented") + callRecorder.addRecord( + signature: "get()", + arguments: [:] + ) + return [] } - func get(_: ARTRealtimePresenceQuery) async throws(InternalError) -> [PresenceMessage] { - fatalError("Not implemented") + func get(_ query: ARTRealtimePresenceQuery) async throws(InternalError) -> [PresenceMessage] { + callRecorder.addRecord( + signature: "get(_:)", + arguments: ["query": "\(query.callRecorderDescription)"] + ) + return [] } - func leave(_: JSONValue?) async throws(InternalError) { - fatalError("Not implemented") + func leave(_ data: JSONValue?) async throws(InternalError) { + callRecorder.addRecord( + signature: "leave(_:)", + arguments: ["data": data] + ) } - func enterClient(_: String, data _: JSONValue?) async throws(InternalError) { - fatalError("Not implemented") + func enterClient(_ name: String, data: JSONValue?) async throws(InternalError) { + callRecorder.addRecord( + signature: "enterClient(_:data:)", + arguments: ["name": name, "data": data] + ) } - func update(_: JSONValue?) async throws(InternalError) { - fatalError("Not implemented") + func update(_ data: JSONValue?) async throws(InternalError) { + callRecorder.addRecord( + signature: "update(_:)", + arguments: ["data": data] + ) + } +} + +extension ARTRealtimePresenceQuery { + var callRecorderDescription: String { + "clientId=\(clientId!)" } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index 269af25c..76a9dc35 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -2,6 +2,7 @@ import Ably @testable import AblyChat class MockRoomLifecycleManager: RoomLifecycleManager { + let callRecorder = MockMethodCallRecorder() private let attachResult: Result? private(set) var attachCallCount = 0 private let detachResult: Result? @@ -10,10 +11,12 @@ class MockRoomLifecycleManager: RoomLifecycleManager { private let _roomStatus: RoomStatus? private let roomStatusSubscriptions = SubscriptionStorage() private let discontinuitySubscriptions = SubscriptionStorage() + private let resultOfWaitToBeAbleToPerformPresenceOperations: Result? - init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil) { + init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil, resultOfWaitToBeAbleToPerformPresenceOperations: Result = .success(())) { self.attachResult = attachResult self.detachResult = detachResult + self.resultOfWaitToBeAbleToPerformPresenceOperations = resultOfWaitToBeAbleToPerformPresenceOperations _roomStatus = roomStatus } @@ -60,8 +63,19 @@ class MockRoomLifecycleManager: RoomLifecycleManager { roomStatusSubscriptions.emit(statusChange) } - func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(InternalError) { - fatalError("Not implemented") + func waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature) async throws(InternalError) { + guard let resultOfWaitToBeAbleToPerformPresenceOperations else { + fatalError("resultOfWaitToBeAblePerformPresenceOperations must be set before waitToBeAbleToPerformPresenceOperations is called") + } + callRecorder.addRecord( + signature: "waitToBeAbleToPerformPresenceOperations(requestedByFeature:)", + arguments: ["requestedByFeature": "\(requestedByFeature)"] + ) + do { + try resultOfWaitToBeAbleToPerformPresenceOperations.get() + } catch { + throw error.toInternalError() + } } func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { From f9ea008765bf557941dd437c1b4de12f953ddadf Mon Sep 17 00:00:00 2001 From: Marat Al Date: Fri, 16 May 2025 22:00:54 +0200 Subject: [PATCH 2/7] Update room clients to use subscription with callback instead of `AsyncSequence`. AsyncSequence API left as a convenience and is used in tests. Update example app to use callbacks. --- Example/AblyChatExample/ContentView.swift | 180 ++++++-------- Example/AblyChatExample/Misc/Utils.swift | 20 ++ .../AblyChatExample/Mocks/MockClients.swift | 227 ++++++++++-------- .../Mocks/MockSubscriptionStorage.swift | 115 ++++++++- Sources/AblyChat/Connection.swift | 35 ++- Sources/AblyChat/DefaultConnection.swift | 21 +- Sources/AblyChat/DefaultMessages.swift | 49 ++-- Sources/AblyChat/DefaultOccupancy.swift | 23 +- Sources/AblyChat/DefaultPresence.swift | 41 ++-- Sources/AblyChat/DefaultRoomReactions.swift | 18 +- Sources/AblyChat/DefaultTyping.swift | 31 ++- Sources/AblyChat/Messages.swift | 70 ++++-- Sources/AblyChat/Occupancy.swift | 42 +++- Sources/AblyChat/Presence.swift | 80 ++++-- Sources/AblyChat/Room.swift | 94 ++++++-- Sources/AblyChat/RoomLifecycleManager.swift | 60 +++-- Sources/AblyChat/RoomReactions.swift | 34 ++- Sources/AblyChat/Subscription.swift | 6 + .../AblyChat/SubscriptionHandleStorage.swift | 42 ++++ Sources/AblyChat/Typing.swift | 40 ++- .../Subscription+RoomStatusChange.swift | 25 +- .../Mocks/MockRealtimeChannel.swift | 4 +- Tests/AblyChatTests/Mocks/MockRoom.swift | 10 + .../Mocks/MockRoomLifecycleManager.swift | 18 +- 24 files changed, 869 insertions(+), 416 deletions(-) create mode 100644 Example/AblyChatExample/Misc/Utils.swift create mode 100644 Sources/AblyChat/SubscriptionHandleStorage.swift diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 329f3dbe..df87433c 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -200,16 +200,18 @@ struct ContentView: View { do { let room = try await room() + printConnectionStatusChange(duration: 30) // stops printing after 30 seconds + subscribeToReactions(room: room) + subscribeToRoomStatus(room: room) + subscribeToTypingEvents(room: room) + subscribeToOccupancy(room: room) + subscribeToPresence(room: room) + try await room.attach() + try await showOccupancy(room: room) try await room.presence.enter(data: ["status": "📱 Online"]) try await showMessages(room: room) - showReactions(room: room) - showPresence(room: room) - try await showOccupancy(room: room) - showTypings(room: room) - showRoomStatus(room: room) - printConnectionStatusChange() } catch { print("Failed to initialize room: \(error)") // TODO: replace with logger (+ message to the user?) } @@ -234,136 +236,110 @@ struct ContentView: View { } func showMessages(room: Room) async throws { - let messagesSubscription = try await room.messages.subscribe() - let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init()) - - for message in previousMessages.items { + let subscriptionHandle = try await room.messages.subscribe { message in switch message.action { - case .create, .update, .delete: + case .create: withAnimation { - listItems.append(.message(.init(message: message, isSender: message.clientID == chatClient.realtime.clientId))) - } - } - } - - // Continue listening for messages on a background task so this function can return - Task { - for await message in messagesSubscription { - switch message.action { - case .create: - withAnimation { - listItems.insert( - .message( - .init( - message: message, - isSender: message.clientID == chatClient.realtime.clientId - ) - ), - at: 0 - ) - } - case .update, .delete: - if let index = listItems.firstIndex(where: { $0.id == message.id }) { - listItems[index] = .message( + listItems.insert( + .message( .init( message: message, isSender: message.clientID == chatClient.realtime.clientId ) + ), + at: 0 + ) + } + case .update, .delete: + if let index = listItems.firstIndex(where: { $0.id == message.id }) { + listItems[index] = .message( + .init( + message: message, + isSender: message.clientID == chatClient.realtime.clientId ) - } + ) } } } - } - - func showReactions(room: Room) { - let reactionSubscription = room.reactions.subscribe() + let previousMessages = try await subscriptionHandle.getPreviousMessages(.init()) - // Continue listening for reactions on a background task so this function can return - Task { - for await reaction in reactionSubscription { + for message in previousMessages.items { + switch message.action { + case .create, .update, .delete: withAnimation { - showReaction(reaction.displayedText) + listItems.append(.message(.init(message: message, isSender: message.clientID == chatClient.realtime.clientId))) } } } } - func showPresence(room: Room) { - // Continue listening for new presence events on a background task so this function can return - Task { - for await event in room.presence.subscribe(events: [.enter, .leave, .update]) { - withAnimation { - listItems.insert( - .presence( - .init( - presence: event - ) - ), - at: 0 - ) - } + func subscribeToReactions(room: Room) { + room.reactions.subscribe { reaction in + withAnimation { + showReaction(reaction.displayedText) } } } - func showTypings(room: Room) { - let typingSubscription = room.typing.subscribe() - // Continue listening for typing events on a background task so this function can return - Task { - for await typing in typingSubscription { - withAnimation { - // Set the typing info to the list of users currently typing - typingInfo = typing.currentlyTyping.isEmpty ? - "" : - "Typing: \(typing.currentlyTyping.joined(separator: ", "))..." - } + func subscribeToPresence(room: Room) { + room.presence.subscribe(events: [.enter, .leave, .update]) { event in + withAnimation { + listItems.insert( + .presence( + .init( + presence: event + ) + ), + at: 0 + ) } } } - func showOccupancy(room: Room) async throws { - // Continue listening for occupancy events on a background task so this function can return - let currentOccupancy = try await room.occupancy.get() - withAnimation { - occupancyInfo = "Connections: \(currentOccupancy.presenceMembers) (\(currentOccupancy.connections))" - } - - Task { - for await event in room.occupancy.subscribe() { - withAnimation { - occupancyInfo = "Connections: \(event.presenceMembers) (\(event.connections))" - } + func subscribeToTypingEvents(room: Room) { + room.typing.subscribe { typing in + withAnimation { + // Set the typing info to the list of users currently typing + let reset = typing.currentlyTyping.isEmpty || typing.currentlyTyping.count == 1 && typing.change.type == .stopped + typingInfo = reset ? "" : "Typing: \(typing.currentlyTyping.joined(separator: ", "))..." } } } - func printConnectionStatusChange() { - let connectionSubsciption = chatClient.connection.onStatusChange() + func showOccupancy(room: Room) async throws { + let occupancy = try await room.occupancy.get() + occupancyInfo = "Connections: \(occupancy.presenceMembers) (\(occupancy.connections))" + } - // Continue listening for connection status change on a background task so this function can return - Task { - for await status in connectionSubsciption { - print("Connection status changed to: \(status.current)") + func subscribeToOccupancy(room: Room) { + room.occupancy.subscribe { occupancy in + withAnimation { + occupancyInfo = "Connections: \(occupancy.presenceMembers) (\(occupancy.connections))" } } } - func showRoomStatus(room: Room) { - // Continue listening for status change events on a background task so this function can return - Task { - for await status in room.onStatusChange() { - withAnimation { - if status.current.isAttaching { - statusInfo = "\(status.current)...".capitalized - } else { - statusInfo = "\(status.current)".capitalized - if status.current.isAttached { - Task { - try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) - withAnimation { - statusInfo = "" - } + func printConnectionStatusChange(duration: TimeInterval) { + let subscriptionHandle = chatClient.connection.onStatusChange { status in + print("Connection status changed to: `\(status.current)` from `\(status.previous)`") + } + after(duration) { + subscriptionHandle.unsubscribe() + print("Unsubscribed from connection status changes.") + } + } + + func subscribeToRoomStatus(room: Room) { + room.onStatusChange { status in + withAnimation { + if status.current.isAttaching { + statusInfo = "\(status.current)...".capitalized + } else { + statusInfo = "\(status.current)".capitalized + if status.current.isAttached { + after(1) { + withAnimation { + statusInfo = "" } } } diff --git a/Example/AblyChatExample/Misc/Utils.swift b/Example/AblyChatExample/Misc/Utils.swift new file mode 100644 index 00000000..d4ad6ccf --- /dev/null +++ b/Example/AblyChatExample/Misc/Utils.swift @@ -0,0 +1,20 @@ +import Ably +import AblyChat + +func after(_ delay: TimeInterval, closure: @MainActor @escaping () -> Void) { + Task { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + await closure() + } +} + +func periodic(with interval: TimeInterval, closure: @MainActor @escaping () -> Bool) { + Task { + while true { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + if await !closure() { + break + } + } + } +} diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index a1c23143..ac4af12f 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -1,5 +1,5 @@ import Ably -import AblyChat +@testable import AblyChat class MockChatClient: ChatClient { let realtime: RealtimeClient @@ -66,7 +66,11 @@ class MockRoom: Room { var status: RoomStatus = .initialized - private let mockSubscriptions = MockSubscriptionStorage() + private let randomStatusInterval = 8.0 + + private let randomStatusChange = { @Sendable in + RoomStatusChange(current: [.attached(error: nil), .attached(error: nil), .attached(error: nil), .attached(error: nil), .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil)) + } func attach() async throws(ARTErrorInfo) { print("Mock client attached to room with roomID: \(roomID)") @@ -76,17 +80,25 @@ class MockRoom: Room { fatalError("Not yet implemented") } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - RoomStatusChange(current: [.attached(error: nil), .attached(error: nil), .attached(error: nil), .attached(error: nil), .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil)) - }, interval: 8) - } - - func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + @discardableResult + func onStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle { + var needNext = true + periodic(with: randomStatusInterval) { [weak self] in + guard let self else { + return false + } + if needNext { + callback(randomStatusChange()) + } + return needNext + } + return SubscriptionHandle { + needNext = false + } } - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { + @discardableResult + func onDiscontinuity(_: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle { fatalError("Not yet implemented") } } @@ -95,35 +107,36 @@ class MockMessages: Messages { let clientID: String let roomID: String - private let mockSubscriptions = MockSubscriptionStorage() + private let mockSubscriptions = MockMessageSubscriptionHandleStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - Message( - serial: "\(Date().timeIntervalSince1970)", - action: .create, - clientID: MockStrings.names.randomElement()!, - roomID: self.roomID, - text: MockStrings.randomPhrase(), - createdAt: Date(), - metadata: [:], - headers: [:], - version: "", - timestamp: Date(), - operation: nil - ) - }, interval: 3) - } - - func subscribe(bufferingPolicy _: BufferingPolicy) -> MessageSubscription { - MessageSubscription(mockAsyncSequence: createSubscription()) { _ in - MockMessagesPaginatedResult(clientID: self.clientID, roomID: self.roomID) - } + func subscribe(_ callback: @escaping @MainActor (Message) -> Void) async throws(ARTErrorInfo) -> MessageSubscriptionHandle { + mockSubscriptions.create( + randomElement: { + Message( + serial: "\(Date().timeIntervalSince1970)", + action: .create, + clientID: MockStrings.names.randomElement()!, + roomID: self.roomID, + text: MockStrings.randomPhrase(), + createdAt: Date(), + metadata: [:], + headers: [:], + version: "", + timestamp: Date(), + operation: nil + ) + }, + previousMessages: { _ in + MockMessagesPaginatedResult(clientID: self.clientID, roomID: self.roomID) + }, + interval: 3, + callback: callback + ) } func get(options _: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult { @@ -189,26 +202,13 @@ class MockRoomReactions: RoomReactions { let clientID: String let roomID: String - private let mockSubscriptions = MockSubscriptionStorage() + private let mockSubscriptions = MockSubscriptionHandleStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - Reaction( - type: ReactionType.allCases.randomElement()!.emoji, - metadata: [:], - headers: [:], - createdAt: Date(), - clientID: self.clientID, - isSelf: false - ) - }, interval: Double.random(in: 0.3 ... 0.6)) - } - func send(params: SendReactionParams) async throws(ARTErrorInfo) { let reaction = Reaction( type: params.type, @@ -221,8 +221,22 @@ class MockRoomReactions: RoomReactions { mockSubscriptions.emit(reaction) } - func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + @discardableResult + func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionHandle { + mockSubscriptions.create( + randomElement: { + Reaction( + type: ReactionType.allCases.randomElement()!.emoji, + metadata: [:], + headers: [:], + createdAt: Date(), + clientID: self.clientID, + isSelf: false + ) + }, + interval: 0.5, + callback: callback + ) } } @@ -230,28 +244,29 @@ class MockTyping: Typing { let clientID: String let roomID: String - private let mockSubscriptions = MockSubscriptionStorage() + private let mockSubscriptions = MockSubscriptionHandleStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - TypingSetEvent( - type: .setChanged, - currentlyTyping: [ - MockStrings.names.randomElement()!, - MockStrings.names.randomElement()!, - ], - change: .init(clientId: MockStrings.names.randomElement()!, type: .started) - ) - }, interval: 2) - } - - func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + @discardableResult + func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> SubscriptionHandle { + mockSubscriptions.create( + randomElement: { + TypingSetEvent( + type: .setChanged, + currentlyTyping: [ + MockStrings.names.randomElement()!, + MockStrings.names.randomElement()!, + ], + change: .init(clientId: MockStrings.names.randomElement()!, type: .started) + ) + }, + interval: 2, + callback: callback + ) } func get() async throws(ARTErrorInfo) -> Set { @@ -283,22 +298,26 @@ class MockPresence: Presence { let clientID: String let roomID: String - private let mockSubscriptions = MockSubscriptionStorage() + private let mockSubscriptions = MockSubscriptionHandleStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - PresenceEvent( - action: [.enter, .leave].randomElement()!, - clientID: MockStrings.names.randomElement()!, - timestamp: Date(), - data: nil - ) - }, interval: 5) + private func createSubscription(callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { + mockSubscriptions.create( + randomElement: { + PresenceEvent( + action: [.enter, .leave].randomElement()!, + clientID: MockStrings.names.randomElement()!, + timestamp: Date(), + data: nil + ) + }, + interval: 5, + callback: callback + ) } func get() async throws(ARTErrorInfo) -> [PresenceMember] { @@ -386,12 +405,12 @@ class MockPresence: Presence { ) } - func subscribe(event _: PresenceEventType, bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + func subscribe(event _: PresenceEventType, _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { + createSubscription(callback: callback) } - func subscribe(events _: [PresenceEventType], bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + func subscribe(events _: [PresenceEventType], _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { + createSubscription(callback: callback) } } @@ -399,22 +418,23 @@ class MockOccupancy: Occupancy { let clientID: String let roomID: String - private let mockSubscriptions = MockSubscriptionStorage() + private let mockSubscriptions = MockSubscriptionHandleStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID } - private func createSubscription() -> MockSubscription { - mockSubscriptions.create(randomElement: { - let random = Int.random(in: 1 ... 10) - return OccupancyEvent(connections: random, presenceMembers: Int.random(in: 0 ... random)) - }, interval: 1) - } - - func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription { - .init(mockAsyncSequence: createSubscription()) + @discardableResult + func subscribe(_ callback: @escaping @MainActor (OccupancyEvent) -> Void) -> SubscriptionHandle { + mockSubscriptions.create( + randomElement: { + let random = Int.random(in: 1 ... 10) + return OccupancyEvent(connections: random, presenceMembers: Int.random(in: 0 ... random)) + }, + interval: 2, + callback: callback + ) } func get() async throws(ARTErrorInfo) -> OccupancyEvent { @@ -423,19 +443,28 @@ class MockOccupancy: Occupancy { } class MockConnection: Connection { - let status: AblyChat.ConnectionStatus + let status: ConnectionStatus let error: ARTErrorInfo? - nonisolated func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription { - let mockSub = MockSubscription(randomElement: { - ConnectionStatusChange(current: .connecting, previous: .connected, retryIn: 1) - }, interval: 5) - - return Subscription(mockAsyncSequence: mockSub) - } + private let mockSubscriptions = MockSubscriptionHandleStorage() - init(status: AblyChat.ConnectionStatus, error: ARTErrorInfo?) { + init(status: ConnectionStatus, error: ARTErrorInfo?) { self.status = status self.error = error } + + @discardableResult + func onStatusChange(_ callback: @escaping @MainActor (ConnectionStatusChange) -> Void) -> SubscriptionHandle { + mockSubscriptions.create( + randomElement: { + ConnectionStatusChange( + current: [.connected, .connecting].randomElement()!, + previous: [.suspended, .disconnected].randomElement()!, + retryIn: 1 + ) + }, + interval: 5, + callback: callback + ) + } } diff --git a/Example/AblyChatExample/Mocks/MockSubscriptionStorage.swift b/Example/AblyChatExample/Mocks/MockSubscriptionStorage.swift index 74604e39..52b2820f 100644 --- a/Example/AblyChatExample/Mocks/MockSubscriptionStorage.swift +++ b/Example/AblyChatExample/Mocks/MockSubscriptionStorage.swift @@ -1,4 +1,5 @@ -import Foundation +import Ably +@testable import AblyChat // This is copied from ably-chat’s internal class `SubscriptionStorage`. @MainActor @@ -35,3 +36,115 @@ class MockSubscriptionStorage { } } } + +@MainActor +class MockSubscriptionHandleStorage { + @MainActor + private struct Subscription { + let handle: SubscriptionHandle + let callback: (Element) -> Void + + init(randomElement: @escaping @Sendable () -> Element, interval: Double, callback: @escaping @MainActor (Element) -> Void, onTerminate: @escaping () -> Void) { + self.callback = callback + + var needNext = true + periodic(with: interval) { + if needNext { + callback(randomElement()) + } + return needNext + } + handle = SubscriptionHandle { + needNext = false + onTerminate() + } + } + } + + private var subscriptions: [UUID: Subscription] = [:] + + func create( + randomElement: @escaping @Sendable () -> Element, + interval: Double, + callback: @escaping @MainActor (Element) -> Void + ) -> SubscriptionHandle { + let id = UUID() + let subscription = Subscription(randomElement: randomElement, interval: interval, callback: callback) { [weak self] in + self?.subscriptionDidTerminate(id: id) + } + subscriptions[id] = subscription + return subscription.handle + } + + private func subscriptionDidTerminate(id: UUID) { + _ = subscriptions.removeValue(forKey: id) + } + + func emit(_ element: Element) { + for subscription in subscriptions.values { + subscription.callback(element) + } + } +} + +@MainActor +class MockMessageSubscriptionHandleStorage { + @MainActor + private struct Subscription { + let handle: MessageSubscriptionHandle + let callback: (Element) -> Void + + init( + randomElement: @escaping @Sendable () -> Element, + previousMessages: @escaping @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult, + interval: Double, + callback: @escaping @MainActor (Element) -> Void, + onTerminate: @escaping () -> Void + ) { + self.callback = callback + + var needNext = true + periodic(with: interval) { + if needNext { + callback(randomElement()) + } + return needNext + } + handle = MessageSubscriptionHandle(unsubscribe: { + needNext = false + onTerminate() + }, getPreviousMessages: previousMessages) + } + } + + private var subscriptions: [UUID: Subscription] = [:] + + func create( + randomElement: @escaping @Sendable () -> Element, + previousMessages: @escaping @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult, + interval: Double, + callback: @escaping @MainActor (Element) -> Void + ) -> MessageSubscriptionHandle { + let id = UUID() + let subscription = Subscription( + randomElement: randomElement, + previousMessages: previousMessages, + interval: interval, + callback: callback + ) { [weak self] in + self?.subscriptionDidTerminate(id: id) + } + subscriptions[id] = subscription + return subscription.handle + } + + private func subscriptionDidTerminate(id: UUID) { + _ = subscriptions.removeValue(forKey: id) + } + + func emit(_ element: Element) { + for subscription in subscriptions.values { + subscription.callback(element) + } + } +} diff --git a/Sources/AblyChat/Connection.swift b/Sources/AblyChat/Connection.swift index ce1e8e62..417db92a 100644 --- a/Sources/AblyChat/Connection.swift +++ b/Sources/AblyChat/Connection.swift @@ -20,11 +20,12 @@ public protocol Connection: AnyObject, Sendable { * Subscribes a given listener to a connection status changes. * * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing ``ConnectionStatusChange`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``ConnectionStatusChange`` events. + * - Returns: A subscription handler that can be used to unsubscribe from ``ConnectionStatusChange`` events. */ - func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func onStatusChange(_ callback: @escaping @MainActor (ConnectionStatusChange) -> Void) -> SubscriptionHandle /// Same as calling ``onStatusChange(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. /// @@ -32,7 +33,35 @@ public protocol Connection: AnyObject, Sendable { func onStatusChange() -> Subscription } +/// `AsyncSequence` variant of `Connection` status changes. public extension Connection { + /** + * Subscribes a given listener to a connection status changes. + * + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``ConnectionStatusChange`` events. + */ + func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = onStatusChange { statusChange in + subscription.emit(statusChange) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /** + * Subscribes a given listener to a connection status changes with the default `unbounded` buffering policy. + */ func onStatusChange() -> Subscription { onStatusChange(bufferingPolicy: .unbounded) } diff --git a/Sources/AblyChat/DefaultConnection.swift b/Sources/AblyChat/DefaultConnection.swift index 3f49efd0..29a5cedb 100644 --- a/Sources/AblyChat/DefaultConnection.swift +++ b/Sources/AblyChat/DefaultConnection.swift @@ -17,9 +17,8 @@ internal final class DefaultConnection: Connection { } // (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events. - internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - let subscription = Subscription(bufferingPolicy: bufferingPolicy) - + @discardableResult + internal func onStatusChange(_ callback: @escaping @MainActor (ConnectionStatusChange) -> Void) -> SubscriptionHandle { // (CHA-CS5) The chat client must monitor the underlying realtime connection for connection status changes. let eventListener = realtime.connection.on { [weak self] stateChange in guard let self else { @@ -49,7 +48,7 @@ internal final class DefaultConnection: Connection { // (CHA-CS5a3) If a transient disconnect timer is active and the realtime connections status changes to `CONNECTED`, `SUSPENDED` or `FAILED`, the library shall cancel the transient disconnect timer. The superseding status change shall be emitted. if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed { timerManager.cancelTimer() - subscription.emit(statusChange) + callback(statusChange) // update local state and error error = stateChange.reason status = currentState @@ -60,7 +59,7 @@ internal final class DefaultConnection: Connection { timerManager.setTimer(interval: 5.0) { [timerManager] in // (CHA-CS5a4) If a transient disconnect timer expires the library shall emit a connection status change event. This event must contain the current status of of timer expiry, along with the original error that initiated the transient disconnect timer. timerManager.cancelTimer() - subscription.emit(statusChange) + callback(statusChange) // update local state and error self.error = stateChange.reason @@ -74,18 +73,18 @@ internal final class DefaultConnection: Connection { } // (CHA-CS5b) Not withstanding CHA-CS5a. If a connection state event is observed from the underlying realtime library, the client must emit a status change event. The current status of that event shall reflect the status change in the underlying realtime library, along with the accompanying error. - subscription.emit(statusChange) + callback(statusChange) // update local state and error error = stateChange.reason status = currentState } - subscription.addTerminationHandler { [weak self] in - Task { @MainActor in - self?.realtime.connection.off(eventListener) + return SubscriptionHandle { [weak self] in + guard let self else { + return } + timerManager.cancelTimer() + realtime.connection.off(eventListener) } - - return subscription } } diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index db23bd31..dec5048b 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -2,7 +2,7 @@ import Ably // Wraps the MessageSubscription with the message serial of when the subscription was attached or resumed. private struct MessageSubscriptionWrapper { - let subscription: MessageSubscription + let subscription: MessageSubscriptionHandle var serial: String } @@ -15,8 +15,8 @@ internal final class DefaultMessages: Messages { implementation = .init(channel: channel, chatAPI: chatAPI, roomID: roomID, clientID: clientID, logger: logger) } - internal func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription { - try await implementation.subscribe(bufferingPolicy: bufferingPolicy) + internal func subscribe(_ callback: @escaping @MainActor (Message) -> Void) async throws(ARTErrorInfo) -> MessageSubscriptionHandle { + try await implementation.subscribe(callback) } internal func get(options: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult { @@ -63,20 +63,9 @@ internal final class DefaultMessages: Messages { } // (CHA-M4) Messages can be received via a subscription in realtime. - internal func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription { + internal func subscribe(_ callback: @escaping @MainActor (Message) -> Void) async throws(ARTErrorInfo) -> MessageSubscriptionHandle { do { logger.log(message: "Subscribing to messages", level: .debug) - let uuid = UUID() - let serial = try await resolveSubscriptionStart() - let messageSubscription = MessageSubscription( - bufferingPolicy: bufferingPolicy - ) { [weak self] queryOptions throws(InternalError) in - guard let self else { throw MessagesError.noReferenceToSelf.toInternalError() } - return try await getBeforeSubscriptionStart(uuid, params: queryOptions) - } - - // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. - subscriptionPoints[uuid] = .init(subscription: messageSubscription, serial: serial) // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. @@ -136,25 +125,33 @@ internal final class DefaultMessages: Messages { operation: message.operation?.toChatOperation() ) - messageSubscription.emit(message) + callback(message) } catch { // note: this replaces some existing code that also didn't handle any thrown error; I suspect not intentional, will leave whoever writes the tests for this class to see what's going on } } - messageSubscription.addTerminationHandler { - Task { - await MainActor.run { [weak self] () in - guard let self else { - return - } - channel.unsubscribe(eventListener) - subscriptionPoints.removeValue(forKey: uuid) - } + let uuid = UUID() + let serial = try await resolveSubscriptionStart() + + // coderabbitai suggestion (remove "guard let self" and hold ref to channel): + // > If DefaultMessages is deallocated before the client calls unsubscribe(), the listener remains registered because the closure early-returns. Store the eventListener and always call channel.unsubscribe regardless of self + let subscriptionHandle = MessageSubscriptionHandle { [weak self, channel] in + channel.unsubscribe(eventListener) + self?.subscriptionPoints.removeValue(forKey: uuid) + } getPreviousMessages: { [weak self] queryOptions throws(ARTErrorInfo) in + guard let self else { throw MessagesError.noReferenceToSelf.toInternalError().toARTErrorInfo() } + do throws(InternalError) { + return try await getBeforeSubscriptionStart(uuid, params: queryOptions) + } catch { + throw error.toARTErrorInfo() } } - return messageSubscription + // (CHA-M4a) A subscription can be registered to receive incoming messages. Adding a subscription has no side effects on the status of the room or the underlying realtime channel. + subscriptionPoints[uuid] = .init(subscription: subscriptionHandle, serial: serial) + + return subscriptionHandle } catch { throw error.toARTErrorInfo() } diff --git a/Sources/AblyChat/DefaultOccupancy.swift b/Sources/AblyChat/DefaultOccupancy.swift index e6ed92fd..39a912eb 100644 --- a/Sources/AblyChat/DefaultOccupancy.swift +++ b/Sources/AblyChat/DefaultOccupancy.swift @@ -7,8 +7,9 @@ internal final class DefaultOccupancy: Occupancy { implementation = .init(channel: channel, chatAPI: chatAPI, roomID: roomID, logger: logger, options: options) } - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.subscribe(bufferingPolicy: bufferingPolicy) + @discardableResult + internal func subscribe(_ callback: @escaping @MainActor (OccupancyEvent) -> Void) -> SubscriptionHandle { + implementation.subscribe(callback) } internal func get() async throws(ARTErrorInfo) -> OccupancyEvent { @@ -35,7 +36,8 @@ internal final class DefaultOccupancy: Occupancy { // (CHA-O4a) Users may register a listener that receives occupancy events in realtime. // (CHA-O4c) When a regular occupancy event is received on the channel (a standard PubSub occupancy event per the docs), the SDK will convert it into occupancy event format and broadcast it to subscribers. // (CHA-O4d) If an invalid occupancy event is received on the channel, it shall be dropped. - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + @discardableResult + internal func subscribe(_ callback: @escaping @MainActor (OccupancyEvent) -> Void) -> SubscriptionHandle { // CHA-O4e (we use a fatalError for this programmer error, which is the idiomatic thing to do for Swift) guard options.enableEvents else { fatalError("In order to be able to subscribe to presence events, please set enableEvents to true in the room's occupancy options.") @@ -43,8 +45,6 @@ internal final class DefaultOccupancy: Occupancy { logger.log(message: "Subscribing to occupancy events", level: .debug) - let subscription = Subscription(bufferingPolicy: bufferingPolicy) - let eventListener = channel.subscribe(OccupancyEvents.meta.rawValue) { [logger] message in logger.log(message: "Received occupancy message: \(message)", level: .debug) guard let data = message.data as? [String: Any], @@ -60,18 +60,15 @@ internal final class DefaultOccupancy: Occupancy { let occupancyEvent = OccupancyEvent(connections: connections, presenceMembers: presenceMembers) logger.log(message: "Emitting occupancy event: \(occupancyEvent)", level: .debug) - subscription.emit(occupancyEvent) + callback(occupancyEvent) } - subscription.addTerminationHandler { [weak self] in - if let eventListener { - Task { @MainActor in - self?.channel.off(eventListener) - } + return SubscriptionHandle { + guard let eventListener else { + return } + self.channel.off(eventListener) } - - return subscription } // (CHA-O3) Users can request an instantaneous occupancy check via the REST API. The request is detailed here (https://sdk.ably.com/builds/ably/specification/main/chat-features/#rest-occupancy-request), with the response format being a simple occupancy event diff --git a/Sources/AblyChat/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift index fd60c046..70235c43 100644 --- a/Sources/AblyChat/DefaultPresence.swift +++ b/Sources/AblyChat/DefaultPresence.swift @@ -43,12 +43,14 @@ internal final class DefaultPresence: Presence { try await implementation.leave() } - internal func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.subscribe(event: event, bufferingPolicy: bufferingPolicy) + @discardableResult + internal func subscribe(event: PresenceEventType, _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { + implementation.subscribe(event: event, callback) } - internal func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.subscribe(events: events, bufferingPolicy: bufferingPolicy) + @discardableResult + internal func subscribe(events: [PresenceEventType], _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { + implementation.subscribe(events: events, callback) } /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `Presence` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @@ -259,60 +261,53 @@ internal final class DefaultPresence: Presence { // (CHA-PR7a) Users may provide a listener to subscribe to all presence events in a room. // (CHA-PR7b) Users may provide a listener and a list of selected presence events, to subscribe to just those events in a room. - internal func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) -> Subscription { + internal func subscribe(event: PresenceEventType, _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { fatalErrorIfEnableEventsDisabled() logger.log(message: "Subscribing to presence events", level: .debug) - let subscription = Subscription(bufferingPolicy: bufferingPolicy) + let eventListener = channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in logger.log(message: "Received presence message: \(message)", level: .debug) do { // processPresenceSubscribe is logging so we don't need to log here let presenceEvent = try processPresenceSubscribe(PresenceMessage(ablyCocoaPresenceMessage: message), event) - subscription.emit(presenceEvent) + callback(presenceEvent) } catch { // note: this replaces some existing code that also didn't handle the processPresenceSubscribe error; I suspect not intentional, will leave whoever writes the tests for this class to see what's going on } } - subscription.addTerminationHandler { [weak self] in + + return SubscriptionHandle { [weak self] in if let eventListener { - Task { @MainActor in - self?.channel.presence.unsubscribe(eventListener) - } + self?.channel.presence.unsubscribe(eventListener) } } - return subscription } - internal func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) -> Subscription { + internal func subscribe(events: [PresenceEventType], _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle { fatalErrorIfEnableEventsDisabled() logger.log(message: "Subscribing to presence events", level: .debug) - let subscription = Subscription(bufferingPolicy: bufferingPolicy) let eventListeners = events.map { event in channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in logger.log(message: "Received presence message: \(message)", level: .debug) do { let presenceEvent = try processPresenceSubscribe(PresenceMessage(ablyCocoaPresenceMessage: message), event) - subscription.emit(presenceEvent) + callback(presenceEvent) } catch { // note: this replaces some existing code that also didn't handle the processPresenceSubscribe error; I suspect not intentional, will leave whoever writes the tests for this class to see what's going on } } } - subscription.addTerminationHandler { [weak self] in - Task { @MainActor in - for eventListener in eventListeners { - if let eventListener { - self?.channel.presence.unsubscribe(eventListener) - } + return SubscriptionHandle { [weak self] in + for eventListener in eventListeners { + if let eventListener { + self?.channel.presence.unsubscribe(eventListener) } } } - - return subscription } private func decodePresenceDataDTO(from presenceData: JSONValue?) throws(InternalError) -> PresenceDataDTO { diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index b044a99c..d3176db1 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -11,8 +11,8 @@ internal final class DefaultRoomReactions: RoomReactions { try await implementation.send(params: params) } - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.subscribe(bufferingPolicy: bufferingPolicy) + internal func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionHandle { + implementation.subscribe(callback) } /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `RoomReactions` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @@ -50,9 +50,9 @@ internal final class DefaultRoomReactions: RoomReactions { // (CHA-ER4) A user may subscribe to reaction events in Realtime. // (CHA-ER4a) A user may provide a listener to subscribe to reaction events. This operation must have no side-effects in relation to room or underlying status. When a realtime message with name roomReaction is received, this message is converted into a reaction object and emitted to subscribers. - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + @discardableResult + internal func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionHandle { logger.log(message: "Subscribing to reaction events", level: .debug) - let subscription = Subscription(bufferingPolicy: bufferingPolicy) // (CHA-ER4c) Realtime events with an unknown name shall be silently discarded. let eventListener = channel.subscribe(RoomReactionEvents.reaction.rawValue) { [clientID, logger] message in @@ -89,19 +89,15 @@ internal final class DefaultRoomReactions: RoomReactions { isSelf: messageClientID == clientID ) logger.log(message: "Emitting reaction: \(reaction)", level: .debug) - subscription.emit(reaction) + callback(reaction) } catch { logger.log(message: "Error processing incoming reaction message: \(error)", level: .error) } } - subscription.addTerminationHandler { [weak self] in - Task { @MainActor in - self?.channel.unsubscribe(eventListener) - } + return SubscriptionHandle { [weak self] in + self?.channel.unsubscribe(eventListener) } - - return subscription } private enum RoomReactionsError: Error { diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index cb915a64..e477ba2b 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -8,8 +8,9 @@ internal final class DefaultTyping: Typing { } // (CHA-T6) Users may subscribe to typing events – updates to a set of clientIDs that are typing. This operation, like all subscription operations, has no side-effects in relation to room lifecycle. - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.subscribe(bufferingPolicy: bufferingPolicy) + @discardableResult + internal func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> SubscriptionHandle { + implementation.subscribe(callback) } // (CHA-T9) Users may retrieve a list of the currently typing client IDs. @@ -70,10 +71,9 @@ internal final class DefaultTyping: Typing { ) } - internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + @discardableResult + internal func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> SubscriptionHandle { // (CHA-T6a) Users may provide a listener to subscribe to typing event V2 in a chat room. - let subscription = Subscription(bufferingPolicy: bufferingPolicy) - let startedEventListener = channel.subscribe(TypingEventType.started.rawValue) { [weak self] message in guard let self, let messageClientID = message.clientId else { return @@ -90,7 +90,7 @@ internal final class DefaultTyping: Typing { return } // (CHA-T13b3) (2/2) If the (CHA-T13b1) timeout expires, the client shall remove the clientId from the typing set and emit a synthetic typing stop event for the given client. - subscription.emit( + callback( TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), @@ -100,7 +100,7 @@ internal final class DefaultTyping: Typing { } // (CHA-T13) When a typing event (typing.start or typing.stop) is received from the realtime client, the Chat client shall emit appropriate events to the user. - subscription.emit( + callback( TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), @@ -123,7 +123,7 @@ internal final class DefaultTyping: Typing { typingTimerManager.cancelTypingTimer(for: messageClientID) // (CHA-T13) When a typing event (typing.start or typing.stop) is received from the realtime client, the Chat client shall emit appropriate events to the user. - subscription.emit( + callback( TypingSetEvent( type: .setChanged, currentlyTyping: typingTimerManager.currentlyTypingClientIDs(), @@ -134,17 +134,14 @@ internal final class DefaultTyping: Typing { } // (CHA-T6b) A subscription to typing may be removed, after which it shall receive no further events. - subscription.addTerminationHandler { [weak self] in - Task { @MainActor in - if let startedEventListener { - self?.channel.unsubscribe(startedEventListener) - } - if let stoppedEventListener { - self?.channel.unsubscribe(stoppedEventListener) - } + return SubscriptionHandle { + if let startedEventListener { + self.channel.unsubscribe(startedEventListener) + } + if let stoppedEventListener { + self.channel.unsubscribe(stoppedEventListener) } } - return subscription } internal func get() async throws(ARTErrorInfo) -> Set { diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 722fc230..d13df90e 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -12,16 +12,11 @@ public protocol Messages: AnyObject, Sendable { * Subscribe to new messages in this chat room. * * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing room ``Message`` events. * - * - Returns: A subscription ``MessageSubscription`` that can be used to iterate through new messages. + * - Returns: A subscription handle that can be used to unsubscribe from ``Message`` events. */ - func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription - - /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Messages` protocol provides a default implementation of this method. - func subscribe() async throws(ARTErrorInfo) -> MessageSubscription + func subscribe(_ callback: @escaping @MainActor (Message) -> Void) async throws(ARTErrorInfo) -> MessageSubscriptionHandle /** * Get messages that have been previously sent to the chat room, based on the provided options. @@ -80,6 +75,38 @@ public protocol Messages: AnyObject, Sendable { } public extension Messages { + /** + * Subscribe to new messages in this chat room. + * + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription ``MessageSubscription`` that can be used to iterate through new messages. + */ + func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription { + var emitMessage: ((Message) -> Void)? + let subscriptionHandle = try await subscribe { message in + emitMessage?(message) + } + + let subscription = MessageSubscription( + bufferingPolicy: bufferingPolicy, + getPreviousMessages: subscriptionHandle.getPreviousMessages + ) + emitMessage = { [weak subscription] message in + subscription?.emit(message) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. func subscribe() async throws(ARTErrorInfo) -> MessageSubscription { try await subscribe(bufferingPolicy: .unbounded) } @@ -277,12 +304,12 @@ public final class MessageSubscription: Sendable, AsyncSequence { private let subscription: Subscription // can be set by either initialiser - private let getPreviousMessages: @Sendable (QueryOptions) async throws(InternalError) -> any PaginatedResult + private let getPreviousMessages: @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult // used internally internal init( bufferingPolicy: BufferingPolicy, - getPreviousMessages: @escaping @Sendable (QueryOptions) async throws(InternalError) -> any PaginatedResult + getPreviousMessages: @escaping @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult ) { subscription = .init(bufferingPolicy: bufferingPolicy) self.getPreviousMessages = getPreviousMessages @@ -291,13 +318,7 @@ public final class MessageSubscription: Sendable, AsyncSequence { // used for testing public init(mockAsyncSequence: Underlying, mockGetPreviousMessages: @escaping @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult) where Underlying.Element == Element { subscription = .init(mockAsyncSequence: mockAsyncSequence) - getPreviousMessages = { @Sendable params throws(InternalError) in - do throws(ARTErrorInfo) { - return try await mockGetPreviousMessages(params) - } catch { - throw error.toInternalError() - } - } + getPreviousMessages = mockGetPreviousMessages } internal func emit(_ element: Element) { @@ -310,11 +331,7 @@ public final class MessageSubscription: Sendable, AsyncSequence { } public func getPreviousMessages(params: QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult { - do { - return try await getPreviousMessages(params) - } catch { - throw error.toARTErrorInfo() - } + try await getPreviousMessages(params) } public struct AsyncIterator: AsyncIteratorProtocol { @@ -333,3 +350,12 @@ public final class MessageSubscription: Sendable, AsyncSequence { .init(subscriptionIterator: subscription.makeAsyncIterator()) } } + +/// An object that is used to unsubscribe from message events and get previous messages. +public struct MessageSubscriptionHandle: Sendable { + /// A closure to call for unsubscribing from events. Set internally. + public let unsubscribe: @MainActor () -> Void + + /// A closure to call to get previous messages. Set internally. + public let getPreviousMessages: @Sendable (QueryOptions) async throws(ARTErrorInfo) -> any PaginatedResult +} diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index 25059b40..8372a7b6 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -14,16 +14,12 @@ public protocol Occupancy: AnyObject, Sendable { * Note that it is a programmer error to call this method if occupancy events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's occupancy options to use this feature. * * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing room ``OccupancyEvent`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``OccupancyEvent`` events. + * - Returns: A subscription handle that can be used to unsubscribe from ``OccupancyEvent`` events. */ - func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription - - /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Occupancy` protocol provides a default implementation of this method. - func subscribe() -> Subscription + @discardableResult + func subscribe(_ callback: @escaping @MainActor (OccupancyEvent) -> Void) -> SubscriptionHandle /** * Get the current occupancy of the chat room. @@ -33,7 +29,37 @@ public protocol Occupancy: AnyObject, Sendable { func get() async throws(ARTErrorInfo) -> OccupancyEvent } +/// `AsyncSequence` variant of receiving room occupancy events. public extension Occupancy { + /** + * Subscribes a given listener to occupancy updates of the chat room. + * + * Note that it is a programmer error to call this method if occupancy events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's occupancy options to use this feature. + * + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``OccupancyEvent`` events. + */ + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = subscribe { occupancy in + subscription.emit(occupancy) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. + /// + /// The `Occupancy` protocol provides a default implementation of this method. func subscribe() -> Subscription { subscribe(bufferingPolicy: .unbounded) } diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index d756edf0..438353f3 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -76,11 +76,12 @@ public protocol Presence: AnyObject, Sendable { * * - Parameters: * - event: A single presence event type ``PresenceEventType`` to subscribe to. - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing room ``PresenceEvent`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``PresenceEvent`` events. + * - Returns: A subscription handle that can be used to unsubscribe from ``PresenceEvent`` events. */ - func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func subscribe(event: PresenceEventType, _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle /** * Subscribes a given listener to different presence events in the chat room. @@ -89,11 +90,12 @@ public protocol Presence: AnyObject, Sendable { * * - Parameters: * - events: An array of presence event types ``PresenceEventType`` to subscribe to. - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing room ``PresenceEvent`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``PresenceEvent`` events. + * - Returns: A subscription handle that can be used to unsubscribe from ``PresenceEvent`` events. */ - func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func subscribe(events: [PresenceEventType], _ callback: @escaping @MainActor (PresenceEvent) -> Void) -> SubscriptionHandle /** * Method to join room presence, will emit an enter event to all subscribers. Repeat calls will trigger more enter events. @@ -118,23 +120,69 @@ public protocol Presence: AnyObject, Sendable { * - Throws: An `ARTErrorInfo`. */ func leave() async throws(ARTErrorInfo) - - /// Same as calling ``subscribe(event:bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Presence` protocol provides a default implementation of this method. - func subscribe(event: PresenceEventType) -> Subscription - - /// Same as calling ``subscribe(events:bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Presence` protocol provides a default implementation of this method. - func subscribe(events: [PresenceEventType]) -> Subscription } public extension Presence { + /** + * Subscribes a given listener to a particular presence event in the chat room. + * + * Note that it is a programmer error to call this method if presence events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's presence options to use this feature (this is the default value). + * + * - Parameters: + * - event: A single presence event type ``PresenceEventType`` to subscribe to. + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``PresenceEvent`` events. + */ + func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = subscribe(event: event) { presence in + subscription.emit(presence) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /** + * Subscribes a given listener to different presence events in the chat room. + * + * Note that it is a programmer error to call this method if presence events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's presence options to use this feature (this is the default value). + * + * - Parameters: + * - events: An array of presence event types ``PresenceEventType`` to subscribe to. + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``PresenceEvent`` events. + */ + func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = subscribe(events: events) { presence in + subscription.emit(presence) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /// Same as calling ``subscribe(event:bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. func subscribe(event: PresenceEventType) -> Subscription { subscribe(event: event, bufferingPolicy: .unbounded) } + /// Same as calling ``subscribe(events:bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. func subscribe(events: [PresenceEventType]) -> Subscription { subscribe(events: events, bufferingPolicy: .unbounded) } diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index df7a19b7..919b43b3 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -66,16 +66,23 @@ public protocol Room: AnyObject, Sendable { * Subscribes a given listener to the room status changes. * * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing ``RoomStatusChange`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``RoomStatusChange`` events. + * - Returns: A subscription handle that can be used to unsubscribe from ``RoomStatusChange`` events. */ - func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func onStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle - /// Same as calling ``onStatusChange(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Room` protocol provides a default implementation of this method. - func onStatusChange() -> Subscription + /** + * Subscribes a given listener to a detected discontinuity. + * + * - Parameters: + * - callback: The listener closure for capturing ``DiscontinuityEvent``. + * + * - Returns: A subscription handle that can be used to unsubscribe from ``DiscontinuityEvent``. + */ + @discardableResult + func onDiscontinuity(_ callback: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle /** * Attaches to the room to receive events in realtime. @@ -106,33 +113,72 @@ public protocol Room: AnyObject, Sendable { nonisolated var options: RoomOptions { get } /** - * Subscribes a given listener to a detected discontinuity. + * Get the underlying Ably realtime channel used for the room. + * + * - Returns: The realtime channel. + */ + nonisolated var channel: any RealtimeChannelProtocol { get } +} + +/// `AsyncSequence` variant of `Room` status changes. +public extension Room { + /** + * Subscribes a given listener to the room status changes. * * - Parameters: * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``DiscontinuityEvent`` events. + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``RoomStatusChange`` events. */ - func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription + func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) - /// Same as calling ``onDiscontinuity(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. + let subscriptionHandle = onStatusChange { statusChange in + subscription.emit(statusChange) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /// Same as calling ``onStatusChange(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. /// /// The `Room` protocol provides a default implementation of this method. - func onDiscontinuity() -> Subscription + func onStatusChange() -> Subscription { + onStatusChange(bufferingPolicy: .unbounded) + } /** - * Get the underlying Ably realtime channel used for the room. + * Subscribes a given listener to a detected discontinuity using `AsyncSequence` subscription. * - * - Returns: The realtime channel. + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``DiscontinuityEvent`` events. */ - nonisolated var channel: any RealtimeChannelProtocol { get } -} + func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) -public extension Room { - func onStatusChange() -> Subscription { - onStatusChange(bufferingPolicy: .unbounded) + let subscriptionHandle = onDiscontinuity { statusChange in + subscription.emit(statusChange) + } + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription } + /// Same as calling ``onDiscontinuity(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. + /// + /// The `Room` protocol provides a default implementation of this method. func onDiscontinuity() -> Subscription { onDiscontinuity(bufferingPolicy: .unbounded) } @@ -322,8 +368,9 @@ internal class DefaultRoom: InternalRoom { // MARK: - Room status - internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - lifecycleManager.onRoomStatusChange(bufferingPolicy: bufferingPolicy) + @discardableResult + internal func onStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle { + lifecycleManager.onRoomStatusChange(callback) } internal var status: RoomStatus { @@ -332,7 +379,8 @@ internal class DefaultRoom: InternalRoom { // MARK: - Discontinuities - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - lifecycleManager.onDiscontinuity(bufferingPolicy: bufferingPolicy) + @discardableResult + internal func onDiscontinuity(_ callback: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle { + lifecycleManager.onDiscontinuity(callback) } } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index e9a92dd5..e119f8da 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -6,7 +6,8 @@ internal protocol RoomLifecycleManager: Sendable { func performDetachOperation() async throws(InternalError) func performReleaseOperation() async var roomStatus: RoomStatus { get } - func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func onRoomStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle /// Waits until we can perform presence operations on this room's channel without triggering an implicit attach. /// @@ -20,7 +21,8 @@ internal protocol RoomLifecycleManager: Sendable { /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) - func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription + @discardableResult + func onDiscontinuity(_ callback: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle } @MainActor @@ -102,8 +104,8 @@ internal class DefaultRoomLifecycleManager: RoomLifecycleManager { } private var channelStateEventListener: ARTEventListener! - private let roomStatusChangeSubscriptions = SubscriptionStorage() - private let discontinuitySubscriptions = SubscriptionStorage() + private let roomStatusChangeSubscriptions = SubscriptionHandleStorage() + private let discontinuitySubscriptions = SubscriptionHandleStorage() private var operationResultContinuations = OperationResultContinuations() // MARK: - Initializers and `deinit` @@ -175,8 +177,9 @@ internal class DefaultRoomLifecycleManager: RoomLifecycleManager { // MARK: - Room status and its changes - internal func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - roomStatusChangeSubscriptions.create(bufferingPolicy: bufferingPolicy) + @discardableResult + internal func onRoomStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle { + roomStatusChangeSubscriptions.create(callback) } /// Updates ``roomStatus`` and emits a status change event. @@ -218,8 +221,24 @@ internal class DefaultRoomLifecycleManager: RoomLifecycleManager { } } + @discardableResult + internal func onDiscontinuity(_ callback: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle { + discontinuitySubscriptions.create(callback) + } + internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = onDiscontinuity { statusChange in + subscription.emit(statusChange) + } + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription } private func emitDiscontinuity(_ event: DiscontinuityEvent) { @@ -589,19 +608,24 @@ internal class DefaultRoomLifecycleManager: RoomLifecycleManager { // CHA-RL9, which is invoked by CHA-PR3d, CHA-PR10d, CHA-PR6c // CHA-RL9a - let subscription = onRoomStatusChange(bufferingPolicy: .unbounded) - logger.log(message: "waitToBeAbleToPerformPresenceOperations waiting for status change", level: .debug) - #if DEBUG - statusChangeWaitEventSubscriptions.emit(.init()) - #endif - let nextRoomStatusChange = await (subscription.first { @Sendable _ in true }) - logger.log(message: "waitToBeAbleToPerformPresenceOperations got status change \(String(describing: nextRoomStatusChange))", level: .debug) - + var nextRoomStatusSubscription: SubscriptionHandle! + var nextRoomStatusChange: RoomStatusChange! + await withCheckedContinuation { (continuation: CheckedContinuation) in + self.logger.log(message: "waitToBeAbleToPerformPresenceOperations waiting for status change", level: .debug) + #if DEBUG + self.statusChangeWaitEventSubscriptions.emit(.init()) + #endif + nextRoomStatusSubscription = self.onRoomStatusChange { [weak self] statusChange in + nextRoomStatusChange = statusChange + self?.logger.log(message: "waitToBeAbleToPerformPresenceOperations got status change \(String(describing: nextRoomStatusChange))", level: .debug) + continuation.resume() + } + } + nextRoomStatusSubscription.unsubscribe() // CHA-RL9b - // TODO: decide what to do if nextRoomStatusChange is nil; I believe that this will happen if the current Task is cancelled. For now, will just treat it as an invalid status change. Handle it properly in https://github.com/ably-labs/ably-chat-swift/issues/29 - if nextRoomStatusChange?.current != .attached(error: nil) { + if nextRoomStatusChange.current != .attached(error: nil) { // CHA-RL9c - throw ARTErrorInfo(chatError: .roomTransitionedToInvalidStateForPresenceOperation(cause: nextRoomStatusChange?.current.error)).toInternalError() + throw ARTErrorInfo(chatError: .roomTransitionedToInvalidStateForPresenceOperation(cause: nextRoomStatusChange.current.error)).toInternalError() } case .attached: // CHA-PR3e, CHA-PR10e, CHA-PR6d diff --git a/Sources/AblyChat/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift index 50738bc4..6314ff28 100644 --- a/Sources/AblyChat/RoomReactions.swift +++ b/Sources/AblyChat/RoomReactions.swift @@ -17,6 +17,20 @@ public protocol RoomReactions: AnyObject, Sendable { */ func send(params: SendReactionParams) async throws(ARTErrorInfo) + /** + * Subscribes a given listener to the room reactions. + * + * - Parameters: + * - callback: The listener closure for capturing room ``Reaction``. + * + * - Returns: A subscription handle that can be used to unsubscribe from ``Reaction`` events. + */ + @discardableResult + func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionHandle +} + +/// `AsyncSequence` variant of receiving room reactions. +public extension RoomReactions { /** * Subscribes a given listener to receive room-level reactions. * @@ -25,15 +39,25 @@ public protocol RoomReactions: AnyObject, Sendable { * * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``Reaction`` events. */ - func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = subscribe { reaction in + subscription.emit(reaction) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. /// /// The `RoomReactions` protocol provides a default implementation of this method. - func subscribe() -> Subscription -} - -public extension RoomReactions { func subscribe() -> Subscription { subscribe(bufferingPolicy: .unbounded) } diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index cbee9b67..6dfb61a5 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -1,5 +1,11 @@ import Foundation +/// An object that is used to unsubscribe from subscription events. +public struct SubscriptionHandle: Sendable { + /// A closure to call for unsubscribing from events. + public let unsubscribe: @MainActor () -> Void +} + // A non-throwing `AsyncSequence` (means that we can iterate over it without a `try`). // // This should respect the `BufferingPolicy` passed to the `subscribe(bufferingPolicy:)` method. diff --git a/Sources/AblyChat/SubscriptionHandleStorage.swift b/Sources/AblyChat/SubscriptionHandleStorage.swift new file mode 100644 index 00000000..dd35ba3c --- /dev/null +++ b/Sources/AblyChat/SubscriptionHandleStorage.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Maintains a list of `SubscriptionHandle` objects, which can be used to unsubscribe from subscription events. +/// +/// Offers the ability to create a new subscription (using ``create(_:)``) or to emit a value on all subscriptions (using ``emit(_:)``). +@MainActor +internal class SubscriptionHandleStorage { + private struct SubscriptionElement { + let callback: (Element) -> Void + let handle: SubscriptionHandle + } + + private var subscriptions: [UUID: SubscriptionElement] = [:] + + /// Creates a subscription and adds it to the list managed by this `SubscriptionStorage` instance. + internal func create(_ callback: @escaping @MainActor (Element) -> Void) -> SubscriptionHandle { + let id = UUID() + let subscriptionHandle = SubscriptionHandle { [weak self] in + self?.subscriptionDidTerminate(id: id) + } + let element = SubscriptionElement(callback: callback, handle: subscriptionHandle) + subscriptions[id] = element + return subscriptionHandle + } + + #if DEBUG + internal var testsOnly_subscriptionCount: Int { + subscriptions.count + } + #endif + + private func subscriptionDidTerminate(id: UUID) { + _ = subscriptions.removeValue(forKey: id) + } + + /// Emits an element on all of the subscriptions in the reciever’s managed list. + internal func emit(_ element: Element) { + for subscription in subscriptions.values { + subscription.callback(element) + } + } +} diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index d18ad6d0..36b13722 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -12,16 +12,12 @@ public protocol Typing: AnyObject, Sendable { * Subscribes a given listener to all typing events from users in the chat room. * * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * - callback: The listener closure for capturing room ``TypingEvent`` events. * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``TypingEvent`` events. + * - Returns: A subscription handle that can be used to unsubscribe from ``TypingEvent`` events. */ - func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription - - /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `Typing` protocol provides a default implementation of this method. - func subscribe() -> Subscription + @discardableResult + func subscribe(_ callback: @escaping @MainActor (TypingSetEvent) -> Void) -> SubscriptionHandle /** * Get the current typers, a set of clientIds. @@ -52,7 +48,35 @@ public protocol Typing: AnyObject, Sendable { func stop() async throws(ARTErrorInfo) } +/// `AsyncSequence` variant of receiving room typing events. public extension Typing { + /** + * Subscribes a given listener to all typing events from users in the chat room. + * + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``TypingSetEvent`` events. + */ + func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = subscribe { typingEvent in + subscription.emit(typingEvent) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + /// Same as calling ``subscribe(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. + /// + /// The `Typing` protocol provides a default implementation of this method. func subscribe() -> Subscription { subscribe(bufferingPolicy: .unbounded) } diff --git a/Tests/AblyChatTests/Helpers/Subscription+RoomStatusChange.swift b/Tests/AblyChatTests/Helpers/Subscription+RoomStatusChange.swift index 116efa95..577bd421 100644 --- a/Tests/AblyChatTests/Helpers/Subscription+RoomStatusChange.swift +++ b/Tests/AblyChatTests/Helpers/Subscription+RoomStatusChange.swift @@ -1,5 +1,5 @@ import Ably -import AblyChat +@testable import AblyChat /// Extensions for filtering a subscription by a given case, and then providing access to the values associated with these cases. /// @@ -49,3 +49,26 @@ extension Subscription where Element == RoomStatusChange { } } } + +/// `AsyncSequence` variant of `Room` status changes. +extension RoomLifecycleManager { + func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + + let subscriptionHandle = onRoomStatusChange { statusChange in + subscription.emit(statusChange) + } + + subscription.addTerminationHandler { + Task { @MainActor in + subscriptionHandle.unsubscribe() + } + } + + return subscription + } + + func onRoomStatusChange() -> Subscription { + onRoomStatusChange(bufferingPolicy: .unbounded) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index b3ad4ea0..cc92cd3d 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -134,7 +134,9 @@ 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 { - callback(messageToEmitOnSubscribe) + Task { + callback(messageToEmitOnSubscribe) + } } else { channelSubscriptions.append((name, callback)) } diff --git a/Tests/AblyChatTests/Mocks/MockRoom.swift b/Tests/AblyChatTests/Mocks/MockRoom.swift index 7e307383..743981c5 100644 --- a/Tests/AblyChatTests/Mocks/MockRoom.swift +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -68,6 +68,16 @@ class MockRoom: InternalRoom { private let _releaseCallsAsyncSequence: (stream: AsyncStream, continuation: AsyncStream.Continuation) + @discardableResult + func onStatusChange(_: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle { + fatalError("Not implemented") + } + + @discardableResult + func onDiscontinuity(_: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle { + fatalError("Not implemented") + } + func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { fatalError("Not implemented") } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index 269af25c..c9aede29 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -8,8 +8,8 @@ class MockRoomLifecycleManager: RoomLifecycleManager { private(set) var detachCallCount = 0 private(set) var releaseCallCount = 0 private let _roomStatus: RoomStatus? - private let roomStatusSubscriptions = SubscriptionStorage() - private let discontinuitySubscriptions = SubscriptionStorage() + private let roomStatusSubscriptions = SubscriptionHandleStorage() + private let discontinuitySubscriptions = SubscriptionHandleStorage() init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil) { self.attachResult = attachResult @@ -52,10 +52,6 @@ class MockRoomLifecycleManager: RoomLifecycleManager { return roomStatus } - func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - roomStatusSubscriptions.create(bufferingPolicy: bufferingPolicy) - } - func emitStatusChange(_ statusChange: RoomStatusChange) { roomStatusSubscriptions.emit(statusChange) } @@ -64,8 +60,14 @@ class MockRoomLifecycleManager: RoomLifecycleManager { fatalError("Not implemented") } - func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) + @discardableResult + func onRoomStatusChange(_ callback: @escaping @MainActor (RoomStatusChange) -> Void) -> SubscriptionHandle { + roomStatusSubscriptions.create(callback) + } + + @discardableResult + func onDiscontinuity(_ callback: @escaping @MainActor (DiscontinuityEvent) -> Void) -> SubscriptionHandle { + discontinuitySubscriptions.create(callback) } func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) { From c9f02562b4ea6187d074a8b8d93909640c42f44f Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sun, 18 May 2025 23:10:48 +0200 Subject: [PATCH 3/7] Add messages tests. --- Sources/AblyChat/InternalError.swift | 6 + .../AblyChatTests/DefaultMessagesTests.swift | 393 +++++++++++++++--- .../Mocks/MockHTTPPaginatedResponse.swift | 28 +- Tests/AblyChatTests/Mocks/MockRealtime.swift | 6 + .../Mocks/MockRealtimeChannel.swift | 44 +- 5 files changed, 392 insertions(+), 85 deletions(-) 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..2792d678 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) @@ -127,29 +389,28 @@ struct DefaultMessagesTests { channelSerial: "001" ), initialState: .attached, + messageJSONToEmitOnSubscribe: [ + "foo": "bar", // malformed message + ], 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() - - // Then - let receivedMessage = try #require(await messagesSubscription.first { @Sendable _ in true }) - #expect(receivedMessage.metadata == ["numberKey": .number(10), "stringKey": .string("hello")]) + _ = try await defaultMessages.subscribe { message in + // Then + #expect(message.serial == "123") + } } } 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..e9df8218 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,16 +29,20 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { initialErrorReason: ARTErrorInfo? = nil, attachBehavior: AttachOrDetachBehavior? = nil, detachBehavior: AttachOrDetachBehavior? = nil, - messageToEmitOnSubscribe: ARTMessage? = nil + messageJSONToEmitOnSubscribe: [String: Sendable]? = nil, + messageToEmitOnSubscribe: ARTMessage? = nil, + stateChangeToEmitForListener: ARTChannelStateChange? = nil ) { _name = name _state = initialState self.attachBehavior = attachBehavior self.detachBehavior = detachBehavior errorReason = initialErrorReason + self.messageJSONToEmitOnSubscribe = messageJSONToEmitOnSubscribe self.messageToEmitOnSubscribe = messageToEmitOnSubscribe attachSerial = properties.attachSerial channelSerial = properties.channelSerial + self.stateChangeToEmitForListener = stateChangeToEmitForListener } var state: ARTRealtimeChannelState { @@ -128,18 +133,38 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { try result.get() } + let messageJSONToEmitOnSubscribe: [String: Sendable]? let messageToEmitOnSubscribe: ARTMessage? - private var channelSubscriptions: [(String, (ARTMessage) -> Void)] = [] // 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. + private var channelSubscriptions: [(String, (ARTMessage) -> Void)] = [] + func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener? { + if let json = messageJSONToEmitOnSubscribe { + let message = ARTMessage(name: nil, data: json["data"] ?? "") + if let action = json["action"] as? UInt { + message.action = ARTMessageAction(rawValue: action) ?? .create + } + if let serial = json["serial"] as? String { + message.serial = serial + } + if let clientId = json["clientId"] as? String { + message.clientId = clientId + } + if let extras = json["extras"] as? ARTJsonCompatible { + message.extras = extras + } + if let ts = json["timestamp"] as? String { + message.timestamp = Date(timeIntervalSince1970: TimeInterval(ts)!) + } + callback(message) + } if let messageToEmitOnSubscribe { Task { callback(messageToEmitOnSubscribe) } - } else { - channelSubscriptions.append((name, callback)) } + channelSubscriptions.append((name, callback)) return ARTEventListener() } @@ -150,18 +175,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() } From c07119700bedc6a0ea1552347fc332c16af543d8 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sat, 5 Apr 2025 22:10:28 +0200 Subject: [PATCH 4/7] Add reaction tests. --- .../DefaultRoomReactionsTests.swift | 61 ++++++++++++++++--- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift index 147db383..ce1665f9 100644 --- a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -29,20 +29,65 @@ struct DefaultRoomReactionsTests { #expect(channel.publishedMessages.last?.extras == ["headers": ["someHeadersKey": "someHeadersValue"], "ephemeral": true]) } - // @spec CHA-ER4 + // @spec CHA-ER4a @Test - func subscribe_returnsSubscription() async throws { - // all setup values here are arbitrary + func subscriptionCanBeRegisteredToReceiveReactionEvents() async throws { // Given - let channel = MockRealtimeChannel(name: "basketball::$chat") + let channel = MockRealtimeChannel( + messageJSONToEmitOnSubscribe: [ + "name": "roomReaction", + "clientId": "who-sent-the-message", + "data": [ + "type": ":like:", + "metadata": [ + "foo": "bar", + ], + ], + "timestamp": "1726232498871", + "extras": [ + "headers": [ + "baz": "qux", + ], + ], + ] as? [String: any Sendable] + ) + let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) // When + let reactionSubscription = defaultRoomReactions.subscribe { reaction in + // Then + #expect(reaction.type == ":like:") + } + } + + // 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( + messageJSONToEmitOnSubscribe: [ + "foo": "bar" // malformed reaction message + ], + 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 - let subscription: Subscription? = defaultRoomReactions.subscribe() - - // Then - #expect(subscription != nil) + let reactionSubscription = defaultRoomReactions.subscribe { reaction in + // Then: `messageJSONToEmitOnSubscribe` is processed ahead of `messageToEmitOnSubscribe` in the mock, but the first message is not the malformed one + #expect(reaction.type == ":like:") + } } } From d4d408f54b44da80f2c906329eaa3dba5b57e312 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Fri, 23 May 2025 18:10:34 +0200 Subject: [PATCH 5/7] Fix reactions `subscribe` method signature. --- Sources/AblyChat/DefaultRoomReactions.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index d3176db1..309abc5b 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) -> SubscriptionHandle { implementation.subscribe(callback) } From 016d94cc3aef84a749cda5404807dccd10c7a4d2 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Fri, 23 May 2025 18:10:52 +0200 Subject: [PATCH 6/7] Add reaction tests. --- .../DefaultRoomReactionsTests.swift | 45 +++++++++++-------- .../Mocks/MockRealtimeChannel.swift | 16 +++---- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift index ce1665f9..2c4f7ebf 100644 --- a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -30,34 +30,41 @@ struct DefaultRoomReactionsTests { } // @spec CHA-ER4a + // @spec CHA-ER4b @Test func subscriptionCanBeRegisteredToReceiveReactionEvents() async throws { // Given + 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 + } + let channel = MockRealtimeChannel( - messageJSONToEmitOnSubscribe: [ - "name": "roomReaction", - "clientId": "who-sent-the-message", - "data": [ - "type": ":like:", - "metadata": [ - "foo": "bar", - ], - ], - "timestamp": "1726232498871", - "extras": [ - "headers": [ - "baz": "qux", - ], - ], - ] as? [String: any Sendable] + messageToEmitOnSubscribe: generateMessage(serial: "1", reactionType: ":like:") ) let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) // When - let reactionSubscription = defaultRoomReactions.subscribe { reaction in + let subscriptionHandle = defaultRoomReactions.subscribe { reaction in // Then #expect(reaction.type == ":like:") } + + // CHA-ER4b + subscriptionHandle.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 @@ -67,7 +74,7 @@ struct DefaultRoomReactionsTests { // Given let channel = MockRealtimeChannel( messageJSONToEmitOnSubscribe: [ - "foo": "bar" // malformed reaction message + "foo": "bar", // malformed reaction message ], messageToEmitOnSubscribe: { let message = ARTMessage() @@ -85,7 +92,7 @@ struct DefaultRoomReactionsTests { let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) // When - let reactionSubscription = defaultRoomReactions.subscribe { reaction in + defaultRoomReactions.subscribe { reaction in // Then: `messageJSONToEmitOnSubscribe` is processed ahead of `messageToEmitOnSubscribe` in the mock, but the first message is not the malformed one #expect(reaction.type == ":like:") } diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index e9df8218..e6c4c941 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -29,7 +29,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { initialErrorReason: ARTErrorInfo? = nil, attachBehavior: AttachOrDetachBehavior? = nil, detachBehavior: AttachOrDetachBehavior? = nil, - messageJSONToEmitOnSubscribe: [String: Sendable]? = nil, + messageJSONToEmitOnSubscribe: [String: JSONValue]? = nil, messageToEmitOnSubscribe: ARTMessage? = nil, stateChangeToEmitForListener: ARTChannelStateChange? = nil ) { @@ -133,7 +133,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { try result.get() } - let messageJSONToEmitOnSubscribe: [String: Sendable]? + let messageJSONToEmitOnSubscribe: [String: JSONValue]? let messageToEmitOnSubscribe: ARTMessage? // 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. @@ -141,20 +141,20 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener? { if let json = messageJSONToEmitOnSubscribe { - let message = ARTMessage(name: nil, data: json["data"] ?? "") - if let action = json["action"] as? UInt { + let message = ARTMessage(name: nil, data: json["data"]?.toAblyCocoaData ?? "") + if let action = json["action"]?.numberValue as? UInt { message.action = ARTMessageAction(rawValue: action) ?? .create } - if let serial = json["serial"] as? String { + if let serial = json["serial"]?.stringValue { message.serial = serial } - if let clientId = json["clientId"] as? String { + if let clientId = json["clientId"]?.stringValue { message.clientId = clientId } - if let extras = json["extras"] as? ARTJsonCompatible { + if let extras = json["extras"]?.objectValue?.toARTJsonCompatible { message.extras = extras } - if let ts = json["timestamp"] as? String { + if let ts = json["timestamp"]?.stringValue { message.timestamp = Date(timeIntervalSince1970: TimeInterval(ts)!) } callback(message) From 78ab16b930b013518dfae37e0458444d13c51bdf Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sat, 24 May 2025 13:19:40 +0200 Subject: [PATCH 7/7] Add connection tests. --- Sources/AblyChat/ChatClient.swift | 4 +- Sources/AblyChat/DefaultConnection.swift | 7 +- Sources/AblyChat/TimerManager.swift | 8 +- .../DefaultConnectionTests.swift | 301 ++++++++++++++++++ Tests/AblyChatTests/Helpers/Helpers.swift | 12 + .../AblyChatTests/Mocks/MockConnection.swift | 11 +- .../Mocks/MockSuppliedRealtime.swift | 44 ++- .../Mocks/MockTimerManager.swift | 33 ++ 8 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 Tests/AblyChatTests/DefaultConnectionTests.swift create mode 100644 Tests/AblyChatTests/Mocks/MockTimerManager.swift diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index b4f915fe..106b9948 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -76,7 +76,7 @@ public class DefaultChatClient: ChatClient { self.init(realtime: suppliedRealtime, clientOptions: clientOptions, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory()) } - internal init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?, internalRealtimeClientFactory: any InternalRealtimeClientFactory) { + internal init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?, internalRealtimeClientFactory: any InternalRealtimeClientFactory, timerManager: TimerManagerProtocol = TimerManager(clock: SystemClock())) { self.realtime = suppliedRealtime self.clientOptions = clientOptions ?? .init() @@ -86,7 +86,7 @@ public class DefaultChatClient: ChatClient { logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel) let roomFactory = DefaultRoomFactory() rooms = DefaultRooms(realtime: internalRealtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory) - connection = DefaultConnection(realtime: internalRealtime) + connection = DefaultConnection(realtime: internalRealtime, timerManager: timerManager) } public nonisolated var clientID: String { diff --git a/Sources/AblyChat/DefaultConnection.swift b/Sources/AblyChat/DefaultConnection.swift index 29a5cedb..40c7cc57 100644 --- a/Sources/AblyChat/DefaultConnection.swift +++ b/Sources/AblyChat/DefaultConnection.swift @@ -2,16 +2,17 @@ import Ably internal final class DefaultConnection: Connection { private let realtime: any InternalRealtimeClientProtocol - private let timerManager = TimerManager(clock: SystemClock()) + private let timerManager: TimerManagerProtocol // (CHA-CS2a) The chat client must expose its current connection status. internal private(set) var status: ConnectionStatus // (CHA-CS2b) The chat client must expose the latest error, if any, associated with its current status. internal private(set) var error: ARTErrorInfo? - internal init(realtime: any InternalRealtimeClientProtocol) { + internal init(realtime: any InternalRealtimeClientProtocol, timerManager: TimerManagerProtocol) { // (CHA-CS3) The initial status and error of the connection will be whatever status the realtime client returns whilst the connection status object is constructed. self.realtime = realtime + self.timerManager = timerManager status = .init(from: realtime.connection.state) error = realtime.connection.errorReason } @@ -73,7 +74,7 @@ internal final class DefaultConnection: Connection { } // (CHA-CS5b) Not withstanding CHA-CS5a. If a connection state event is observed from the underlying realtime library, the client must emit a status change event. The current status of that event shall reflect the status change in the underlying realtime library, along with the accompanying error. - callback(statusChange) +// callback(statusChange) // this call shouldn't be here - "Not withstanding CHA-CS5a" means just that I guess. // update local state and error error = stateChange.reason status = currentState diff --git a/Sources/AblyChat/TimerManager.swift b/Sources/AblyChat/TimerManager.swift index 4552fed1..baf8c986 100644 --- a/Sources/AblyChat/TimerManager.swift +++ b/Sources/AblyChat/TimerManager.swift @@ -1,7 +1,13 @@ import Foundation @MainActor -internal final class TimerManager { +public protocol TimerManagerProtocol { + func setTimer(interval: TimeInterval, handler: @escaping @MainActor () -> Void) + func cancelTimer() + func hasRunningTask() -> Bool +} + +internal final class TimerManager: TimerManagerProtocol { private var currentTask: Task? private let clock: Clock diff --git a/Tests/AblyChatTests/DefaultConnectionTests.swift b/Tests/AblyChatTests/DefaultConnectionTests.swift new file mode 100644 index 00000000..897658fe --- /dev/null +++ b/Tests/AblyChatTests/DefaultConnectionTests.swift @@ -0,0 +1,301 @@ +import Ably +@testable import AblyChat +import Testing + +@MainActor +struct DefaultConnectionTests { + // @spec CHA-CS2a + // @spec CHA-CS2b + // @spec CHA-CS3 + @Test + func chatClientMustExposeItsCurrentStatus() async throws { + // Given: An instance of DefaultChatClient + let options = ARTClientOptions(key: "fake:key") + options.autoConnect = false + let realtime = ARTRealtime(options: options) + let client = DefaultChatClient(realtime: realtime, clientOptions: nil) + + // When: the connection status object is constructed + let status = client.connection.status + let error = client.connection.error + + #expect(status == .initialized) + #expect(error == nil) + } + + // @spec CHA-CS4a + // @spec CHA-CS4b + // @spec CHA-CS4c + // @spec CHA-CS4d + @Test + func chatClientMustAllowItsConnectionStatusToBeObserved() async throws { + // Given: An instance of DefaultChatClient and a connection error + let connection = MockSuppliedRealtime.Connection(state: .connecting) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: connection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let connectionError = ARTErrorInfo.createUnknownError() + + // When + // (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events. + client.connection.onStatusChange { statusChange in + // Then + // (CHA-CS4a) Connection status update events must contain the newly entered connection status. + // (CHA-CS4b) Connection status update events must contain the previous connection status. + // (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status. + #expect(statusChange.current == .disconnected) + #expect(statusChange.previous == .connecting) + #expect(statusChange.error == connectionError) + } + + connection.emit(.disconnected, event: .disconnected, error: connectionError) + } + + // @specUntested CHA-CS4f - We don't have method for unsubscribing all subscriptions at once in ``Connection`` protocol. Each subscription handle only unsubscribe its own listener (CHA-CS4e). + + // @spec CHA-CS5a1 + // @spec CHA-CS5a4 + // @spec CHA-CS4e + @Test + func whenConnectionGoesFromConnectedToDisconnectedTransientDisconnectTimerStarts() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let connection = MockSuppliedRealtime.Connection(state: .connected) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: connection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Status subscription + var statusChanges = [ConnectionStatusChange]() + let subscriptionHandle = defaultConnection.onStatusChange { statusChange in + statusChanges.append(statusChange) + } + + // When: + + // Realtime connection status transitions from CONNECTED to DISCONNECTED + let connectionError = ARTErrorInfo.create(withCode: 0, message: "Connection error") + connection.emit(.disconnected, event: .disconnected, error: connectionError) + + // Then: + + // Transient disconnect timer interval is 5 seconds + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "setTimer(interval:handler:)", + arguments: ["interval": 5.0] + ) + ) + + // CHA-CS5a1: "the chat client connection status must not change" + #expect(statusChanges.count == 0) + + // When + mockTimerManager.expireTimer() + + // Then: + // CHA-CS5a4: Status change was generated by `emit:` when transient timer has expired + #expect(statusChanges[0].error == connectionError) + + // When + subscriptionHandle.unsubscribe() + connection.emit(.connected, event: .connected) + + // Then: + // CHA-CS4e: no new events recorded after `unsubscribe` call + #expect(statusChanges.count == 1) + } + + // @spec CHA-CS5a2 + @Test + func whenConnectionGoesFromDisconnectedToConnectingNoStatusChangeIsEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Status subscription + var statusChanges = [ConnectionStatusChange]() + defaultConnection.onStatusChange { statusChange in + statusChanges.append(statusChange) + } + + // When: + + // Starting timer by going to DISCONNECTED + realtimeConnection.emit(.disconnected, event: .disconnected) + + // Then: + + // Transient disconnect timer interval is 5 seconds + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "setTimer(interval:handler:)", + arguments: ["interval": 5.0] + ) + ) + + // When: + + // Realtime connection status changes to CONNECTING + realtimeConnection.emit(.connecting, event: .connecting) + + // Or DISCONNECTED + realtimeConnection.emit(.disconnected, event: .disconnected) + + // Then: + // CHA-CS5a2: the library must not emit a status change + #expect(statusChanges.count == 0) + } + + // @specOneOf(1/3) CHA-CS5a3 - Connection goes to CONNECTED + @Test + func whenConnectionGoesToConnectedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Status subscription + var statusChanges = [ConnectionStatusChange]() + defaultConnection.onStatusChange { statusChange in + statusChanges.append(statusChange) + } + + // When: + + // Starting timer by going to DISCONNECTED + realtimeConnection.emit(.disconnected, event: .disconnected) + + // Then: + + // Transient disconnect timer interval is 5 seconds + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "setTimer(interval:handler:)", + arguments: ["interval": 5.0] + ) + ) + + // When: + + // And the realtime connection status changes to CONNECTED + realtimeConnection.emit(.connected, event: .connected) + + // Then: + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "cancelTimer", + arguments: [:] + ) + ) + + // Then: + // CHA-CS5a3: The superseding status change shall be emitted + #expect(statusChanges.count == 1) + #expect(statusChanges[0].current == .connected) + } + + // @specOneOf(2/3) CHA-CS5a3 - Connection goes to SUSPENDED + @Test + func whenConnectionGoesToSuspendedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Status subscription + var statusChanges = [ConnectionStatusChange]() + defaultConnection.onStatusChange { statusChange in + statusChanges.append(statusChange) + } + + // When: + + // Starting timer by going to DISCONNECTED + realtimeConnection.emit(.disconnected, event: .disconnected) + + // Then: + + // Transient disconnect timer interval is 5 seconds + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "setTimer(interval:handler:)", + arguments: ["interval": 5.0] + ) + ) + + // When: + + // And the realtime connection status changes to SUSPENDED + realtimeConnection.emit(.suspended, event: .suspended) + + // Then: + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "cancelTimer", + arguments: [:] + ) + ) + + // Then: + // CHA-CS5a3: The superseding status change shall be emitted + #expect(statusChanges.count == 1) + #expect(statusChanges[0].current == .suspended) + } + + // @specOneOf(3/3) CHA-CS5a3 - Connection goes to FAILED + @Test + func whenConnectionGoesToFailedStatusChangeShouldBeEmitted() async throws { + // Given: + // An instance of DefaultChatClient, connected realtime connection and default chat connection + let realtimeConnection = MockSuppliedRealtime.Connection(state: .connected) + let suppliedRealtime = MockSuppliedRealtime(createWrapperSDKProxyReturnValue: .init(connection: realtimeConnection)) + let mockTimerManager = MockTimerManager() + let client = DefaultChatClient(realtime: suppliedRealtime, clientOptions: nil, internalRealtimeClientFactory: DefaultInternalRealtimeClientFactory(), timerManager: mockTimerManager) + let defaultConnection = try #require(client.connection as? DefaultConnection) + + // Status subscription + var statusChanges = [ConnectionStatusChange]() + defaultConnection.onStatusChange { statusChange in + statusChanges.append(statusChange) + } + + // When: + + // Starting timer by going to DISCONNECTED + realtimeConnection.emit(.disconnected, event: .disconnected) + + // Then: + + // Transient disconnect timer interval is 5 seconds + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "setTimer(interval:handler:)", + arguments: ["interval": 5.0] + ) + ) + + // When: + + // And the realtime connection status changes to FAILED + realtimeConnection.emit(.failed, event: .failed) + + // Then: + #expect(mockTimerManager.callRecorder.waitUntil( + hasMatching: "cancelTimer", + arguments: [:] + ) + ) + + // Then: + // CHA-CS5a3: The superseding status change shall be emitted + #expect(statusChanges.count == 1) + #expect(statusChanges[0].current == .failed) + } + + // @specUntested CHA-CS5b - The implementation of this part is not clear. I've commented extra call for emitting event because I think it's in the wrong place, see `callback(statusChange)` call with "this call shouldn't be here" comment in "DefaultConnection.swift". +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index f69df1c4..215db4b2 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -101,6 +101,8 @@ func compareAny(_ any1: Any?, with any2: Any?) -> Bool { } if let any1 = any1 as? Int, let any2 = any2 as? Int { return any1 == any2 + } else if let any1 = any1 as? Double, let any2 = any2 as? Double { + return any1 == any2 } else if let any1 = any1 as? Bool, let any2 = any2 as? Bool { return any1 == any2 } else if let any1 = any1 as? String, let any2 = any2 as? String { @@ -162,6 +164,16 @@ class MockMethodCallRecorder: @unchecked Sendable { mutex.unlock() return result } + + func waitUntil(hasMatching signature: String, arguments: [String: Any], forMaxTimeout timeout: TimeInterval = 1.0) -> Bool { + let startedAt = Date() + while !hasRecord(matching: signature, arguments: arguments) { + if startedAt.distance(to: Date()) > timeout { + return false + } + } + return true + } } private extension [MockMethodCallRecorder.MethodArgument] { diff --git a/Tests/AblyChatTests/Mocks/MockConnection.swift b/Tests/AblyChatTests/Mocks/MockConnection.swift index 17c926d9..61a56902 100644 --- a/Tests/AblyChatTests/Mocks/MockConnection.swift +++ b/Tests/AblyChatTests/Mocks/MockConnection.swift @@ -6,16 +6,23 @@ final class MockConnection: InternalConnectionProtocol { let errorReason: ARTErrorInfo? + private var stateCallback: ((ARTConnectionStateChange) -> Void)? + init(state: ARTRealtimeConnectionState = .initialized, errorReason: ARTErrorInfo? = nil) { self.state = state self.errorReason = errorReason } - func on(_: @escaping @MainActor (ARTConnectionStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + func on(_ callback: @escaping @MainActor (ARTConnectionStateChange) -> Void) -> ARTEventListener { + stateCallback = callback + return ARTEventListener() } func off(_: ARTEventListener) { + stateCallback = nil + } + + func off() { fatalError("Not implemented") } } diff --git a/Tests/AblyChatTests/Mocks/MockSuppliedRealtime.swift b/Tests/AblyChatTests/Mocks/MockSuppliedRealtime.swift index 99f01f9e..14d24b8d 100644 --- a/Tests/AblyChatTests/Mocks/MockSuppliedRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockSuppliedRealtime.swift @@ -4,7 +4,7 @@ import Foundation // This mock isn't used much in the tests, since inside the SDK we mainly use `InternalRealtimeClientProtocol` (whose mock is ``MockRealtime``). final class MockSuppliedRealtime: NSObject, SuppliedRealtimeClientProtocol, @unchecked Sendable { - let connection = Connection() + let connection: Connection let channels = Channels() let createWrapperSDKProxyReturnValue: MockSuppliedRealtime? @@ -21,8 +21,10 @@ final class MockSuppliedRealtime: NSObject, SuppliedRealtimeClientProtocol, @unc } init( + connection: Connection = .init(), createWrapperSDKProxyReturnValue: MockSuppliedRealtime? = nil ) { + self.connection = connection self.createWrapperSDKProxyReturnValue = createWrapperSDKProxyReturnValue } @@ -336,24 +338,28 @@ final class MockSuppliedRealtime: NSObject, SuppliedRealtimeClientProtocol, @unc } } - final class Connection: NSObject, ConnectionProtocol { - var id: String? { - fatalError("Not implemented") - } + final class Connection: NSObject, ConnectionProtocol, @unchecked Sendable { + let state: ARTRealtimeConnectionState - var key: String? { - fatalError("Not implemented") + let errorReason: ARTErrorInfo? + + private let stateCallbackLock = NSLock() + private var stateCallback: ((ARTConnectionStateChange) -> Void)? + + init(state: ARTRealtimeConnectionState = .initialized, errorReason: ARTErrorInfo? = nil) { + self.state = state + self.errorReason = errorReason } - var maxMessageSize: Int { + var id: String? { fatalError("Not implemented") } - var state: ARTRealtimeConnectionState { + var key: String? { fatalError("Not implemented") } - var errorReason: ARTErrorInfo? { + var maxMessageSize: Int { fatalError("Not implemented") } @@ -381,8 +387,11 @@ final class MockSuppliedRealtime: NSObject, SuppliedRealtimeClientProtocol, @unc fatalError("Not implemented") } - func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + func on(_ callback: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { + stateCallbackLock.withLock { + stateCallback = callback + } + return ARTEventListener() } func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener { @@ -398,11 +407,20 @@ final class MockSuppliedRealtime: NSObject, SuppliedRealtimeClientProtocol, @unc } func off(_: ARTEventListener) { - fatalError("Not implemented") + stateCallbackLock.withLock { + stateCallback = nil + } } func off() { fatalError("Not implemented") } + + func emit(_ state: ARTRealtimeConnectionState, event: ARTRealtimeConnectionEvent, error: ARTErrorInfo? = nil) { + let stateChange = ARTConnectionStateChange(current: state, previous: self.state, event: event, reason: error) + stateCallbackLock.withLock { + stateCallback?(stateChange) + } + } } } diff --git a/Tests/AblyChatTests/Mocks/MockTimerManager.swift b/Tests/AblyChatTests/Mocks/MockTimerManager.swift new file mode 100644 index 00000000..981e877f --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockTimerManager.swift @@ -0,0 +1,33 @@ +@testable import AblyChat +import Foundation + +@MainActor +internal final class MockTimerManager: TimerManagerProtocol { + let callRecorder = MockMethodCallRecorder() + + private var handler: (@MainActor @Sendable () -> Void)? + + internal func setTimer(interval: TimeInterval, handler: @escaping @MainActor @Sendable () -> Void) { + callRecorder.addRecord( + signature: "setTimer(interval:handler:)", + arguments: ["interval": interval] + ) + self.handler = handler + } + + internal func cancelTimer() { + handler = nil + callRecorder.addRecord( + signature: "cancelTimer", + arguments: [:] + ) + } + + internal func hasRunningTask() -> Bool { + handler != nil + } + + internal func expireTimer() { + handler?() + } +}