From bace45558df5d75ee5036a72bb1bd2943102e1a8 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sun, 15 Dec 2024 16:55:23 +0100 Subject: [PATCH 1/2] Added tests for occupancy. Ignored CHA-04d since it's impossible to emit invalid JSON on a subscription currently. --- .../DefaultRoomOccupancyTests.swift | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Tests/AblyChatTests/DefaultRoomOccupancyTests.swift diff --git a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift new file mode 100644 index 0000000..198a7b1 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift @@ -0,0 +1,101 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomOccupancyTests { + // @spec CHA-O1 + @Test + func init_channelNameIsSetAsChatMessagesChannelName() async throws { + // Given + let realtime = MockRealtime.create() + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + + // Then + #expect(defaultOccupancy.channel.name == "basketball::$chat::$chatMessages") + } + + // @spec CHA-O2 + // @spec CHA-O3 + @Test + func requestOccupancyCheck() async throws { + // Given + let realtime = MockRealtime.create { + (MockHTTPPaginatedResponse( + items: [ + [ + "connections": 5, + "presenceMembers": 2, + ], + ] + ), nil) + } + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + + // When + let occupancyInfo = try await defaultOccupancy.get() + + // Then + #expect(occupancyInfo.connections == 5) + #expect(occupancyInfo.presenceMembers == 2) + } + + // @spec CHA-O4 + @Test + func usersCanSubscribeToRealtimeOccupancyUpdates() async throws { + // Given + let realtime = MockRealtime.create() + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + + // CHA-O4a, CHA-O4c + + // When + let subscription = await defaultOccupancy.subscribe() + subscription.emit(OccupancyEvent(connections: 5, presenceMembers: 2)) + + // Then + let occupancyInfo = try #require(await subscription.first { _ in true }) + #expect(occupancyInfo.connections == 5) + #expect(occupancyInfo.presenceMembers == 2) + + // CHA-O4b + + // When + subscription.unsubscribe() + subscription.emit(OccupancyEvent(connections: 5, presenceMembers: 2)) + + // Then + let nilOccupancyInfo = await subscription.first { _ in true } + #expect(nilOccupancyInfo == nil) + } + + // @spec CHA-O5 + @Test + func onDiscontinuity() async throws { + // Given + let realtime = MockRealtime.create() + let chatAPI = ChatAPI(realtime: realtime) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let featureChannel = MockFeatureChannel(channel: channel) + let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + + // When: The feature channel emits a discontinuity through `onDiscontinuity` + let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error + let discontinuitySubscription = await defaultOccupancy.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) + } +} From 86e8aab9931359d3a3db4bd35813688b7bb1b795 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Mon, 16 Dec 2024 01:28:30 +0100 Subject: [PATCH 2/2] Added tests for typing indicators (WIP). --- Sources/AblyChat/Dependencies.swift | 2 + .../DefaultRoomOccupancyTests.swift | 2 +- .../DefaultRoomTypingTests.swift | 101 +++++++++++++++++ Tests/AblyChatTests/Helpers/Helpers.swift | 7 ++ .../Mocks/MockRealtimeChannel.swift | 11 +- .../Mocks/MockRealtimePresence.swift | 105 ++++++++++++++++++ 6 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 Tests/AblyChatTests/DefaultRoomTypingTests.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRealtimePresence.swift diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index 13cff5f..3027142 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -25,6 +25,8 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol/get(_:options:)``. public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {} +public protocol RealtimePresenceProtocol: ARTRealtimePresenceProtocol, Sendable {} + public protocol ConnectionProtocol: ARTConnectionProtocol, Sendable {} /// Like (a subset of) `ARTRealtimeChannelOptions` but with value semantics. (It’s unfortunate that `ARTRealtimeChannelOptions` doesn’t have a `-copy` method.) diff --git a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift index 198a7b1..9b8d970 100644 --- a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift +++ b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift @@ -5,7 +5,7 @@ import Testing struct DefaultRoomOccupancyTests { // @spec CHA-O1 @Test - func init_channelNameIsSetAsChatMessagesChannelName() async throws { + func channelNameIsSetAsChatMessagesChannelName() async throws { // Given let realtime = MockRealtime.create() let chatAPI = ChatAPI(realtime: realtime) diff --git a/Tests/AblyChatTests/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift new file mode 100644 index 0000000..0444061 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomTypingTests.swift @@ -0,0 +1,101 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomTypingTests { + // @spec CHA-T1 + @Test + func channelNameIsSetAsTypingIndicatorsChannelName() async throws { + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // Then + #expect(defaultTyping.channel.name == "basketball::$chat::$typingIndicators") + } + + // @spec CHA-T2 + @Test + func retrieveCurrentlyTypingClientIDs() async throws { + // Given + let typingPresence = MockRealtimePresence(["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"]) + } + + // @spec CHA-T4 + // @spec CHA-T5 + @Test + func usersMayIndicateThatTheyHaveStartedOrStoppedTyping() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + 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-T4 + + // When + try await defaultTyping.start() + + // Then + var typingInfo = try await defaultTyping.get() + #expect(typingInfo == ["client1"]) + + // CHA-T5 + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + } + + // @spec CHA-T6 + @Test + func usersMaySubscribeToTypingEvents() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + 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) + + // When + let subscription = await defaultTyping.subscribe() + subscription.emit(TypingEvent(currentlyTyping: ["client1"])) + + // Then + let typingEvent = try #require(await subscription.first { _ in true }) + #expect(typingEvent.currentlyTyping == ["client1"]) + } + + // @spec CHA-T7 + @Test + func onDiscontinuity() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + 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) + + // 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 289adcd..427f62e 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -21,3 +21,10 @@ func isChatError(_ maybeError: (any Error)?, withCodeAndStatusCode codeAndStatus return ablyError.message == message }() } + +extension ARTPresenceMessage { + convenience init(clientId: String) { + self.init() + self.clientId = clientId + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index f4e7c57..26026cc 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -2,16 +2,15 @@ import Ably import AblyChat final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { - var presence: ARTRealtimePresenceProtocol { - fatalError("Not implemented") - } - private let attachSerial: String? private let channelSerial: String? private let _name: String? + private let mockPresence: MockRealtimePresence! var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } + var presence: ARTRealtimePresenceProtocol { mockPresence } + // I don't see why the nonisolated(unsafe) keyword would cause a problem when used for tests in this context. nonisolated(unsafe) var lastMessagePublishedName: String? nonisolated(unsafe) var lastMessagePublishedData: Any? @@ -32,7 +31,8 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { state _: ARTRealtimeChannelState = .suspended, attachResult: AttachOrDetachResult? = nil, detachResult: AttachOrDetachResult? = nil, - messageToEmitOnSubscribe: MessageToEmit? = nil + messageToEmitOnSubscribe: MessageToEmit? = nil, + mockPresence: MockRealtimePresence! = nil ) { _name = name self.attachResult = attachResult @@ -40,6 +40,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { self.messageToEmitOnSubscribe = messageToEmitOnSubscribe attachSerial = properties.attachSerial channelSerial = properties.channelSerial + self.mockPresence = mockPresence } /// A threadsafe counter that starts at zero. diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift new file mode 100644 index 0000000..6cef382 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -0,0 +1,105 @@ +import Ably +import AblyChat + +final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenceProtocol { + let syncComplete: Bool + private var members: [ARTPresenceMessage] + + init(syncComplete: Bool = true, _ members: [ARTPresenceMessage]) { + self.syncComplete = syncComplete + self.members = members + } + + func get(_ callback: @escaping ARTPresenceMessagesCallback) { + callback(members, nil) + } + + func get(_: ARTRealtimePresenceQuery, callback _: @escaping ARTPresenceMessagesCallback) { + fatalError("Not implemented") + } + + func enter(_: Any?) { + fatalError("Not implemented") + } + + func enter(_: Any?, callback _: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func update(_: Any?) { + fatalError("Not implemented") + } + + func update(_: Any?, callback _: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func leave(_: Any?) { + fatalError("Not implemented") + } + + func leave(_: Any?, callback _: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func enterClient(_ clientId: String, data _: Any?) { + members.append(ARTPresenceMessage(clientId: clientId)) + } + + func enterClient(_ clientId: String, data _: Any?, callback: ARTCallback? = nil) { + members.append(ARTPresenceMessage(clientId: clientId)) + callback?(nil) + } + + func updateClient(_: String, data _: Any?) { + fatalError("Not implemented") + } + + func updateClient(_: String, data _: Any?, callback _: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func leaveClient(_ clientId: String, data _: Any?) { + members.removeAll { $0.clientId == clientId } + } + + func leaveClient(_ clientId: String, data _: Any?, callback _: ARTCallback? = nil) { + members.removeAll { $0.clientId == clientId } + } + + func subscribe(_: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(attachCallback _: ARTCallback?, callback _: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(_: ARTPresenceAction, callback _: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(_: ARTPresenceAction, onAttach _: ARTCallback?, callback _: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func unsubscribe() { + fatalError("Not implemented") + } + + func unsubscribe(_: ARTEventListener) { + fatalError("Not implemented") + } + + func unsubscribe(_: ARTPresenceAction, listener _: ARTEventListener) { + fatalError("Not implemented") + } + + func history(_: @escaping ARTPaginatedPresenceCallback) { + fatalError("Not implemented") + } + + func history(_: ARTRealtimeHistoryQuery?, callback _: @escaping ARTPaginatedPresenceCallback) throws { + fatalError("Not implemented") + } +}