Skip to content

Commit

Permalink
CHA-M5 spec tests
Browse files Browse the repository at this point in the history
  • Loading branch information
maratal committed Feb 21, 2025
1 parent 621a00d commit 87dc17a
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 54 deletions.
2 changes: 1 addition & 1 deletion Sources/AblyChat/DefaultMessages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities {
}
}

// (CHA-M4d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial.
// (CHA-M5d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial.
channel.on(.update) { [weak self] stateChange in
Task {
do {
Expand Down
23 changes: 0 additions & 23 deletions Tests/AblyChatTests/ChatAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,27 +164,4 @@ struct ChatAPITests {
// Then
#expect(getMessagesResult == expectedPaginatedResult)
}

// @spec CHA-M5i
@Test
func getMessages_whenGetMessagesReturnsServerError_throwsARTError() async {
// Given
let paginatedResponse = MockHTTPPaginatedResponse.successGetMessagesWithNoItems
let artError = ARTErrorInfo.create(withCode: 50000, message: "Internal server error")
let realtime = MockRealtime.create {
(paginatedResponse, artError)
}
let chatAPI = ChatAPI(realtime: realtime)
let roomId = "basketball::$chat::$chatMessages"

await #expect(
performing: {
// When
try await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper<Message>
}, throws: { error in
// Then
error as? ARTErrorInfo == artError
}
)
}
}
207 changes: 199 additions & 8 deletions Tests/AblyChatTests/DefaultMessagesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,214 @@ struct DefaultMessagesTests {

// @spec CHA-M5a
@Test
func subscribe_whenChannelIsAttachedAndNoChannelSerial_throwsError() async throws {
// roomId and clientId values are arbitrary
func subscriptionPointIsChannelSerialWhenUnderlyingRealtimeChannelIsAttached() async throws {
// Given
let realtime = MockRealtime.create {
(MockHTTPPaginatedResponse.successSendMessageWithNoItems, nil)
}
let channelSerial = "123"
let chatAPI = ChatAPI(realtime: realtime)
let channel = MockRealtimeChannel(properties: ARTChannelProperties(attachSerial: nil, channelSerial: channelSerial), attachResult: .success)
let featureChannel = MockFeatureChannel(channel: channel)
let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger())

// When: subscription is added when the underlying realtime channel is ATTACHED
let subscription = try await defaultMessages.subscribe()
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: subscription point is the current channelSerial of the realtime channel.
let requestParams = try #require(realtime.requestArguments.first?.params)
#expect(requestParams["fromSerial"] == channelSerial)
}

// @spec CHA-M5b
@Test
func subscriptionPointIsAttachSerialWhenUnderlyingRealtimeChannelIsNotAttached() async throws {
// Given
let realtime = MockRealtime.create()
let attachSerial = "123"
let channel = MockRealtimeChannel(properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: nil), state: .attaching, attachResult: .success)
let contributor = RoomLifecycleHelper.createContributor(feature: .messages, underlyingChannel: channel, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay), detachBehavior: .completeAndChangeState(.success, newState: .detached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay))
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

let realtime = MockRealtime.create {
(MockHTTPPaginatedResponse.successSendMessageWithNoItems, nil)
}
let chatAPI = ChatAPI(realtime: realtime)
let channel = MockRealtimeChannel(attachResult: .success)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger())

// Wait for room to become ATTACHING
let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded)
async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: UUID())
_ = try #require(await roomStatusSubscription.attachingElements().first { _ in true })

// When: subscription is added when the underlying realtime channel is not ATTACHED
let subscription = try await defaultMessages.subscribe()

// When: history get is called
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: subscription point becomes the attachSerial at the the moment of channel attachment
let requestParams = try #require(realtime.requestArguments.first?.params)
#expect(requestParams["fromSerial"] == attachSerial)
}

// @spec CHA-M5c
@Test
func whenChannelReentersATTACHEDWithResumedFalseThenSubscriptionPointResetsToAttachSerial() async throws {
// Given
let attachSerial = "attach123"
let channelSerial = "channel456"
let channel = MockRealtimeChannel(properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: channelSerial), state: .attached, attachResult: .success, detachResult: .success)
let contributor = RoomLifecycleHelper.createContributor(feature: .messages, underlyingChannel: channel, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay), detachBehavior: .completeAndChangeState(.success, newState: .detached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay))
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

let realtime = MockRealtime.create {
(MockHTTPPaginatedResponse.successSendMessageWithNoItems, nil)
}
let chatAPI = ChatAPI(realtime: realtime)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger())

// Wait for room to become ATTACHED
try await lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: UUID())

// When: subscription is added
let subscription = try await defaultMessages.subscribe()

// When: history get is called
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: subscription point is the channelSerial
let requestParams1 = try #require(realtime.requestArguments.first?.params)
#expect(requestParams1["fromSerial"] == channelSerial)

// Wait for room to become DETACHED
try await lifecycleManager.performDetachOperation(testsOnly_forcingOperationID: UUID())

// And then to become ATTACHED again
try await lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: UUID())

// When: history get is called
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: The subscription point of any subscribers must be reset to the attachSerial
let requestParams2 = try #require(realtime.requestArguments.last?.params)
#expect(requestParams2["fromSerial"] == attachSerial)
}

// @spec CHA-M5d
@Test
func whenChannelUPDATEReceivedWithResumedFalseThenSubscriptionPointResetsToAttachSerial() async throws {
// Given
let attachSerial = "attach123"
let channelSerial = "channel456"
let channel = MockRealtimeChannel(properties: ARTChannelProperties(attachSerial: attachSerial, channelSerial: channelSerial), state: .attached, attachResult: .success, detachResult: .success)
let contributor = RoomLifecycleHelper.createContributor(feature: .messages, underlyingChannel: channel, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay), detachBehavior: .completeAndChangeState(.success, newState: .detached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay))
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

let realtime = MockRealtime.create {
(MockHTTPPaginatedResponse.successSendMessageWithNoItems, nil)
}
let chatAPI = ChatAPI(realtime: realtime)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger())

// Wait for room to become ATTACHED
try await lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: UUID())

// When: subscription is added
let subscription = try await defaultMessages.subscribe()

// When: history get is called
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: subscription point is the channelSerial
let requestParams1 = try #require(realtime.requestArguments.first?.params)
#expect(requestParams1["fromSerial"] == channelSerial)

// When: This contributor emits an UPDATE event with `resumed` flag set to false
let contributorStateChange = ARTChannelStateChange(
current: .attached, // arbitrary
previous: .attached, // arbitrary
event: .update,
reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary
resumed: false
)

await RoomLifecycleHelper.waitForManager(lifecycleManager, toHandleContributorStateChange: contributorStateChange) {
await contributor.channel.emitStateChange(contributorStateChange)
}

// When: history get is called
let _ = try await subscription.getPreviousMessages(params: .init())

// Then: The subscription point of any subscribers must be reset to the attachSerial
let requestParams2 = try #require(realtime.requestArguments.last?.params)
#expect(requestParams2["fromSerial"] == attachSerial)
}

// @spec CHA-M5f
// @spec CHA-M5g
// @spec CHA-M5h
@Test
func subscriptionGetPreviousMessagesAcceptsStandardHistoryQueryOptionsExceptForDirection() async throws {
// Given
let realtime = MockRealtime.create {
(MockHTTPPaginatedResponse.successGetMessagesWithItems, nil)
}
let chatAPI = ChatAPI(realtime: realtime)
let channel = MockRealtimeChannel(properties: ARTChannelProperties(attachSerial: nil, channelSerial: "123"), attachResult: .success)
let featureChannel = MockFeatureChannel(channel: channel)
let defaultMessages = await DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger())

// When: subscription is added when the underlying realtime channel is ATTACHED
let subscription = try await defaultMessages.subscribe()
let paginatedResult = try await subscription.getPreviousMessages(params: .init(orderBy: .oldestFirst)) // CHA-M5f, try to set unsupported direction

let requestParams = try #require(realtime.requestArguments.first?.params)

// Then
await #expect(throws: ARTErrorInfo.create(withCode: 40000, status: 400, message: "channel is attached, but channelSerial is not defined"), performing: {
// When
try await defaultMessages.subscribe()
})

// CHA-M5g: the subscription point must be additionally specified (internally, by us) in the "fromSerial" query parameter
#expect(requestParams["fromSerial"] == "123")

// CHA-M5f: method must accept any of the standard history query options, except for direction, which must always be backwards (`OrderBy.newestFirst` is equivalent to "backwards", see `getBeforeSubscriptionStart` func)
#expect(requestParams["direction"] == "backwards")

// CHA-M5h: The method must return a standard PaginatedResult
#expect(paginatedResult.items.count == 2)
#expect(paginatedResult.hasNext == true)

// CHA-M5h: which can be further inspected to paginate across results
let nextPage = try #require(await paginatedResult.next)
#expect(nextPage.hasNext == false)
}

// @spec CHA-M5i
@Test
func subscriptionGetPreviousMessagesThrowsErrorInfoInCaseOfServerError() async {
// Given
let paginatedResponse = MockHTTPPaginatedResponse.successGetMessagesWithNoItems
let artError = ARTErrorInfo.create(withCode: 50000, message: "Internal server error")
let realtime = MockRealtime.create {
(paginatedResponse, artError)
}
let chatAPI = ChatAPI(realtime: realtime)
let roomId = "basketball::$chat::$chatMessages"

await #expect(
performing: {
// When
try await chatAPI.getMessages(roomId: roomId, params: .init()) as? PaginatedResultWrapper<Message>
}, throws: { error in
// Then
error as? ARTErrorInfo == artError
}
)
}

// @spec CHA-M6a
@Test
func get_getMessagesIsExposedFromChatAPI() async throws {
// Message response of succcess with no items, and roomId are arbitrary
Expand Down
13 changes: 13 additions & 0 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ enum RoomLifecycleHelper {
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
underlyingChannel: MockRealtimeChannel? = nil,
attachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil,
detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil,
subscribeToStateBehavior: MockRoomLifecycleContributorChannel.SubscribeToStateBehavior? = nil
) -> MockRoomLifecycleContributor {
.init(
feature: feature,
channel: .init(
underlyingChannel: underlyingChannel,
initialState: initialState,
initialErrorReason: initialErrorReason,
attachBehavior: attachBehavior,
Expand All @@ -79,6 +81,17 @@ enum RoomLifecycleHelper {
)
)
}

/// Given a room lifecycle manager and a channel state change, this method will return once the manager has performed all of the side effects that it will perform as a result of receiving this state change. You can provide a function which will be called after ``waitForManager`` has started listening for the manager’s “state change handled” notifications.
// TODO: replace duplicates of this func elsewhere
static func waitForManager(_ manager: DefaultRoomLifecycleManager<some RoomLifecycleContributor>, toHandleContributorStateChange stateChange: ARTChannelStateChange, during action: () async -> Void) async {
let subscription = await manager.testsOnly_subscribeToHandledContributorStateChanges()
async let handledSignal = subscription.first {
$0 === stateChange
}
await action()
_ = await handledSignal
}
}

extension Double {
Expand Down
26 changes: 15 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 @@ -117,7 +114,8 @@ extension MockHTTPPaginatedResponse {
],
],
statusCode: 200,
headers: [:]
headers: [:],
hasNext: true
)
}

Expand All @@ -127,21 +125,27 @@ extension MockHTTPPaginatedResponse {
static let nextPage = MockHTTPPaginatedResponse(
items: [
[
"serial": "3446450",
"clientId": "random",
"serial": "3446458",
"action": "message.create",
"createdAt": 1_730_943_049_269,
"roomId": "basketball::$chat::$chatMessages",
"text": "previous message",
"text": "next hello",
"metadata": [:],
"headers": [:],
],
[
"serial": "3446451",
"clientId": "random",
"serial": "3446459",
"action": "message.create",
"roomId": "basketball::$chat::$chatMessages",
"text": "previous response",
"text": "next hello response",
"metadata": [:],
"headers": [:],
],
],
statusCode: 200,
headers: [:]
headers: [:],
hasNext: false
)
}
Loading

0 comments on commit 87dc17a

Please sign in to comment.