diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index 89796ca..feab42e 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -6,14 +6,16 @@ internal final class DefaultTyping: Typing { private let clientID: String private let logger: InternalLogger private let timeout: TimeInterval + private let maxPresenseGetRetryDuration: TimeInterval // Max duration as specified in CHA-T6c1 private let timerManager = TimerManager() - internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval) { + internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval, maxPresenseGetRetryDuration: TimeInterval = 30.0) { self.roomID = roomID self.featureChannel = featureChannel self.clientID = clientID self.logger = logger self.timeout = timeout + self.maxPresenseGetRetryDuration = maxPresenseGetRetryDuration } internal nonisolated var channel: any RealtimeChannelProtocol { @@ -32,18 +34,21 @@ internal final class DefaultTyping: Typing { logger.log(message: "Received presence message: \(message)", level: .debug) Task { let currentEventID = await eventTracker.updateEventID() - let maxRetryDuration: TimeInterval = 30.0 // Max duration as specified in CHA-T6c1 let baseDelay: TimeInterval = 1.0 // Initial retry delay let maxDelay: TimeInterval = 5.0 // Maximum delay between retries var totalElapsedTime: TimeInterval = 0 var delay: TimeInterval = baseDelay - while totalElapsedTime < maxRetryDuration { + while totalElapsedTime < maxPresenseGetRetryDuration { do { // (CHA-T6c) When a presence event is received from the realtime client, the Chat client will perform a presence.get() operation to get the current presence set. This guarantees that we get a fully synced presence set. This is then used to emit the typing clients to the subscriber. let latestTypingMembers = try await get() - + #if DEBUG + for subscription in testPresenceGetTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif // (CHA-T6c2) If multiple presence events are received resulting in concurrent presence.get() calls, then we guarantee that only the “latest” event is emitted. That is to say, if presence event A and B occur in that order, then only the typing event generated by B’s call to presence.get() will be emitted to typing subscribers. let isLatestEvent = await eventTracker.isLatestEvent(currentEventID) guard isLatestEvent else { @@ -67,9 +72,19 @@ internal final class DefaultTyping: Typing { // Exponential backoff (double the delay) delay = min(delay * 2, maxDelay) + #if DEBUG + for subscription in testPresenceGetRetryTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif } } - logger.log(message: "Failed to fetch presence set after \(maxRetryDuration) seconds. Giving up.", level: .error) + #if DEBUG + for subscription in testPresenceGetRetryTypingEventSubscriptions { + subscription.unsubscribe() + } + #endif + logger.log(message: "Failed to fetch presence set after \(maxPresenseGetRetryDuration) seconds. Giving up.", level: .error) } } return subscription @@ -153,6 +168,11 @@ internal final class DefaultTyping: Typing { // (CHA-T5b) If typing is in progress, he CHA-T3 timeout is cancelled. The client then leaves presence. await timerManager.cancelTimer() channel.presence.leaveClient(clientID, data: nil) + #if DEBUG + for subscription in testStopTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif } else { // (CHA-T5a) If typing is not in progress, this operation is no-op. logger.log(message: "User is not typing. No need to leave presence.", level: .debug) @@ -202,12 +222,67 @@ internal final class DefaultTyping: Typing { try await stop() } } + #if DEBUG + for subscription in testStartTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif } } } } +#if DEBUG + /// The `DefaultTyping` emits a `TestTypingEvent` each time ``start`` or ``stop`` is called. + internal struct TestTypingEvent: Equatable { + let timestamp = Date() + } + + /// Subscription of typing start events for testing purposes. + private var testStartTypingEventSubscriptions: [Subscription] = [] + + /// Subscription of typing stop events for testing purposes. + private var testStopTypingEventSubscriptions: [Subscription] = [] + + /// Subscription of presence get events for testing purposes. + private var testPresenceGetTypingEventSubscriptions: [Subscription] = [] + + /// Subscription of retry presence get events for testing purposes. + private var testPresenceGetRetryTypingEventSubscriptions: [Subscription] = [] + + /// Returns a subscription which emits typing start events for testing purposes. + internal func testsOnly_subscribeToStartTestTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testStartTypingEventSubscriptions.append(subscription) + return subscription + } + + /// Returns a subscription which emits typing stop events for testing purposes. + internal func testsOnly_subscribeToStopTestTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testStopTypingEventSubscriptions.append(subscription) + return subscription + } + + /// Returns a subscription which emits presence get events for testing purposes. + internal func testsOnly_subscribeToPresenceGetTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testPresenceGetTypingEventSubscriptions.append(subscription) + return subscription + } + + /// Returns a subscription which emits retry presence get events for testing purposes. + internal func testsOnly_subscribeToPresenceGetRetryTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testPresenceGetRetryTypingEventSubscriptions.append(subscription) + return subscription + } +#endif } +#if DEBUG +extension DefaultTyping: @unchecked Sendable { } +#endif + private final actor EventTracker { private var latestEventID: UUID = .init() diff --git a/Tests/AblyChatTests/DefaultRoomPresenceTests.swift b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift index 1a63e39..adb96f6 100644 --- a/Tests/AblyChatTests/DefaultRoomPresenceTests.swift +++ b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift @@ -26,7 +26,7 @@ struct DefaultRoomPresenceTests { @Test func usersMayEnterPresence() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client2", logger: TestLogger()) @@ -51,7 +51,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -85,7 +85,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -121,7 +121,7 @@ struct DefaultRoomPresenceTests { @Test func failToEnterPresenceWhenRoomInInvalidState() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error)) @@ -147,7 +147,7 @@ struct DefaultRoomPresenceTests { @Test func usersMayUpdatePresence() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger()) @@ -171,7 +171,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -205,7 +205,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -241,7 +241,7 @@ struct DefaultRoomPresenceTests { @Test func failToUpdatePresenceWhenRoomInInvalidState() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error)) @@ -266,7 +266,7 @@ struct DefaultRoomPresenceTests { @Test func usersMayLeavePresence() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger()) @@ -285,7 +285,7 @@ struct DefaultRoomPresenceTests { @Test func ifUserIsPresent() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) // CHA-PR6d let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -306,7 +306,7 @@ struct DefaultRoomPresenceTests { @Test func retrieveAllTheMembersOfThePresenceSet() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -322,7 +322,7 @@ struct DefaultRoomPresenceTests { @Test func failToRetrieveAllTheMembersOfThePresenceSetWhenRoomInInvalidState() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error)) @@ -349,7 +349,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -383,7 +383,7 @@ struct DefaultRoomPresenceTests { let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor - let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -423,7 +423,7 @@ struct DefaultRoomPresenceTests { @Test func usersMaySubscribeToAllPresenceEvents() async throws { // Given - let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) // CHA-PR6d let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) @@ -485,7 +485,7 @@ struct DefaultRoomPresenceTests { @Test func onDiscontinuity() async throws { // Given - let realtimePresence = MockRealtimePresence([]) + let realtimePresence = MockRealtimePresence(members: []) let channel = MockRealtimeChannel(mockPresence: realtimePresence) let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger()) diff --git a/Tests/AblyChatTests/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift new file mode 100644 index 0000000..e4bcc63 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomTypingTests.swift @@ -0,0 +1,327 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomTypingTests { + // @spec CHA-T2 + // @spec CHA-T2d + @Test + func retrieveCurrentlyTypingClientIDs() async throws { + // Given + let typingPresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // When + let typingInfo = try await defaultTyping.get() + + // Then + #expect(typingInfo.sorted() == ["client1", "client2"]) + } + + // @specPartial CHA-T2c + @Test + func retrieveCurrentlyTypingClientIDsWhileAttaching() async throws { + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And presence get is called + _ = try await defaultTyping.get() + + // Then: The manager was waiting for its room status to change before presence `get` was called + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @specPartial CHA-T2c + @Test + func retrieveCurrentlyTypingClientIDsWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultTyping.get() + } catch { + // Then: An exception with status code of 500 should be thrown + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 500) + #expect(error.code == ErrorCode.roomInInvalidState.rawValue) + throw error + } + } + // Then: The manager were waiting for its room status to change from attaching + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @spec CHA-T2g + @Test + func failToRetrieveCurrentlyTypingClientIDsWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error)) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // Then + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultTyping.get() + } catch { + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 400) + #expect(error.localizedDescription.contains("attach")) + throw error + } + } + } + + // @spec CHA-T3 + @Test + func usersMayConfigureTimeoutForTyping() async throws { + // Given + let typingPresence = MockRealtimePresence(members: []) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success, mockPresence: typingPresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + + // Given + let timeout = 0.5 // default is 5 (seconds) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(typing: .init(timeout: timeout)), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let defaultTyping = try #require(room.typing as? DefaultTyping) + let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents() + + try await room.attach() + + // When + try await defaultTyping.start() + let typingStartedAt = Date() + + // Then: The `DefaultTyping` will emit typing stop event in `timeout` interval +/- + let typingStopped = try #require(await typingStoppedSubscription.first { _ in true }) + let interval = typingStartedAt.distance(to: typingStopped.timestamp) + #expect(interval.isEqual(to: timeout, tolerance: 0.1)) + } + + // @spec CHA-T4a + // @spec CHA-T4a1 + // @spec CHA-T5a + // @spec CHA-T5b + @Test + func usersMayIndicateThatTheyHaveStartedOrStoppedTyping() async throws { + // Given + let typingPresence = MockRealtimePresence(members: []) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // CHA-T4a + + // When + try await defaultTyping.start() + + // Then: CHA-T4a1 + var typingInfo = try await defaultTyping.get() + #expect(typingInfo == ["client1"]) + + // CHA-T5b + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + + // CHA-T5a + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + } + + // @spec CHA-T4a2 + // @spec CHA-T4b + @Test + func ifTypingIsAlreadyInProgressThenTimeoutIsExtended() async throws { + // Given + let timeout = 0.5 + let typingPresence = MockRealtimePresence(members: []) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: timeout) + + let typingStartedSubscription = defaultTyping.testsOnly_subscribeToStartTestTypingEvents() + let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents() + + // When: Typing is already in progress, the CHA-T3 timeout is extended to be timeoutMs from now + let timeoutExtension = 0.3 + try await defaultTyping.start() + try? await Task.sleep(nanoseconds: UInt64(timeoutExtension * 1_000_000_000)) + try await defaultTyping.start() // CHA-T4b + + let typingStarted = try #require(await typingStartedSubscription.first { _ in true }) + let typingStopped = try #require(await typingStoppedSubscription.first { _ in true }) // CHA-T4a2 + + // Then + let interval = typingStarted.timestamp.distance(to: typingStopped.timestamp) + #expect(interval.isEqual(to: timeout + timeoutExtension, tolerance: 0.1)) + } + + // @spec CHA-T6a + // @spec CHA-T6b + @Test + func usersMaySubscribeToTypingEvents() async throws { + // Given + let typingPresence = MockRealtimePresence(members: []) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // CHA-T6a + + // When + let subscription = await defaultTyping.subscribe() + try await defaultTyping.start() + + // Then + let typingEvent = try #require(await subscription.first { _ in true }) + #expect(typingEvent.currentlyTyping == ["client1"]) + + // CHA-T6b + + // When + subscription.unsubscribe() + try await defaultTyping.stop() + try await defaultTyping.start() + + // Then + let nilTypingEvento = await subscription.first { _ in true } + #expect(nilTypingEvento == nil) + } + + // @spec CHA-T6c + @Test + func whenPresenceEventReceivedClientWillPerformPresenceGet() async throws { + // Given + let typingPresence = MockRealtimePresence(members: []) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 0.1) + + // Given: subscription to typing events + let _ = await defaultTyping.subscribe() + + // Given: test presence.get() call subscription + let typingPresenceGetSubscription = defaultTyping.testsOnly_subscribeToPresenceGetTypingEvents() + + // When: A presence event is received from the realtime client + try await defaultTyping.start() + + // Then: The Chat client will perform a presence.get() operation + _ = try #require(await typingPresenceGetSubscription.first { _ in true }) + } + + // @spec CHA-T6c1 + @Test + func ifPresenceGetFailsItShallBeRetriedUsingBackoffWithJitter() async throws { + // Given: presence.get() failure + let presenceGetError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given + let maxPresenseGetRetryDuration = 1.0 // TODO: https://github.com/ably/ably-chat-swift/issues/216 + let typingPresence = MockRealtimePresence(members: [], presenceGetError: presenceGetError) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 0.1, maxPresenseGetRetryDuration: maxPresenseGetRetryDuration) + + // Given: subscription to typing events + let _ = await defaultTyping.subscribe() + + // Given: test presence.get() call failure subscription + let typingPresenceGetRetrySubscription = defaultTyping.testsOnly_subscribeToPresenceGetRetryTypingEvents() + + // When: A presence event is received from the realtime client and presence.get() operation fails + try await defaultTyping.start() + + // Then: It shall be retried using a backoff with jitter, up to a max timeout + let retryStartedAt = Date() + for await event in typingPresenceGetRetrySubscription { + print("Retrying presence.get() at \(event.timestamp)") + } + #expect(Date().distance(to: retryStartedAt) <= maxPresenseGetRetryDuration) + } + + // @spec CHA-T6c2 + @Test + func ifMultiplePresenceEventsReceivedThenOnlyTheLatestEventIsEmitted() async throws { + // TODO: https://github.com/ably/ably-chat-swift/issues/216 + } + + // @spec CHA-T7 + @Test + func onDiscontinuity() async throws { + // Given + let typingPresence = MockRealtimePresence(members: []) + let channel = MockRealtimeChannel(mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // When: The feature channel emits a discontinuity through `onDiscontinuity` + let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error + let discontinuitySubscription = await defaultTyping.onDiscontinuity() + await featureChannel.emitDiscontinuity(featureChannelDiscontinuity) + + // Then: The DefaultOccupancy instance emits this discontinuity through `onDiscontinuity` + let discontinuity = try #require(await discontinuitySubscription.first { _ in true }) + #expect(discontinuity == featureChannelDiscontinuity) + } +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 294a7be..fda4dbd 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -81,3 +81,9 @@ struct RoomLifecycleHelper { ) } } + +extension Double { + func isEqual(to other: Double, tolerance: Double) -> Bool { + self >= other && self < other + tolerance + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift index 46ca625..d02c2cb 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -5,15 +5,18 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc let syncComplete: Bool private var members: [ARTPresenceMessage] private var currentMember: ARTPresenceMessage? + private var subscribeCallback: ARTPresenceMessageCallback? + private var presenceGetError: ARTErrorInfo? - init(syncComplete: Bool = true, _ members: [ARTPresenceMessage]) { + init(syncComplete: Bool = true, members: [ARTPresenceMessage], presenceGetError: ARTErrorInfo? = nil) { self.syncComplete = syncComplete self.members = members self.currentMember = members.count == 1 ? members[0] : nil + self.presenceGetError = presenceGetError } func get(_ callback: @escaping ARTPresenceMessagesCallback) { - callback(members, nil) + callback(presenceGetError == nil ? members: nil, presenceGetError) } func get(_ query: ARTRealtimePresenceQuery, callback: @escaping ARTPresenceMessagesCallback) { @@ -49,12 +52,16 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc func enterClient(_ clientId: String, data: Any?) { currentMember = ARTPresenceMessage(clientId: clientId, data: data) members.append(currentMember!) + currentMember!.action = .enter + subscribeCallback?(currentMember!) } func enterClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { currentMember = ARTPresenceMessage(clientId: clientId, data: data) members.append(currentMember!) callback?(nil) + currentMember!.action = .enter + subscribeCallback?(currentMember!) } func updateClient(_ clientId: String, data: Any?) { @@ -62,7 +69,12 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc } func updateClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { - members.first { $0.clientId == clientId }?.data = data + guard let member = members.first(where: { $0.clientId == clientId }) else { + preconditionFailure("Client \(clientId) doesn't exist in this presence set.") + } + member.action = .update + member.data = data + subscribeCallback?(member) callback?(nil) } @@ -74,8 +86,12 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc members.removeAll { $0.clientId == clientId } } - func subscribe(_: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { - ARTEventListener() + func subscribe(_ callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + subscribeCallback = callback + for member in members { + subscribeCallback?(member) + } + return ARTEventListener() } func subscribe(attachCallback _: ARTCallback?, callback _: @escaping ARTPresenceMessageCallback) -> ARTEventListener? {