diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift new file mode 100644 index 0000000..0586ae8 --- /dev/null +++ b/Sources/AblyChat/Errors.swift @@ -0,0 +1,67 @@ +import Ably + +/** + The error domain used for the ``Ably.ARTErrorInfo`` error instances thrown by the Ably Chat SDK. + + See ``ErrorCode`` for the possible ``ARTErrorInfo.code`` values. + */ +public let errorDomain: NSString = "AblyChatErrorDomain" + +/** + The error codes for errors in the ``errorDomain`` error domain. + */ +public enum ErrorCode: Int { + /// ``Rooms.get(roomID:options:)`` was called with a different set of room options than was used on a previous call. You must first release the existing room instance using ``Rooms.release(roomID:)``. + /// + /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 + case inconsistentRoomOptions = 1 + + /// The ``ARTErrorInfo.statusCode`` that should be returned for this error. + internal var statusCode: Int { + // TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 + switch self { + case .inconsistentRoomOptions: + 400 + } + } +} + +/** + The errors thrown by the Chat SDK. + + This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`. + */ +internal enum ChatError { + case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) + + /// The ``ARTErrorInfo.code`` that should be returned for this error. + internal var code: ErrorCode { + switch self { + case .inconsistentRoomOptions: + .inconsistentRoomOptions + } + } + + /// The ``ARTErrorInfo.localizedDescription`` that should be returned for this error. + internal var localizedDescription: String { + switch self { + case let .inconsistentRoomOptions(requested, existing): + "Rooms.get(roomID:options:) was called with a different set of room options than was used on a previous call. You must first release the existing room instance using Rooms.release(roomID:). Requested options: \(requested), existing options: \(existing)" + } + } +} + +internal extension ARTErrorInfo { + convenience init(chatError: ChatError) { + var userInfo: [String: Any] = [:] + // TODO: copied and pasted from implementation of -[ARTErrorInfo createWithCode:status:message:requestId:] because there’s no way to pass domain; revisit in https://github.com/ably-labs/ably-chat-swift/issues/32. Also the ARTErrorInfoStatusCode variable in ably-cocoa is not public. + userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode + userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription + + self.init( + domain: errorDomain as String, + code: chatError.code.rawValue, + userInfo: userInfo + ) + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 286821a..01d1350 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -1,3 +1,5 @@ +@preconcurrency import Ably + public protocol Room: AnyObject, Sendable { var roomID: String { get } var messages: any Messages { get } @@ -14,3 +16,49 @@ public protocol Room: AnyObject, Sendable { func detach() async throws var options: RoomOptions { get } } + +public final class DefaultRoom: Room { + public let roomID: String + public let options: RoomOptions + + // Exposed for testing. + internal let realtime: any ARTRealtimeProtocol + + internal init(realtime: any ARTRealtimeProtocol, roomID: String, options: RoomOptions) { + self.realtime = realtime + self.roomID = roomID + self.options = options + } + + public var messages: any Messages { + fatalError("Not yet implemented") + } + + public var presence: any Presence { + fatalError("Not yet implemented") + } + + public var reactions: any RoomReactions { + fatalError("Not yet implemented") + } + + public var typing: any Typing { + fatalError("Not yet implemented") + } + + public var occupancy: any Occupancy { + fatalError("Not yet implemented") + } + + public var status: any RoomStatus { + fatalError("Not yet implemented") + } + + public func attach() async throws { + fatalError("Not yet implemented") + } + + public func detach() async throws { + fatalError("Not yet implemented") + } +} diff --git a/Sources/AblyChat/RoomOptions.swift b/Sources/AblyChat/RoomOptions.swift index a927bfc..ca7b597 100644 --- a/Sources/AblyChat/RoomOptions.swift +++ b/Sources/AblyChat/RoomOptions.swift @@ -1,6 +1,6 @@ import Foundation -public struct RoomOptions: Sendable { +public struct RoomOptions: Sendable, Equatable { public var presence: PresenceOptions? public var typing: TypingOptions? public var reactions: RoomReactionsOptions? @@ -14,7 +14,7 @@ public struct RoomOptions: Sendable { } } -public struct PresenceOptions: Sendable { +public struct PresenceOptions: Sendable, Equatable { public var enter = true public var subscribe = true @@ -24,7 +24,7 @@ public struct PresenceOptions: Sendable { } } -public struct TypingOptions: Sendable { +public struct TypingOptions: Sendable, Equatable { public var timeout: TimeInterval = 10 public init(timeout: TimeInterval = 10) { @@ -32,10 +32,10 @@ public struct TypingOptions: Sendable { } } -public struct RoomReactionsOptions: Sendable { +public struct RoomReactionsOptions: Sendable, Equatable { public init() {} } -public struct OccupancyOptions: Sendable { +public struct OccupancyOptions: Sendable, Equatable { public init() {} } diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index f09478a..aa39b2e 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -1,5 +1,64 @@ +@preconcurrency import Ably + public protocol Rooms: AnyObject, Sendable { func get(roomID: String, options: RoomOptions) throws -> any Room func release(roomID: String) async throws var clientOptions: ClientOptions { get } } + +internal final class DefaultRooms: Rooms { + private let realtime: ARTRealtimeProtocol + private let storage = Storage() + + internal init(realtime: ARTRealtimeProtocol) { + self.realtime = realtime + } + + /// Thread-safe storage of the set of rooms. + private class Storage: @unchecked Sendable { + /// Mutex used to synchronize access to ``rooms``. + private let lock = NSLock() + + /// The set of rooms, keyed by room ID. Only access whilst holding ``lock``. + private var rooms: [String: DefaultRoom] = [:] + + /// If there is an existing room with the given ID, returns it. Else creates a new room with the given ID and options. + public func getOrCreate(realtime: any ARTRealtimeProtocol, roomID: String, options: RoomOptions) -> DefaultRoom { + let room: DefaultRoom + lock.lock() + // CHA-RC1b + if let existingRoom = rooms[roomID] { + room = existingRoom + } else { + room = DefaultRoom(realtime: realtime, roomID: roomID, options: options) + rooms[roomID] = room + } + lock.unlock() + return room + } + } + + internal func get(roomID: String, options: RoomOptions) throws -> any Room { + let room = storage.getOrCreate( + realtime: realtime, + roomID: roomID, + options: options + ) + + if room.options != options { + throw ARTErrorInfo( + chatError: .inconsistentRoomOptions(requested: options, existing: room.options) + ) + } + + return room + } + + internal func release(roomID _: String) async throws { + fatalError("Not yet implemented") + } + + internal var clientOptions: ClientOptions { + fatalError("Not yet implemented") + } +} diff --git a/Tests/AblyChatTests/AblyChatTests.swift b/Tests/AblyChatTests/AblyChatTests.swift index c8c5c0b..fbda0fe 100644 --- a/Tests/AblyChatTests/AblyChatTests.swift +++ b/Tests/AblyChatTests/AblyChatTests.swift @@ -3,6 +3,6 @@ import XCTest final class AblyChatTests: XCTestCase { func testExample() throws { - XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime(key: ""), clientOptions: ClientOptions())) + XCTAssertNoThrow(DefaultChatClient(realtime: MockRealtime.create(), clientOptions: ClientOptions())) } } diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift new file mode 100644 index 0000000..796750e --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -0,0 +1,64 @@ +@testable import AblyChat +import XCTest + +class DefaultRoomsTests: XCTestCase { + // @spec CHA-RC1a + func test_get_returnsRoomWithGivenID() throws { + // Given: an instance of DefaultRooms + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime) + + // When: get(roomID:options:) is called + let roomID = "basketball" + let options = RoomOptions() + let room = try rooms.get(roomID: roomID, options: options) + + // Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options + let defaultRoom = try XCTUnwrap(room as? DefaultRoom) + XCTAssertIdentical(defaultRoom.realtime, realtime) + XCTAssertEqual(defaultRoom.roomID, roomID) + XCTAssertEqual(defaultRoom.options, options) + } + + // @spec CHA-RC1b + func test_get_returnsExistingRoomWithGivenID() throws { + // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime) + + let roomID = "basketball" + let options = RoomOptions() + let firstRoom = try rooms.get(roomID: roomID, options: options) + + // When: get(roomID:options:) is called with the same room ID + let secondRoom = try rooms.get(roomID: roomID, options: options) + + // Then: It returns the same room object + XCTAssertIdentical(secondRoom, firstRoom) + } + + // @spec CHA-RC1c + func test_get_throwsErrorWhenOptionsDoNotMatch() throws { + // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options + let realtime = MockRealtime.create() + let rooms = DefaultRooms(realtime: realtime) + + let roomID = "basketball" + let options = RoomOptions() + _ = try rooms.get(roomID: roomID, options: options) + + // When: get(roomID:options:) is called with the same ID but different options + let differentOptions = RoomOptions(presence: .init(subscribe: false)) + + let caughtError: Error? + do { + _ = try rooms.get(roomID: roomID, options: differentOptions) + caughtError = nil + } catch { + caughtError = error + } + + // Then: It throws an inconsistentRoomOptions error + try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions) + } +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift new file mode 100644 index 0000000..669c23e --- /dev/null +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -0,0 +1,15 @@ +import Ably +@testable import AblyChat +import XCTest + +/** + Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code. + */ +func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws { + let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line) + let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line) + + XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line) + XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line) + XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line) +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtime.swift b/Tests/AblyChatTests/Mocks/MockRealtime.swift index f49b06c..efdd449 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtime.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtime.swift @@ -15,6 +15,15 @@ class MockRealtime: NSObject, ARTRealtimeProtocol { required init(token _: String) {} + /** + 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: "") + } + func time(_: @escaping ARTDateTimeCallback) { fatalError("Not implemented") }