diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 122756a..70b98a7 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -111,7 +111,7 @@ public struct SendMessageParams: Sendable { * Options for querying messages in a chat room. */ public struct QueryOptions: Sendable { - public enum ResultOrder: Sendable { + public enum OrderBy: Sendable { case oldestFirst case newestFirst } @@ -143,15 +143,13 @@ public struct QueryOptions: Sendable { * The direction to query messages in. * If ``ResultOrder/oldestFirst``, the response will include messages from the start of the time window to the end. * If ``ResultOrder/newestFirst``, the response will include messages from the end of the time window to the start. - * - * Defaults to `oldestFirst`. */ - public var orderBy: ResultOrder? + public var orderBy: OrderBy? // (CHA-M5g) The subscribers subscription point must be additionally specified (internally, by us) in the fromSerial query parameter. internal var fromSerial: String? - public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: QueryOptions.ResultOrder? = nil) { + public init(start: Date? = nil, end: Date? = nil, limit: Int? = nil, orderBy: QueryOptions.OrderBy? = nil) { self.start = start self.end = end self.limit = limit diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index 025639e..3271342 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -155,11 +155,6 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { */ func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) async -> Subscription - /// Same as calling ``subscribe(event:bufferingPolicy:)`` with ``BufferingPolicy.unbounded``. - /// - /// The `Presence` protocol provides a default implementation of this method. - func subscribe(event: PresenceEventType) async -> Subscription - /** * Subscribes a given listener to different presense events in the chat room. * @@ -171,12 +166,46 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { */ func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) async -> Subscription + /// Same as calling ``enter(data:)`` with `nil`. + /// + /// The `Presence` protocol provides a default implementation of this method. + func enter() async throws + + /// Same as calling ``update(data:)`` with `nil`. + /// + /// The `Presence` protocol provides a default implementation of this method. + func update() async throws + + /// Same as calling ``leave(data:)`` with `nil`. + /// + /// The `Presence` protocol provides a default implementation of this method. + func leave() async throws + + /// Same as calling ``subscribe(event:bufferingPolicy:)`` with ``BufferingPolicy.unbounded``. + /// + /// The `Presence` protocol provides a default implementation of this method. + func subscribe(event: PresenceEventType) async -> Subscription + /// Same as calling ``subscribe(events:bufferingPolicy:)`` with ``BufferingPolicy.unbounded``. /// /// The `Presence` protocol provides a default implementation of this method. func subscribe(events: [PresenceEventType]) async -> Subscription } +public extension Presence { + func enter() async throws { + try await enter(data: nil) + } + + func update() async throws { + try await update(data: nil) + } + + func leave() async throws { + try await leave(data: nil) + } +} + public extension Presence { func subscribe(event: PresenceEventType) async -> Subscription { await subscribe(event: event, bufferingPolicy: .unbounded) diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index a596fca..3859059 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -192,6 +192,9 @@ internal actor DefaultRoomLifecycleManager, error: ARTErrorInfo) case suspended(retryOperationID: UUID, error: ARTErrorInfo) + // `rundownOperationTask` is exposed so that tests can wait for the triggered RUNDOWN operation to complete. + case failedAwaitingStartOfRundownOperation(rundownOperationTask: Task, error: ARTErrorInfo) + case failedAndPerformingRundownOperation(rundownOperationID: UUID, error: ARTErrorInfo) case failed(error: ARTErrorInfo) case releasing(releaseOperationID: UUID) case released @@ -216,6 +219,10 @@ internal actor DefaultRoomLifecycleManager 0) - #expect(await contributors[2].channel.detachCallCount > 0) - #expect(await contributors[1].channel.detachCallCount == 0) - } - - // @spec CHA-RL1h6 - @Test - func attach_whenChannelDetachTriggered_ifADetachFailsItIsRetriedUntilSuccess() async throws { - // Given: A room with the following contributors, in the following order: - // - // 0. a channel: - // - for whom calling `attach` will complete successfully, putting it in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) - // - and for whom subsequently calling `detach` will fail on the first attempt and succeed on the second - // 1. a channel for whom calling `attach` will fail, putting it in the FAILED state (we won’t make any assertions about this channel; it’s just to trigger the room’s channel detach behaviour) - - let detachResult = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachBehavior in - if callCount == 1 { - return .failure(.create(withCode: 123, message: "")) + // - the lifecycle manager schedules a RUNDOWN operation + // - when we wait for this RUNDOWN operation to complete, we confirm that it has occured by checking for its side effects, namely that: + // - the lifecycle manager has called `detach` on contributors 0 and 2 + // - the lifecycle manager has not called `detach` on contributor 1 + + let rundownOperationTask = try #require(await managerStatusSubscription.compactMap { managerStatusChange in + if case let .failedAwaitingStartOfRundownOperation(rundownOperationTask, _) = managerStatusChange.current { + rundownOperationTask } else { - return .success + nil } } + .first { _ in true }) - let contributors = [ - createContributor( - attachBehavior: .completeAndChangeState(.success, newState: .attached), - detachBehavior: .fromFunction(detachResult) - ), - createContributor( - attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) - ), - ] - - let manager = await createManager(contributors: contributors) - - // When: `performAttachOperation()` is called on the lifecycle manager - try? await manager.performAttachOperation() + _ = await rundownOperationTask.value - // Then: the lifecycle manager will call `detach` twice on contributor 0 (i.e. it will retry the failed detach) - #expect(await contributors[0].channel.detachCallCount == 2) + #expect(await contributors[0].channel.detachCallCount > 0) + #expect(await contributors[2].channel.detachCallCount > 0) + #expect(await contributors[1].channel.detachCallCount == 0) } // MARK: - DETACH operation @@ -1306,7 +1285,7 @@ struct DefaultRoomLifecycleManagerTests { let contributors = [ createContributor( attachBehavior: .success, - detachBehavior: .success, // So that the detach performed by CHA-RL1h5’s rollback of the attachment cycle succeeds + detachBehavior: .success, // So that the detach performed by CHA-RL1h5’s triggered RUNDOWN succeeds subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( .init( current: .attached, @@ -1322,7 +1301,7 @@ struct DefaultRoomLifecycleManagerTests { ), createContributor( attachBehavior: .success, - detachBehavior: .success // So that the CHA-RL5a detachment cycle completes (not related to this test) and so that the detach performed by CHA-RL1h5’s rollback of the attachment cycle succeeds + detachBehavior: .success // So that the CHA-RL5a detachment cycle completes (not related to this test) and so that the detach performed by CHA-RL1h5’s triggered RUNDOWN succeeds ), ] @@ -1333,22 +1312,105 @@ struct DefaultRoomLifecycleManagerTests { let roomStatusSubscription = await manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let failedStatusChange = roomStatusSubscription.failedElements().first { _ in true } + let managerStatusSubscription = await manager.testsOnly_onStatusChange() + // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) // Then: The room performs a CHA-RL1e attachment cycle (we sufficiently satisfy ourselves of this fact by checking that it’s attempted to attach all of the channels up to and including the one for which attachment fails, and subsequently detached all channels except for the FAILED one), transitions to FAILED, and the RETRY operation completes + + // We first wait for CHA-RLh5’s triggered RUNDOWN operation to complete, so that we can make assertions about its side effects + let rundownOperationTask = try #require(await managerStatusSubscription.compactMap { managerStatusChange in + if case let .failedAwaitingStartOfRundownOperation(rundownOperationTask, _) = managerStatusChange.current { + rundownOperationTask + } else { + nil + } + } + .first { _ in true }) + + _ = await rundownOperationTask.value + #expect(await contributors[0].channel.attachCallCount == 1) #expect(await contributors[1].channel.attachCallCount == 1) #expect(await contributors[2].channel.attachCallCount == 0) - #expect(await contributors[0].channel.detachCallCount == 1) // from CHA-RL1h5’s rollback of the attachment cycle + #expect(await contributors[0].channel.detachCallCount == 1) // from CHA-RL1h5’s triggered RUNDOWN #expect(await contributors[1].channel.detachCallCount == 1) // from CHA-RL5a detachment cycle - #expect(await contributors[2].channel.detachCallCount == 2) // from CHA-RL5a detachment cycle and CHA-RL1h5’s rollback of the attachment cycle + #expect(await contributors[2].channel.detachCallCount == 2) // from CHA-RL5a detachment cycle and CHA-RL1h5’s triggered RUNDOWN _ = try #require(await failedStatusChange) #expect(await manager.roomStatus.isFailed) } + // MARK: - RUNDOWN operation + + // @specOneOf(2/2) CHA-RL1h5 - Tests the behaviour of the RUNDOWN operation - see TODO on `performRundownOperation` for my interpretation of CHA-RL1h5, in which I introduce RUNDOWN + @Test + func rundown_detachesAllNonFailedChannels() async throws { + // Given: A room with the following contributors, in the following order: + // + // 0. a channel in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) + // 1. a channel in the FAILED state + // 2. a channel in the INITIALIZED state (another arbitrarily-chosen state that is not FAILED) + // + // for which, when `detach` is called on contributors 0 and 2 (i.e. the non-FAILED contributors), it completes successfully + let contributors = [ + createContributor( + initialState: .attached, + detachBehavior: .success + ), + createContributor( + initialState: .failed + ), + createContributor( + detachBehavior: .success + ), + ] + + let manager = await createManager(contributors: contributors) + + // When: `performRundownOperation()` is called on the lifecycle manager + await manager.performRundownOperation(errorForFailedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: + // + // - the lifecycle manager calls `detach` on contributors 0 and 2 + // - the lifecycle manager does not call `detach` on contributor 1 + // - the call to `performRundownOperation()` completes + #expect(await contributors[0].channel.detachCallCount == 1) + #expect(await contributors[2].channel.detachCallCount == 1) + #expect(await contributors[1].channel.detachCallCount == 0) + } + + // @spec CHA-RL1h6 - see TODO on `performRundownOperation` for my interpretation of CHA-RL1h5, in which I introduce RUNDOWN + @Test + func rundown_ifADetachFailsItIsRetriedUntilSuccess() async throws { + // Given: A room with a contributor in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) and for whom calling `detach` will fail on the first attempt and succeed on the second + + let detachResult = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachBehavior in + if callCount == 1 { + return .failure(.create(withCode: 123, message: "")) + } else { + return .success + } + } + + let contributors = [ + createContributor( + detachBehavior: .fromFunction(detachResult) + ), + ] + + let manager = await createManager(contributors: contributors) + + // When: `performRundownOperation()` is called on the lifecycle manager + await manager.performRundownOperation(errorForFailedStatus: .createUnknownError() /* arbitrary */ ) + + // Then: the lifecycle manager calls `detach` twice on the contributor (i.e. it retries the failed detach) + #expect(await contributors[0].channel.detachCallCount == 2) + } + // MARK: - Handling contributor UPDATE events // @spec CHA-RL4a1