Skip to content

Commit

Permalink
Added tests for presence and typing indicators (WIP).
Browse files Browse the repository at this point in the history
  • Loading branch information
maratal committed Dec 23, 2024
1 parent dc42f21 commit 62d1e23
Show file tree
Hide file tree
Showing 6 changed files with 458 additions and 5 deletions.
2 changes: 2 additions & 0 deletions Sources/AblyChat/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
219 changes: 219 additions & 0 deletions Tests/AblyChatTests/DefaultRoomPresenceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import Ably
@testable import AblyChat
import Testing

struct DefaultRoomPresenceTests {
// @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")
}

// @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)
}

// @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)
}

// @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)
}

// @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)
}

// @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
}
}
}

// @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)
}

// @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)
}
}

101 changes: 101 additions & 0 deletions Tests/AblyChatTests/DefaultRoomTypingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 18 additions & 0 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}
11 changes: 6 additions & 5 deletions Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -32,14 +31,16 @@ 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
self.detachResult = detachResult
self.messageToEmitOnSubscribe = messageToEmitOnSubscribe
attachSerial = properties.attachSerial
channelSerial = properties.channelSerial
self.mockPresence = mockPresence
}

/// A threadsafe counter that starts at zero.
Expand Down
Loading

0 comments on commit 62d1e23

Please sign in to comment.