Skip to content

Commit

Permalink
Add the ability to attach and detach a room
Browse files Browse the repository at this point in the history
Based on the simplified requirements described in #19. This doesn’t
include the emission of a room status change; will do that in a separate
PR.
  • Loading branch information
lawrence-forooghian committed Sep 2, 2024
1 parent 0e9f703 commit c272c82
Show file tree
Hide file tree
Showing 9 changed files with 464 additions and 9 deletions.
30 changes: 30 additions & 0 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Concurrency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Ably

// This file contains extensions to ably-cocoa’s types, to make them easier to use in Swift concurrency.
// TODO: remove once we improve this experience in ably-cocoa (https://github.com/ably/ably-cocoa/issues/1967)

internal extension ARTRealtimeChannelProtocol {
func attachAsync() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Swift.Error>) in
attach { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}

func detachAsync() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Swift.Error>) in
detach { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
9 changes: 9 additions & 0 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Ably

// TODO: remove "@unchecked Sendable" once https://github.com/ably/ably-cocoa/issues/1962 done

extension ARTRealtime: RealtimeClientProtocol, @unchecked Sendable {}

extension ARTRealtimeChannels: RealtimeChannelsProtocol, @unchecked Sendable {}

extension ARTRealtimeChannel: RealtimeChannelProtocol, @unchecked Sendable {}
2 changes: 1 addition & 1 deletion Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public protocol ChatClient: AnyObject, Sendable {
var clientOptions: ClientOptions { get }
}

public typealias RealtimeClient = any(ARTRealtimeProtocol & Sendable)
public typealias RealtimeClient = any RealtimeClientProtocol

public actor DefaultChatClient: ChatClient {
public let realtime: RealtimeClient
Expand Down
22 changes: 22 additions & 0 deletions Sources/AblyChat/Dependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Ably

/// Expresses the requirements of the Ably realtime client that is supplied to the Chat SDK.
///
/// The `ARTRealtime` class from the ably-cocoa SDK implements this protocol.
public protocol RealtimeClientProtocol: ARTRealtimeProtocol, Sendable {
associatedtype Channels: RealtimeChannelsProtocol

// It’s not clear to me why ARTRealtimeProtocol doesn’t include this property. I briefly tried adding it but ran into compilation failures that it wasn’t immediately obvious how to fix.
var channels: Channels { get }
}

/// Expresses the requirements of the object returned by ``RealtimeClientProtocol.channels``.
public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable {
associatedtype Channel: RealtimeChannelProtocol

// It’s not clear to me why ARTRealtimeChannelsProtocol doesn’t include this property.
func get(_ name: String) -> Channel
}

/// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``.
public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {}
19 changes: 17 additions & 2 deletions Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,26 @@ internal actor DefaultRoom: Room {
fatalError("Not yet implemented")
}

/// Fetches the channels that contribute to this room.
private func channels() -> [any RealtimeChannelProtocol] {
[
"chatMessages",
"typingIndicators",
"reactions",
].map { suffix in
realtime.channels.get("\(roomID)::$chat::$\(suffix)")
}
}

public func attach() async throws {
fatalError("Not yet implemented")
for channel in channels() {
try await channel.attachAsync()
}
}

public func detach() async throws {
fatalError("Not yet implemented")
for channel in channels() {
try await channel.detachAsync()
}
}
}
115 changes: 115 additions & 0 deletions Tests/AblyChatTests/DefaultRoomTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Ably
@testable import AblyChat
import XCTest

class DefaultRoomTests: XCTestCase {
func test_attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
// - basketball::$chat::$typingIndicators
// - basketball::$chat::$reactions
let channelsList = [
MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success),
]
let channels = MockChannels(channels: channelsList)
let realtime = MockRealtime.create(channels: channels)
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())

// When: `attach` is called on the room
try await room.attach()

// Then: `attach(_:)` is called on each of the channels, and the room `attach` call succeeds
for channel in channelsList {
XCTAssertTrue(channel.attachCallCounter.isNonZero)
}
}

func test_attach_attachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
// - basketball::$chat::$typingIndicators
//
// and fails when called on channel basketball::$chat::$reactions
let channelAttachError = ARTErrorInfo.createUnknownError() // arbitrary
let channelsList = [
MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .failure(channelAttachError)),
]
let channels = MockChannels(channels: channelsList)
let realtime = MockRealtime.create(channels: channels)
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())

// When: `attach` is called on the room
let roomAttachError: Error?
do {
try await room.attach()
roomAttachError = nil
} catch {
roomAttachError = error
}

// Then: the room `attach` call fails with the same error as the channel `attach(_:)` call
let roomAttachErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo)
XCTAssertIdentical(roomAttachErrorInfo, channelAttachError)
}

func test_detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
// - basketball::$chat::$typingIndicators
// - basketball::$chat::$reactions
let channelsList = [
MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .success),
]
let channels = MockChannels(channels: channelsList)
let realtime = MockRealtime.create(channels: channels)
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())

// When: `detach` is called on the room
try await room.detach()

// Then: `detach(_:)` is called on each of the channels, and the room `detach` call succeeds
for channel in channelsList {
XCTAssertTrue(channel.detachCallCounter.isNonZero)
}
}

func test_detach_detachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
// - basketball::$chat::$typingIndicators
//
// and fails when called on channel basketball::$chat::$reactions
let channelDetachError = ARTErrorInfo.createUnknownError() // arbitrary
let channelsList = [
MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success),
MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .failure(channelDetachError)),
]
let channels = MockChannels(channels: channelsList)
let realtime = MockRealtime.create(channels: channels)
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())

// When: `detach` is called on the room
let roomDetachError: Error?
do {
try await room.detach()
roomDetachError = nil
} catch {
roomDetachError = error
}

// Then: the room `detach` call fails with the same error as the channel `detach(_:)` call
let roomDetachErrorInfo = try XCTUnwrap(roomDetachError as? ARTErrorInfo)
XCTAssertIdentical(roomDetachErrorInfo, channelDetachError)
}
}
30 changes: 30 additions & 0 deletions Tests/AblyChatTests/Mocks/MockChannels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Ably
import AblyChat

final class MockChannels: RealtimeChannelsProtocol, Sendable {
private let channels: [MockRealtimeChannel]

init(channels: [MockRealtimeChannel]) {
self.channels = channels
}

func get(_ name: String) -> MockRealtimeChannel {
guard let channel = (channels.first { $0.name == name }) else {
fatalError("There is no mock channel with name \(name)")
}

return channel
}

func exists(_: String) -> Bool {
fatalError("Not implemented")
}

func release(_: String, callback _: ARTCallback? = nil) {
fatalError("Not implemented")
}

func release(_: String) {
fatalError("Not implemented")
}
}
25 changes: 19 additions & 6 deletions Tests/AblyChatTests/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Ably
import AblyChat
import Foundation

/// A mock implementation of `ARTRealtimeProtocol`. Copied from the class of the same name in the example app. 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: NSObject, ARTRealtimeProtocol, Sendable {
final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
var device: ARTLocalDevice {
fatalError("Not implemented")
}
Expand All @@ -11,19 +12,31 @@ final class MockRealtime: NSObject, ARTRealtimeProtocol, Sendable {
fatalError("Not implemented")
}

required init(options _: ARTClientOptions) {}
required init(options _: ARTClientOptions) {
channels = .init(channels: [])
}

required init(key _: String) {
channels = .init(channels: [])
}

required init(key _: String) {}
required init(token _: String) {
channels = .init(channels: [])
}

init(channels: MockChannels = .init(channels: [])) {
self.channels = channels
}

required init(token _: String) {}
let channels: MockChannels

/**
Creates an instance of MockRealtime.

This exists to give a convenient way to create an instance, because `init` is marked as unavailable in `ARTRealtimeProtocol`.
*/
static func create() -> MockRealtime {
MockRealtime(key: "")
static func create(channels: MockChannels = MockChannels(channels: [])) -> MockRealtime {
MockRealtime(channels: channels)
}

func time(_: @escaping ARTDateTimeCallback) {
Expand Down
Loading

0 comments on commit c272c82

Please sign in to comment.