diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index 13cff5f8..30271429 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/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 80e168b1..20bf57c9 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -423,3 +423,14 @@ internal actor DefaultRoom } } } + +#if DEBUG +extension DefaultRoom { + var testsOnly_lifecycleManager: DefaultRoomLifecycleManager { + guard let lifecycleManager = lifecycleManager as? DefaultRoomLifecycleManager else { + preconditionFailure("DefaultRoomLifecycleManager is expected here.") + } + return lifecycleManager + } +} +#endif diff --git a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift new file mode 100644 index 00000000..f310590e --- /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 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() + 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) + } +} diff --git a/Tests/AblyChatTests/DefaultRoomPresenceTests.swift b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift new file mode 100644 index 00000000..430a8bbd --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift @@ -0,0 +1,484 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomPresenceTests { + // MARK: CHA-PR1 + + // @spec CHA-PR1 + @Test + func channelNameIsSetAsChatMessagesChannelName() async throws { + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + // Then + #expect(defaultPresence.channel.name == "basketball::$chat::$chatMessages") + } + + // MARK: CHA-PR3 + + // @spec CHA-PR3a + // @spec CHA-PR3e + @Test + func usersMayEnterPresence() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // When + try await defaultPresence.enter(data: ["status": "Online"]) + + // Then + let presenceMembers = try await defaultPresence.get() + #expect(presenceMembers.map { $0.clientID }.sorted() == ["client1", "client2"]) + let client2 = presenceMembers.filter { member in + member.clientID == "client2" && member.data?.objectValue?["status"]?.stringValue == "Online" + } + #expect(client2 != nil) + } + + // @specPartial CHA-PR3d + @Test + func usersMayEnterPresenceWhileAttaching() async throws { + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success, mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state and presence enter is called + Task { + try await lifecycleManager.performAttachOperation() + } + // When: And presence enter is called + try await room.presence.enter() + + // Then: The manager waits for its room status to change + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + + // Then: Room eventually attached + #expect(await room.status == .attached) + } + + // @specPartial CHA-PR3d + @Test + func usersMayEnterPresenceWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .failure(attachError), mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + Task { + try await lifecycleManager.performAttachOperation() + } + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + try await room.presence.enter() + } 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-PR3h + @Test + func failToEnterPresenceWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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)) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + // Then + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultPresence.enter() + } catch { + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 400) + #expect(error.localizedDescription.contains("attach")) + throw error + } + } + } + + // MARK: CHA-PR10 + + // @spec CHA-PR10a + // @spec CHA-PR10e + @Test + func usersMayUpdatePresence() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // When + try await defaultPresence.update(data: ["status": "Online"]) + + // Then + let presenceMembers = try await defaultPresence.get() + let client1 = presenceMembers.filter { member in + member.clientID == "client1" && member.data?.objectValue?["status"]?.stringValue == "Online" + } + #expect(client1 != nil) + } + + // @specPartial CHA-PR10d + @Test + func usersMayUpdatePresenceWhileAttaching() async throws { + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success, mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state and presence enter is called + Task { + try await lifecycleManager.performAttachOperation() + } + // When: And presence update is called + try await room.presence.update() + + // Then: The manager waits for its room status to change + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + + // Then: Room eventually attached + #expect(await room.status == .attached) + } + + // @specPartial CHA-PR10d + @Test + func usersMayUpdatePresenceWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .failure(attachError), mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + Task { + try await lifecycleManager.performAttachOperation() + } + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + try await room.presence.update() + } 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-PR10h + @Test + func failToUpdatePresenceWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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)) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + // Then + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultPresence.update() + } catch { + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 400) + #expect(error.localizedDescription.contains("attach")) + throw error + } + } + } + + // MARK: CHA-PR4 + + // @spec CHA-PR4a + @Test + func usersMayLeavePresence() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // When + try await defaultPresence.leave() + + // Then + let presenceMembers = try await defaultPresence.get() + #expect(presenceMembers.isEmpty) + } + + // MARK: CHA-PR5 + + // @spec CHA-PR5 + @Test + func ifUserIsPresent() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // When + let isUserPresent1 = try await defaultPresence.isUserPresent(clientID: "client2") + let isUserPresent2 = try await defaultPresence.isUserPresent(clientID: "client3") + + // Then + #expect(isUserPresent1 == true) + #expect(isUserPresent2 == false) + } + + // MARK: CHA-PR6 + + // @spec CHA-PR6 + // @spec CHA-PR6d + @Test + func retrieveAllTheMembersOfThePresenceSet() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // When + let presenceMembers = try await defaultPresence.get() + + // Then + #expect(presenceMembers.map { $0.clientID }.sorted() == ["client1", "client2"]) + } + + // @spec CHA-PR6h + @Test + func failToRetrieveAllTheMembersOfThePresenceSetWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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 defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + // Then + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultPresence.get() + } catch { + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 400) + #expect(error.localizedDescription.contains("attach")) + throw error + } + } + } + + // @specPartial CHA-PR6c + @Test + func retrieveAllTheMembersOfThePresenceSetWhileAttaching() async throws { + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success, mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state and presence enter is called + Task { + try await lifecycleManager.performAttachOperation() + } + // When: And presence get is called + _ = try await room.presence.get() + + // Then: The manager waits for its room status to change + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + + // Then: Room eventually attached + #expect(await room.status == .attached) + } + + // @specPartial CHA-PR6c + @Test + func retrieveAllTheMembersOfThePresenceSetWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .failure(attachError), mockPresence: realtimePresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(presence: .init()), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let lifecycleManager = await room.testsOnly_lifecycleManager + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + Task { + try await lifecycleManager.performAttachOperation() + } + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await room.presence.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 }) + } + + // MARK: CHA-PR7 + + // @spec CHA-PR7a + // @spec CHA-PR7b + // @spec CHA-PR7c + @Test + func usersMaySubscribeToAllPresenceEvents() async throws { + // Given + let realtimePresence = MockRealtimePresence(["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()) + + // Given + let subscription = await defaultPresence.subscribe(events: .all) // CHA-PR7a and CHA-PR7b since `all` is just a selection of all events + + // When + subscription.emit(PresenceEvent(action: .present, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let presentEvent = try #require(await subscription.first { _ in true }) + #expect(presentEvent.action == .present) + #expect(presentEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .enter, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let enterEvent = try #require(await subscription.first { _ in true }) + #expect(enterEvent.action == .enter) + #expect(enterEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .update, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let updateEvent = try #require(await subscription.first { _ in true }) + #expect(updateEvent.action == .update) + #expect(updateEvent.clientID == "client1") + + // When + subscription.emit(PresenceEvent(action: .leave, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let leaveEvent = try #require(await subscription.first { _ in true }) + #expect(leaveEvent.action == .leave) + #expect(leaveEvent.clientID == "client1") + + // CHA-PR7c + + // When + subscription.unsubscribe() + + // When + subscription.emit(PresenceEvent(action: .present, clientID: "client1", timestamp: Date(), data: nil)) + subscription.emit(PresenceEvent(action: .enter, clientID: "client1", timestamp: Date(), data: nil)) + subscription.emit(PresenceEvent(action: .leave, clientID: "client1", timestamp: Date(), data: nil)) + subscription.emit(PresenceEvent(action: .update, clientID: "client1", timestamp: Date(), data: nil)) + + // Then + let nilAnyEvent = await subscription.first { _ in true } + #expect(nilAnyEvent == nil) + } + + // MARK: CHA-PR8 + + // @spec CHA-PR8 + @Test + func onDiscontinuity() async throws { + // Given + let realtimePresence = MockRealtimePresence([]) + let channel = MockRealtimeChannel(mockPresence: realtimePresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger()) + + // When: The feature channel emits a discontinuity through `onDiscontinuity` + let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error + let discontinuitySubscription = await defaultPresence.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/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift new file mode 100644 index 00000000..532cc5e6 --- /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(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 289adcd1..8db7abcd 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -21,3 +21,21 @@ func isChatError(_ maybeError: (any Error)?, withCodeAndStatusCode codeAndStatus return ablyError.message == message }() } + +extension ARTPresenceMessage { + convenience init(clientId: String, data: Any? = [:], timestamp: Date = Date()) { + self.init() + self.clientId = clientId + self.data = data + self.timestamp = timestamp + } +} + +extension Array where Element == PresenceEventType { + static let all = [ + PresenceEventType.present, + PresenceEventType.enter, + PresenceEventType.leave, + PresenceEventType.update + ] +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index f4e7c579..cf64a247 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. @@ -71,7 +72,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } var state: ARTRealtimeChannelState { - .attached + attachResult == .success ? .attached : .failed } var errorReason: ARTErrorInfo? { @@ -86,7 +87,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { fatalError("Not implemented") } - enum AttachOrDetachResult { + enum AttachOrDetachResult: Equatable { case success case failure(ARTErrorInfo) @@ -183,7 +184,7 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { } func on(_: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + ARTEventListener() } func once(_: ARTChannelEvent, callback _: @escaping (ARTChannelStateChange) -> Void) -> ARTEventListener { diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift new file mode 100644 index 00000000..46ca625b --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -0,0 +1,112 @@ +import Ably +import AblyChat + +final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenceProtocol { + let syncComplete: Bool + private var members: [ARTPresenceMessage] + private var currentMember: ARTPresenceMessage? + + init(syncComplete: Bool = true, _ members: [ARTPresenceMessage]) { + self.syncComplete = syncComplete + self.members = members + self.currentMember = members.count == 1 ? members[0] : nil + } + + func get(_ callback: @escaping ARTPresenceMessagesCallback) { + callback(members, nil) + } + + func get(_ query: ARTRealtimePresenceQuery, callback: @escaping ARTPresenceMessagesCallback) { + callback(members.filter { $0.clientId == query.clientId }, nil) + } + + func enter(_: Any?) { + fatalError("Not implemented") + } + + func enter(_: Any?, callback _: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func update(_ data: Any?) { + currentMember?.data = data + } + + func update(_ data: Any?, callback: ARTCallback? = nil) { + currentMember?.data = data + callback?(nil) + } + + func leave(_: Any?) { + members.removeAll { $0.clientId == currentMember?.clientId } + } + + func leave(_: Any?, callback: ARTCallback? = nil) { + members.removeAll { $0.clientId == currentMember?.clientId } + callback?(nil) + } + + func enterClient(_ clientId: String, data: Any?) { + currentMember = ARTPresenceMessage(clientId: clientId, data: data) + members.append(currentMember!) + } + + func enterClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + currentMember = ARTPresenceMessage(clientId: clientId, data: data) + members.append(currentMember!) + callback?(nil) + } + + func updateClient(_ clientId: String, data: Any?) { + members.first { $0.clientId == clientId }?.data = data + } + + func updateClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + members.first { $0.clientId == clientId }?.data = data + callback?(nil) + } + + 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") + } +}