From bf5b09acc5da66dfd293a418de0ba4fd87b00a82 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sun, 12 Jan 2025 22:10:39 +0100 Subject: [PATCH 1/2] Added presence tests. --- Sources/AblyChat/Dependencies.swift | 2 + Sources/AblyChat/RoomFeature.swift | 2 +- .../AblyChatTests/DefaultMessagesTests.swift | 11 +- .../DefaultRoomPresenceTests.swift | 486 ++++++++++++++++++ Tests/AblyChatTests/Helpers/Helpers.swift | 59 +++ .../Mocks/MockRealtimeChannel.swift | 17 +- .../Mocks/MockRealtimePresence.swift | 128 +++++ .../Mocks/MockRoomLifecycleContributor.swift | 6 +- .../MockRoomLifecycleContributorChannel.swift | 7 +- 9 files changed, 702 insertions(+), 16 deletions(-) create mode 100644 Tests/AblyChatTests/DefaultRoomPresenceTests.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRealtimePresence.swift 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/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index 69ab9bf7..a3ad0cec 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -63,7 +63,7 @@ internal protocol FeatureChannel: Sendable, EmitsDiscontinuities { internal struct DefaultFeatureChannel: FeatureChannel { internal var channel: RealtimeChannelProtocol - internal var contributor: DefaultRoomLifecycleContributor + internal var contributor: any RoomLifecycleContributor & EmitsDiscontinuities internal var roomLifecycleManager: RoomLifecycleManager internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) async -> Subscription { diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift index d2ad536b..6cf53452 100644 --- a/Tests/AblyChatTests/DefaultMessagesTests.swift +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -10,7 +10,7 @@ struct DefaultMessagesTests { // Given let realtime = MockRealtime.create() let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() + let channel = MockRealtimeChannel(attachResult: .success) let featureChannel = MockFeatureChannel(channel: channel) let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) @@ -28,7 +28,7 @@ struct DefaultMessagesTests { // Given let realtime = MockRealtime.create { (MockHTTPPaginatedResponse.successGetMessagesWithNoItems, nil) } let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() + let channel = MockRealtimeChannel(attachResult: .success) let featureChannel = MockFeatureChannel(channel: channel) let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) @@ -52,7 +52,8 @@ struct DefaultMessagesTests { properties: .init( attachSerial: "001", channelSerial: "001" - ) + ), + attachResult: .success ) let featureChannel = MockFeatureChannel(channel: channel) let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) @@ -80,6 +81,7 @@ struct DefaultMessagesTests { attachSerial: "001", channelSerial: "001" ), + attachResult: .success, messageToEmitOnSubscribe: .init( action: .create, // arbitrary serial: "", // arbitrary @@ -114,6 +116,7 @@ struct DefaultMessagesTests { attachSerial: "001", channelSerial: "001" ), + attachResult: .success, messageToEmitOnSubscribe: .init( action: .create, // arbitrary serial: "", // arbitrary @@ -142,7 +145,7 @@ struct DefaultMessagesTests { // Given: A DefaultMessages instance let realtime = MockRealtime.create() let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() + let channel = MockRealtimeChannel(attachResult: .success) let featureChannel = MockFeatureChannel(channel: channel) let messages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) diff --git a/Tests/AblyChatTests/DefaultRoomPresenceTests.swift b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift new file mode 100644 index 00000000..51409717 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomPresenceTests.swift @@ -0,0 +1,486 @@ +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(members: ["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(\.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: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And presence enter is called + try await defaultPresence.enter() + + // Then: The manager was waiting for its room status to change before presence `enter` was called + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @specPartial CHA-PR3d + @Test + func usersMayEnterPresenceWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) // Without this delay most of the time attach fail happens before lifecycleManager has a chance to start waiting. I tried to use SignallableChannelOperation, but looks like `await #expect(...)` doesn't understand `let async x/try await x` syntax. + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + try await defaultPresence.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(members: ["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(members: ["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: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And presence update is called + try await defaultPresence.update() + + // Then: The manager was waiting for its room status to change before presence `update` was called + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @specPartial CHA-PR10d + @Test + func usersMayUpdatePresenceWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + try await defaultPresence.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(members: ["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(members: ["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(members: ["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(members: ["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(\.clientID).sorted() == ["client1", "client2"]) + } + + // @spec CHA-PR6h + @Test + func failToRetrieveAllTheMembersOfThePresenceSetWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(members: ["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: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And presence get is called + _ = try await defaultPresence.get() + + // Then: The manager was waiting for its room status to change before presence `get` was called + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @specPartial CHA-PR6c + @Test + func retrieveAllTheMembersOfThePresenceSetWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger()) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultPresence.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 + @Test + func usersMaySubscribeToAllPresenceEvents() async throws { + // Given + let realtimePresence = MockRealtimePresence(members: ["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") + } + + // MARK: CHA-PR8 + + // @spec CHA-PR8 + @Test + func onDiscontinuity() async throws { + // Given + let realtimePresence = MockRealtimePresence(members: []) + 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/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 289adcd1..ccf0f40b 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -21,3 +21,62 @@ 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 [PresenceEventType] { + static let all = [ + PresenceEventType.present, + PresenceEventType.enter, + PresenceEventType.leave, + PresenceEventType.update, + ] +} + +enum RoomLifecycleHelper { + static let fakeNetworkDelay: UInt64 = 10 // milliseconds; without this delay (or with a very low value such as 1) most of the time attach happens before lifecycleManager has a chance to start waiting. + + static func createManager( + forTestingWhatHappensWhenCurrentlyIn status: DefaultRoomLifecycleManager.Status? = nil, + forTestingWhatHappensWhenHasPendingDiscontinuityEvents pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: DiscontinuityEvent]? = nil, + forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs idsOfContributorsWithTransientDisconnectTimeout: Set? = nil, + contributors: [MockRoomLifecycleContributor] = [], + clock: SimpleClock = MockSimpleClock() + ) async -> DefaultRoomLifecycleManager { + await .init( + testsOnly_status: status, + testsOnly_pendingDiscontinuityEvents: pendingDiscontinuityEvents, + testsOnly_idsOfContributorsWithTransientDisconnectTimeout: idsOfContributorsWithTransientDisconnectTimeout, + contributors: contributors, + logger: TestLogger(), + clock: clock + ) + } + + static func createContributor( + initialState: ARTRealtimeChannelState = .initialized, + initialErrorReason: ARTErrorInfo? = nil, + feature: RoomFeature = .messages, // Arbitrarily chosen, its value only matters in test cases where we check which error is thrown + attachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, + detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, + subscribeToStateBehavior: MockRoomLifecycleContributorChannel.SubscribeToStateBehavior? = nil + ) -> MockRoomLifecycleContributor { + .init( + feature: feature, + channel: .init( + initialState: initialState, + initialErrorReason: initialErrorReason, + attachBehavior: attachBehavior, + detachBehavior: detachBehavior, + subscribeToStateBehavior: subscribeToStateBehavior + ) + ) + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index 071bc4af..7943e3ea 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..4ed50c60 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -0,0 +1,128 @@ +import Ably +import AblyChat + +final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenceProtocol { + let syncComplete: Bool + private var members: [ARTPresenceMessage] + private var currentMember: ARTPresenceMessage? + private var subscribeCallback: ARTPresenceMessageCallback? + private var presenceGetError: ARTErrorInfo? + + init(syncComplete: Bool = true, members: [ARTPresenceMessage], presenceGetError: ARTErrorInfo? = nil) { + self.syncComplete = syncComplete + self.members = members + currentMember = members.count == 1 ? members[0] : nil + self.presenceGetError = presenceGetError + } + + func get(_ callback: @escaping ARTPresenceMessagesCallback) { + callback(presenceGetError == nil ? members : nil, presenceGetError) + } + + 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!) + currentMember!.action = .enter + subscribeCallback?(currentMember!) + } + + func enterClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + currentMember = ARTPresenceMessage(clientId: clientId, data: data) + members.append(currentMember!) + callback?(nil) + currentMember!.action = .enter + subscribeCallback?(currentMember!) + } + + func updateClient(_ clientId: String, data: Any?) { + members.first { $0.clientId == clientId }?.data = data + } + + func updateClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + guard let member = members.first(where: { $0.clientId == clientId }) else { + preconditionFailure("Client \(clientId) doesn't exist in this presence set.") + } + member.action = .update + member.data = data + subscribeCallback?(member) + 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(_ callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + subscribeCallback = callback + for member in members { + subscribeCallback?(member) + } + return 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) { + subscribeCallback = nil + } + + 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") + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift index e09bbbfc..e0163089 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift @@ -1,7 +1,7 @@ import Ably @testable import AblyChat -actor MockRoomLifecycleContributor: RoomLifecycleContributor { +actor MockRoomLifecycleContributor: RoomLifecycleContributor, EmitsDiscontinuities { nonisolated let feature: RoomFeature nonisolated let channel: MockRoomLifecycleContributorChannel @@ -15,4 +15,8 @@ actor MockRoomLifecycleContributor: RoomLifecycleContributor { func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) async { emitDiscontinuityArguments.append(discontinuity) } + + func onDiscontinuity(bufferingPolicy _: AblyChat.BufferingPolicy) async -> AblyChat.Subscription { + fatalError("Not implemented") + } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift index 9b25c414..09ea717c 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -45,7 +45,7 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel /// Receives an argument indicating how many times (including the current call) the method for which this is providing a mock implementation has been called. case fromFunction(@Sendable (Int) async -> AttachOrDetachBehavior) case complete(AttachOrDetachResult) - case completeAndChangeState(AttachOrDetachResult, newState: ARTRealtimeChannelState) + case completeAndChangeState(AttachOrDetachResult, newState: ARTRealtimeChannelState, delayInMilliseconds: UInt64 = 0) // emulating network delay before going to the new state static var success: Self { .complete(.success) @@ -90,7 +90,10 @@ final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel return case let .complete(completeResult): result = completeResult - case let .completeAndChangeState(completeResult, newState): + case let .completeAndChangeState(completeResult, newState, milliseconds): + if milliseconds > 0 { + try? await Task.sleep(nanoseconds: milliseconds * 1_000_000) + } state = newState if case let .failure(error) = completeResult { errorReason = error From 3fdad4d6ab7535a830dbf9721abf3ed9fa56ab00 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Sun, 19 Jan 2025 19:37:08 +0100 Subject: [PATCH 2/2] Added occupancy tests. --- .../DefaultRoomOccupancyTests.swift | 75 +++++++++++++++++++ 1 file changed, 75 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 00000000..572f154a --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift @@ -0,0 +1,75 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomOccupancyTests { + // @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-O4a + // @spec CHA-O4c + @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) + } + + // @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) + } +}