Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/AblyChat/DefaultRoomReactions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal final class DefaultRoomReactions: RoomReactions {
try await implementation.send(params: params)
}

@discardableResult
internal func subscribe(_ callback: @escaping @MainActor (Reaction) -> Void) -> SubscriptionProtocol {
implementation.subscribe(callback)
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/AblyChat/InternalError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ internal enum InternalError: Error {
}
}

extension InternalError: Equatable {
internal static func == (lhs: InternalError, rhs: InternalError) -> Bool {
lhs.toARTErrorInfo() == rhs.toARTErrorInfo()
}
}

internal extension ARTErrorInfo {
func toInternalError() -> InternalError {
.errorInfo(self)
Expand Down
394 changes: 329 additions & 65 deletions Tests/AblyChatTests/DefaultMessagesTests.swift

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions Tests/AblyChatTests/DefaultPresenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,4 @@ struct DefaultPresenceTests {
#expect(leaveEvent.action == .leave)
#expect(leaveEvent.clientID == "client1")
}

// @specNotApplicable CHA-PR7c - Untestable due to `AsyncSequence` subscription used which is removed once the object is removed from memory.
}
69 changes: 61 additions & 8 deletions Tests/AblyChatTests/DefaultRoomReactionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,73 @@ struct DefaultRoomReactionsTests {
#expect(channel.publishedMessages.last?.extras == ["headers": ["someHeadersKey": "someHeadersValue"], "ephemeral": true])
}

// @spec CHA-ER4
// @spec CHA-ER4a
// @spec CHA-ER4b
@Test
func subscribe_returnsSubscription() async throws {
// all setup values here are arbitrary
func subscriptionCanBeRegisteredToReceiveReactionEvents() async throws {
// Given
let channel = MockRealtimeChannel(name: "basketball::$chat")
func generateMessage(serial: String, reactionType: String) -> ARTMessage {
let message = ARTMessage()
message.action = .create // arbitrary
message.serial = serial // arbitrary
message.clientId = "" // arbitrary
message.data = [
"type": reactionType,
]
message.version = "0"
return message
}

// When
let channel = MockRealtimeChannel(
messageToEmitOnSubscribe: generateMessage(serial: "1", reactionType: ":like:")
)
let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger())

// When
let subscription: SubscriptionAsyncSequence<Reaction>? = defaultRoomReactions.subscribe()
let subscription = defaultRoomReactions.subscribe { reaction in
// Then
#expect(reaction.type == ":like:")
}

// Then
#expect(subscription != nil)
// CHA-ER4b
subscription.unsubscribe()

// will not be received and expectations above will not fail
channel.simulateIncomingMessage(
generateMessage(serial: "2", reactionType: ":dislike:"),
for: RoomReactionEvents.reaction.rawValue
)
}

// CHA-ER4c is currently untestable due to not subscribing to those events on lower level
// @spec CHA-ER4d
Comment thread
maratal marked this conversation as resolved.
@Test
func malformedRealtimeEventsShallNotBeEmittedToSubscribers() async throws {
// Given
let channel = MockRealtimeChannel(
messageToEmitOnSubscribe: {
let message = ARTMessage()
message.action = .create // arbitrary
message.serial = "123" // arbitrary
message.clientId = "" // arbitrary
message.data = [
"type": ":like:",
]
message.extras = [:] as any ARTJsonCompatible
message.version = "0"
return message
}()
)
let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger())

// When
defaultRoomReactions.subscribe { reaction in
#expect(reaction.type == ":like:")
}
// will not be received and expectations above will not fail
channel.simulateIncomingMessage(
ARTMessage(), // malformed message
for: RealtimeMessageName.chatMessage.rawValue
)
}
}
6 changes: 3 additions & 3 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ class MockMethodCallRecorder: @unchecked Sendable {
private var records = [CallRecord]()

func addRecord(signature: String, arguments: [String: Any?]) { // chose Any to not to deal with types in the test's code
mutex.withLock {
records.append(CallRecord(signature: signature, arguments: arguments.map { MethodArgument(name: $0.key, value: $0.value) }))
}
mutex.lock()
records.append(CallRecord(signature: signature, arguments: arguments.map { MethodArgument(name: $0.key, value: $0.value) }))
mutex.unlock()
}

func hasRecord(matching signature: String, arguments: [String: Any]) -> Bool {
Expand Down
28 changes: 17 additions & 11 deletions Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,17 @@ final class MockHTTPPaginatedResponse: ARTHTTPPaginatedResponse, @unchecked Send
private let _statusCode: Int
private let _headers: [String: String]
private let _hasNext: Bool
private let _isLast: Bool

init(
items: [NSDictionary],
statusCode: Int = 200,
headers: [String: String] = [:],
hasNext: Bool = false,
isLast: Bool = true
hasNext: Bool = false
) {
_items = items
_statusCode = statusCode
_headers = headers
_hasNext = hasNext
_isLast = isLast
super.init()
}

Expand All @@ -43,7 +40,7 @@ final class MockHTTPPaginatedResponse: ARTHTTPPaginatedResponse, @unchecked Send
}

override var isLast: Bool {
_isLast
!_hasNext
}

override func next(_ callback: @escaping ARTHTTPPaginatedCallback) {
Expand Down Expand Up @@ -118,7 +115,8 @@ extension MockHTTPPaginatedResponse {
],
],
statusCode: 200,
headers: [:]
headers: [:],
hasNext: true
)
}

Expand All @@ -128,21 +126,29 @@ extension MockHTTPPaginatedResponse {
static let nextPage = MockHTTPPaginatedResponse(
items: [
[
"serial": "3446450",
"clientId": "random",
"serial": "3446458",
"action": "message.create",
"createdAt": 1_730_943_049_269,
"roomId": "basketball",
"text": "previous message",
"text": "next hello",
"metadata": [:],
"headers": [:],
"version": "3446458",
],
[
"serial": "3446451",
"clientId": "random",
"serial": "3446459",
"action": "message.create",
"roomId": "basketball",
"text": "previous response",
"text": "next hello response",
"metadata": [:],
"headers": [:],
"version": "3446459",
],
],
statusCode: 200,
headers: [:]
headers: [:],
hasNext: false
)
}
6 changes: 6 additions & 0 deletions Tests/AblyChatTests/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Foundation

/// A mock implementation of `InternalRealtimeClientProtocol`. We’ll figure out how to do mocking in tests properly in https://github.com/ably-labs/ably-chat-swift/issues/5.
final class MockRealtime: InternalRealtimeClientProtocol {
let callRecorder = MockMethodCallRecorder()

let connection: MockConnection
let channels: MockChannels
let paginatedCallback: (@Sendable () throws(ARTErrorInfo) -> ARTHTTPPaginatedResponse)?
Expand Down Expand Up @@ -34,6 +36,10 @@ final class MockRealtime: InternalRealtimeClientProtocol {
fatalError("Paginated callback not set")
}
do {
callRecorder.addRecord(
signature: "request(_:path:params:body:headers:)",
arguments: ["method": method, "path": path, "params": params, "body": body == nil ? [:] : body as? [String: Any], "headers": headers]
)
return try paginatedCallback()
} catch {
throw error.toInternalError()
Expand Down
20 changes: 12 additions & 8 deletions Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol {
nonisolated var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) }

private var _state: ARTRealtimeChannelState?
private let stateChangeToEmitForListener: ARTChannelStateChange?
var errorReason: ARTErrorInfo?

var publishedMessages: [TestMessage] = []
Expand All @@ -28,7 +29,8 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol {
initialErrorReason: ARTErrorInfo? = nil,
attachBehavior: AttachOrDetachBehavior? = nil,
detachBehavior: AttachOrDetachBehavior? = nil,
messageToEmitOnSubscribe: ARTMessage? = nil
messageToEmitOnSubscribe: ARTMessage? = nil,
stateChangeToEmitForListener: ARTChannelStateChange? = nil
) {
_name = name
_state = initialState
Expand All @@ -38,6 +40,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol {
self.messageToEmitOnSubscribe = messageToEmitOnSubscribe
attachSerial = properties.attachSerial
channelSerial = properties.channelSerial
self.stateChangeToEmitForListener = stateChangeToEmitForListener
}

var state: ARTRealtimeChannelState {
Expand Down Expand Up @@ -134,9 +137,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol {
// Added the ability to emit a message whenever we want instead of just on subscribe... I didn't want to dig into what the messageToEmitOnSubscribe is too much so just if/else between the two.
func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener? {
if let messageToEmitOnSubscribe {
Task {
callback(messageToEmitOnSubscribe)
}
callback(messageToEmitOnSubscribe)
} else {
channelSubscriptions.append((name, callback))
}
Expand All @@ -150,18 +151,21 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol {
}

func unsubscribe(_: ARTEventListener?) {
// no-op; revisit if we need to test something that depends on this method actually stopping `subscribe` from emitting more events
channelSubscriptions.removeAll() // make more strict when needed
}

private var stateSubscriptionCallbacks: [@MainActor (ARTChannelStateChange) -> Void] = []

func on(_: ARTChannelEvent, callback _: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
ARTEventListener()
func on(_: ARTChannelEvent, callback: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
stateSubscriptionCallbacks.append(callback)
return ARTEventListener()
}

func on(_ callback: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener {
stateSubscriptionCallbacks.append(callback)

if let stateChangeToEmitForListener {
callback(stateChangeToEmitForListener)
}
return ARTEventListener()
}

Expand Down