diff --git a/Amplify.xcodeproj/project.pbxproj b/Amplify.xcodeproj/project.pbxproj index 69e48e5e7d..6cc14a6086 100755 --- a/Amplify.xcodeproj/project.pbxproj +++ b/Amplify.xcodeproj/project.pbxproj @@ -353,13 +353,13 @@ B9FAA180238FBB5D009414B4 /* Model+Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FAA17F238FBB5D009414B4 /* Model+Array.swift */; }; B9FB05F82383740D00DE1FD4 /* DataStoreStatement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB05F72383740D00DE1FD4 /* DataStoreStatement.swift */; }; D83C5160248964780091548E /* ModelGraphQLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83C515F248964780091548E /* ModelGraphQLTests.swift */; }; + D8DD7A1D24A1CCCD001C49FD /* QuerySortInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DD7A1C24A1CCCD001C49FD /* QuerySortInput.swift */; }; FA00F68824DA37EE003E8A71 /* AuthCategoryBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F68724DA37EE003E8A71 /* AuthCategoryBehavior+Combine.swift */; }; FA00F68A24DA3A43003E8A71 /* AuthCategoryDeviceBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F68924DA3A43003E8A71 /* AuthCategoryDeviceBehavior+Combine.swift */; }; FA00F68C24DA3A8F003E8A71 /* AuthCategoryUserBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F68B24DA3A8F003E8A71 /* AuthCategoryUserBehavior+Combine.swift */; }; FA00F68E24DA3DFF003E8A71 /* HubCategoryBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F68D24DA3DFE003E8A71 /* HubCategoryBehavior+Combine.swift */; }; FA00F69024DA3F95003E8A71 /* HubCombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F68F24DA3F95003E8A71 /* HubCombineTests.swift */; }; FA00F69224DA4087003E8A71 /* PredictionsCategoryBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00F69124DA4087003E8A71 /* PredictionsCategoryBehavior+Combine.swift */; }; - D8DD7A1D24A1CCCD001C49FD /* QuerySortInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DD7A1C24A1CCCD001C49FD /* QuerySortInput.swift */; }; FA0173352375F8A5005DDDFC /* LoggingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0173342375F8A5005DDDFC /* LoggingError.swift */; }; FA0173372375FAA5005DDDFC /* HubError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0173362375FAA5005DDDFC /* HubError.swift */; }; FA05B83424CE265E0026180B /* StorageCategory+ClientBehavior+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA05B83324CE265D0026180B /* StorageCategory+ClientBehavior+Combine.swift */; }; diff --git a/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift b/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift index 18d77b2c35..ce26faf0a7 100644 --- a/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift +++ b/Amplify/Categories/DataStore/DataStoreCategory+HubPayloadEventName.swift @@ -23,4 +23,18 @@ public extension HubPayload.EventName.DataStore { /// Dispatched when DataStore receives a sync response from the remote API via the API category. The Hub Payload /// will be a `MutationEvent` instance that caused the conditional save failed. static let conditionalSaveFailed = "DataStore.conditionalSaveFailed" + + /// Dispatched when: + /// - the DataStore starts + /// - each time a local mutation is enqueued into the outbox + /// - each time a local mutation is finished processing + /// HubPayload `OutboxStatusEvent` contains a boolean value `isEmpty` to notify if there are mutations in the outbox + static let outboxStatus = "DataStore.outboxStatus" + + /// Dispatched when DataStore has finished establishing its subscriptions to all syncable models + static let subscriptionsEstablished = "DataStore.subscriptionEstablished" + + /// Dispatched when DataStore is about to start sync queries + /// HubPayload `syncQueriesStartedEvent` contains an array of each model's `name` + static let syncQueriesStarted = "DataStore.syncQueriesStarted" } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/OutboxStatusEvent.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/OutboxStatusEvent.swift new file mode 100644 index 0000000000..c8d0c964c7 --- /dev/null +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/OutboxStatusEvent.swift @@ -0,0 +1,16 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Used as HubPayload for the `OutboxStatus` +public struct OutboxStatusEvent { + /// status of outbox: true if there are no events in the outbox at the time the event was dispatched + public let isEmpty: Bool + + public init(isEmpty: Bool) { + self.isEmpty = isEmpty + } +} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/SyncQueriesStartedEvent.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/SyncQueriesStartedEvent.swift new file mode 100644 index 0000000000..f58d99eafa --- /dev/null +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Events/SyncQueriesStartedEvent.swift @@ -0,0 +1,16 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +/// Used as HubPayload for the `SyncQueriesStarted` +public struct SyncQueriesStartedEvent { + /// A list of all model names for which DataStore has started establishing subscriptions + public let models: [String] + + public init(models: [String]) { + self.models = models + } +} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/InitialSync/InitialSyncOrchestrator.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/InitialSync/InitialSyncOrchestrator.swift index e18532f160..e5484feea8 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/InitialSync/InitialSyncOrchestrator.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/InitialSync/InitialSyncOrchestrator.swift @@ -62,7 +62,8 @@ final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { log.info("Beginning initial sync") - enqueueSyncableModels() + let syncableModels = ModelRegistry.models.filter { $0.schema.isSyncable } + enqueueSyncableModels(syncableModels) // This operation is intentionally not cancel-aware; we always want resolveCompletion to execute // as the last item @@ -70,11 +71,12 @@ final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { self.resolveCompletion() } + let modelNames = syncableModels.map { $0.modelName } + dispatchSyncQueriesStarted(for: modelNames) syncOperationQueue.isSuspended = false } - private func enqueueSyncableModels() { - let syncableModels = ModelRegistry.models.filter { $0.schema.isSyncable } + private func enqueueSyncableModels(_ syncableModels: [Model.Type]) { let sortedModels = syncableModels.sortByDependencyOrder() for model in sortedModels { enqueueSyncOperation(for: model) @@ -118,6 +120,13 @@ final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { completion?(.successfulVoid) } + private func dispatchSyncQueriesStarted(for modelNames: [String]) { + let syncQueriesStartedEvent = SyncQueriesStartedEvent(models: modelNames) + let syncQueriesStartedEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.syncQueriesStarted, + data: syncQueriesStartedEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: syncQueriesStartedEventPayload) + } + } @available(iOS 13.0, *) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift index 8a2a660a3a..11f56728c9 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift @@ -138,10 +138,12 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { mutationEventPublisher: MutationEventPublisher) { log.verbose(#function) self.api = api - operationQueue.isSuspended = false - // State machine notification to ".receivedSubscription" will be handled in `receive(subscription:)` - mutationEventPublisher.publisher.subscribe(self) + queryMutationEventsFromStorage(onComplete: { + self.operationQueue.isSuspended = false + // State machine notification to ".receivedSubscription" will be handled in `receive(subscription:)` + mutationEventPublisher.publisher.subscribe(self) + }) } // MARK: - Event loop processing @@ -187,6 +189,8 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { "[SyncMutationToCloudOperation] mutationEvent finished: \(mutationEvent.id); result: \(result)") self.processSyncMutationToCloudResult(result, mutationEvent: mutationEvent, api: api) } + + dispatchOutboxStatusEvent(isEmpty: false) operationQueue.addOperation(syncMutationToCloudOperation) stateMachine.notify(action: .enqueuedEvent) } @@ -241,10 +245,37 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { self.log.verbose("mutationEvent deleted successfully") } - self.stateMachine.notify(action: .processedEvent) + self.queryMutationEventsFromStorage { + self.stateMachine.notify(action: .processedEvent) + } } } + private func queryMutationEventsFromStorage(onComplete: @escaping (() -> Void)) { + let fields = MutationEvent.keys + let predicate = fields.inProcess == false || fields.inProcess == nil + + storageAdapter.query(MutationEvent.self, + predicate: predicate, + sort: nil, + paginationInput: nil) { result in + switch result { + case .success(let events): + self.dispatchOutboxStatusEvent(isEmpty: events.isEmpty) + case .failure(let error): + log.error("Error querying mutation events: \(error)") + } + onComplete() + } + } + + private func dispatchOutboxStatusEvent(isEmpty: Bool) { + let outboxStatusEvent = OutboxStatusEvent(isEmpty: isEmpty) + let outboxStatusEventPayload = HubPayload(eventName: HubPayload.EventName.DataStore.outboxStatus, + data: outboxStatusEvent) + Amplify.Hub.dispatch(to: .dataStore, payload: outboxStatusEventPayload) + } + } @available(iOS 13.0, *) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift index b1f1c393ff..ca7e8cca6a 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/RemoteSyncEngine+IncomingEventReconciliationQueueEvent.swift @@ -34,6 +34,8 @@ extension RemoteSyncEngine { func onReceive(receiveValue: IncomingEventReconciliationQueueEvent) { switch receiveValue { case .initialized: + let payload = HubPayload(eventName: HubPayload.EventName.DataStore.subscriptionsEstablished) + Amplify.Hub.dispatch(to: .dataStore, payload: payload) remoteSyncTopicPublisher.send(.subscriptionsInitialized) stateMachine.notify(action: .initializedSubscriptions) case .started: diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift index f562cd9e71..e94a11495d 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift @@ -151,6 +151,7 @@ final class AWSModelReconciliationQueue: ModelReconciliationQueue { modelReconciliationQueueSubject.send(.connected(modelName)) } } + private func receiveCompletion(_ completion: Subscribers.Completion) { switch completion { case .finished: diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift index dbfab30bd1..c3402df818 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift @@ -173,13 +173,16 @@ class DataStoreEndToEndTests: SyncEngineIntegrationTestBase { createdAt: date) var updatedPost = newPost - updatedPost.content = "UPDATED CONTENT from DataStoreEndToEndTests at \(Date())" + updatedPost.content = "UPDATED CONTENT from DataStoreEndToEndTests at \(Date())" let createReceived = expectation(description: "Create notification received") let updateLocalSuccess = expectation(description: "Update local successful") let conditionalReceived = expectation(description: "Conditional save failed received") - let hubListener = Amplify.Hub.listen(to: .dataStore) { payload in + let syncReceivedFilter = HubFilters.forEventName(HubPayload.EventName.DataStore.syncReceived) + let conditionalSaveFailedFilter = HubFilters.forEventName(HubPayload.EventName.DataStore.conditionalSaveFailed) + let filters = HubFilters.any(filters: syncReceivedFilter, conditionalSaveFailedFilter) + let hubListener = Amplify.Hub.listen(to: .dataStore, isIncluded: filters) { payload in guard let mutationEvent = payload.data as? MutationEvent else { XCTFail("Can't cast payload as mutation event") diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreHubEventsTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreHubEventsTests.swift new file mode 100644 index 0000000000..2c06a5377f --- /dev/null +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreHubEventsTests.swift @@ -0,0 +1,67 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest + +import AmplifyPlugins +import AWSPluginsCore + +@testable import Amplify +@testable import AmplifyTestCommon +@testable import AWSDataStoreCategoryPlugin + +@available(iOS 13.0, *) +class DataStoreHubEventTests: HubEventsIntegrationTestBase { + + /// - Given: + /// - registered two models from `TestModelRegistration` + /// - no pending MutationEvents in MutationEvent database + /// - When: + /// - DataStore's remote sync engine is initialized + /// - Then: + /// - subscriptionEstablished received, payload should be nil + /// - syncQueriesStarted received, payload should be: {models: ["Post", "Comment"]} + /// - outboxStatus received, payload should be {isEmpty: true} + func testDataStoreConfiguredDispatchesHubEvents() throws { + + let subscriptionsEstablishedReceived = expectation(description: "subscriptionsEstablished received") + let syncQueriesStartedReceived = expectation(description: "syncQueriesStarted received") + let outboxStatusReceived = expectation(description: "outboxStatus received") + + let hubListener = Amplify.Hub.listen(to: .dataStore) { payload in + if payload.eventName == HubPayload.EventName.DataStore.subscriptionsEstablished { + XCTAssertNil(payload.data) + subscriptionsEstablishedReceived.fulfill() + } + + if payload.eventName == HubPayload.EventName.DataStore.syncQueriesStarted { + guard let syncQueriesStartedEvent = payload.data as? SyncQueriesStartedEvent else { + XCTFail("Failed to cast payload data as SyncQueriesStartedEvent") + return + } + XCTAssertEqual(syncQueriesStartedEvent.models.count, 2) + syncQueriesStartedReceived.fulfill() + } + + if payload.eventName == HubPayload.EventName.DataStore.outboxStatus { + guard let outboxStatusEvent = payload.data as? OutboxStatusEvent else { + XCTFail("Failed to cast payload data as OutboxStatusEvent") + return + } + XCTAssertTrue(outboxStatusEvent.isEmpty) + outboxStatusReceived.fulfill() + } + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } + + waitForExpectations(timeout: networkTimeout, handler: nil) + } +} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/HubEventsIntegrationTestBase.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/HubEventsIntegrationTestBase.swift new file mode 100644 index 0000000000..57b5dd6d3c --- /dev/null +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/TestSupport/HubEventsIntegrationTestBase.swift @@ -0,0 +1,50 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest + +import AmplifyPlugins +import AWSMobileClient + +@testable import Amplify +@testable import AmplifyTestCommon +@testable import AWSDataStoreCategoryPlugin + +class HubEventsIntegrationTestBase: XCTestCase { + + static let networkTimeout = TimeInterval(180) + let networkTimeout = HubEventsIntegrationTestBase.networkTimeout + + override func setUp() { + super.setUp() + + continueAfterFailure = false + + let bundle = Bundle(for: type(of: self)) + guard let configFile = bundle.url(forResource: "amplifyconfiguration", withExtension: "json") else { + XCTFail("Could not get URL for amplifyconfiguration.json from \(bundle)") + return + } + + do { + let configData = try Data(contentsOf: configFile) + let amplifyConfig = try JSONDecoder().decode(AmplifyConfiguration.self, from: configData) + try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: TestModelRegistration())) + try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: TestModelRegistration())) + try Amplify.configure(amplifyConfig) + } catch { + XCTFail(String(describing: error)) + return + } + } + + override func tearDown() { + sleep(1) + print("Amplify reset") + Amplify.reset() + } +} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/InitialSync/InitialSyncOrchestratorTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/InitialSync/InitialSyncOrchestratorTests.swift index 9a694a339f..6b9147f0b9 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/InitialSync/InitialSyncOrchestratorTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/InitialSync/InitialSyncOrchestratorTests.swift @@ -49,11 +49,29 @@ class InitialSyncOrchestratorTests: XCTestCase { storageAdapter: storageAdapter) let syncCallbackReceived = expectation(description: "Sync callback received, sync operation is complete") + let syncQueriesStartedReceived = expectation(description: "syncQueriesStarted received") + + let filter = HubFilters.forEventName(HubPayload.EventName.DataStore.syncQueriesStarted) + let hubListener = Amplify.Hub.listen(to: .dataStore, isIncluded: filter) { payload in + guard let syncQueriesStartedEvent = payload.data as? SyncQueriesStartedEvent else { + XCTFail("Failed to cast payload data as SyncQueriesStartedEvent") + return + } + XCTAssertEqual(syncQueriesStartedEvent.models.count, 2) + syncQueriesStartedReceived.fulfill() + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } orchestrator.sync { _ in syncCallbackReceived.fulfill() } + wait(for: [syncQueriesStartedReceived], timeout: 1.0) wait(for: [syncCallbackReceived], timeout: 1.0) + Amplify.Hub.removeListener(hubListener) } /// - Given: An InitialSyncOrchestrator with a model dependency graph diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/OutgoingMutationQueueTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/OutgoingMutationQueueTests.swift index cfd1fe5728..4d07db133a 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/OutgoingMutationQueueTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/OutgoingMutationQueueTests.swift @@ -26,11 +26,36 @@ class OutgoingMutationQueueTests: SyncEngineTestBase { try setUpDataStore(mutationQueue: OutgoingMutationQueue(storageAdapter: storageAdapter, dataStoreConfiguration: .default)) } - let post = Post(title: "Post title", content: "Post content", createdAt: .now()) + var outboxStatusReceivedCurrentCount = 0 + let outboxStatusOnStart = expectation(description: "On DataStore start, outboxStatus received") + let outboxStatusOnMutationEnqueued = expectation(description: "Mutation enqueued, outboxStatus received") + + let filter = HubFilters.forEventName(HubPayload.EventName.DataStore.outboxStatus) + let hubListener = Amplify.Hub.listen(to: .dataStore, isIncluded: filter) { payload in + outboxStatusReceivedCurrentCount += 1 + guard let outboxStatusEvent = payload.data as? OutboxStatusEvent else { + XCTFail("Failed to cast payload data as OutboxStatusEvent") + return + } + + if outboxStatusReceivedCurrentCount == 1 { + XCTAssertTrue(outboxStatusEvent.isEmpty) + outboxStatusOnStart.fulfill() + } else { + XCTAssertFalse(outboxStatusEvent.isEmpty) + outboxStatusOnMutationEnqueued.fulfill() + } + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } + let createMutationSent = expectation(description: "Create mutation sent to API category") apiPlugin.listeners.append { message in if message.contains("createPost") && message.contains(post.id) { @@ -41,7 +66,8 @@ class OutgoingMutationQueueTests: SyncEngineTestBase { try startAmplifyAndWaitForSync() Amplify.DataStore.save(post) { _ in } - wait(for: [createMutationSent], timeout: 5.0) + waitForExpectations(timeout: 5.0, handler: nil) + Amplify.Hub.removeListener(hubListener) } /// - Given: A sync-configured DataStore @@ -95,6 +121,32 @@ class OutgoingMutationQueueTests: SyncEngineTestBase { wait(for: [mutationEventSaved], timeout: 1.0) + var outboxStatusReceivedCurrentCount = 0 + let outboxStatusOnStart = expectation(description: "On DataStore start, outboxStatus received") + let outboxStatusOnMutationEnqueued = expectation(description: "Mutation enqueued, outboxStatus received") + + let filter = HubFilters.forEventName(HubPayload.EventName.DataStore.outboxStatus) + let hubListener = Amplify.Hub.listen(to: .dataStore, isIncluded: filter) { payload in + outboxStatusReceivedCurrentCount += 1 + guard let outboxStatusEvent = payload.data as? OutboxStatusEvent else { + XCTFail("Failed to cast payload data as OutboxStatusEvent") + return + } + + if outboxStatusReceivedCurrentCount == 1 { + XCTAssertFalse(outboxStatusEvent.isEmpty) + outboxStatusOnStart.fulfill() + } else { + XCTAssertFalse(outboxStatusEvent.isEmpty) + outboxStatusOnMutationEnqueued.fulfill() + } + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } + let mutation1Sent = expectation(description: "Create mutation 1 sent to API category") let mutation2Sent = expectation(description: "Create mutation 2 sent to API category") mutation2Sent.isInverted = true @@ -113,6 +165,7 @@ class OutgoingMutationQueueTests: SyncEngineTestBase { } waitForExpectations(timeout: 5.0, handler: nil) + Amplify.Hub.removeListener(hubListener) } /// - Given: A sync-configured DataStore diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/RemoteSyncEngineTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/RemoteSyncEngineTests.swift index f72ddf83da..b79477b94d 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/RemoteSyncEngineTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/RemoteSyncEngineTests.swift @@ -68,6 +68,7 @@ class RemoteSyncEngineTests: XCTestCase { let mutationsPaused = expectation(description: "mutationsPaused") let stateMutationsCleared = expectation(description: "stateMutationsCleared") let subscriptionsInitialized = expectation(description: "subscriptionsInitialized") + let subscriptionsEstablishedReceived = expectation(description: "subscriptionsEstablished received") let cleanedup = expectation(description: "cleanedup") let failureOnInitialSync = expectation(description: "failureOnInitialSync") @@ -76,6 +77,17 @@ class RemoteSyncEngineTests: XCTestCase { let advice = RequestRetryAdvice.init(shouldRetry: false) mockRequestRetryablePolicy.pushOnRetryRequestAdvice(response: advice) + let filter = HubFilters.forEventName(HubPayload.EventName.DataStore.subscriptionsEstablished) + let hubListener = Amplify.Hub.listen(to: .dataStore, isIncluded: filter) { payload in + XCTAssertNil(payload.data) + subscriptionsEstablishedReceived.fulfill() + } + + guard try HubListenerTestUtilities.waitForListener(with: hubListener, timeout: 5.0) else { + XCTFail("Listener not registered for hub") + return + } + let remoteSyncEngineSink = remoteSyncEngine .publisher .sink(receiveCompletion: { _ in @@ -113,9 +125,11 @@ class RemoteSyncEngineTests: XCTestCase { mutationsPaused, stateMutationsCleared, subscriptionsInitialized, + subscriptionsEstablishedReceived, cleanedup, failureOnInitialSync], timeout: defaultAsyncWaitTimeout) remoteSyncEngineSink.cancel() + Amplify.Hub.removeListener(hubListener) } func testRemoteSyncEngineHappyPath() throws { diff --git a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj index ff9c263eea..f8392a5687 100755 --- a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj @@ -96,11 +96,15 @@ B9FAA140238C600A009414B4 /* ListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FAA13F238C600A009414B4 /* ListTests.swift */; }; B9FAA142238C6082009414B4 /* BaseDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FAA141238C6082009414B4 /* BaseDataStoreTests.swift */; }; D80064F62499297800935DA3 /* MockFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80064F52499297800935DA3 /* MockFileManager.swift */; }; + D838AB4424FF264600BF4940 /* OutboxStatusEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D838AB4324FF264600BF4940 /* OutboxStatusEvent.swift */; }; + D838AB4624FF268400BF4940 /* SyncQueriesStartedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D838AB4524FF268400BF4940 /* SyncQueriesStartedEvent.swift */; }; D888E80A24A65B3800F4CE3E /* DataStoreLocalStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D888E80924A65B3800F4CE3E /* DataStoreLocalStoreTests.swift */; }; D888E80C24A65DC200F4CE3E /* LocalStoreIntegrationTestBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D888E80B24A65DC200F4CE3E /* LocalStoreIntegrationTestBase.swift */; }; D8B90862249839D4002593F5 /* amplifyconfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 21233DD7247591D100039337 /* amplifyconfiguration.json */; }; D8C5BA59249815A6007C3A68 /* DataStoreConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C5BA58249815A6007C3A68 /* DataStoreConfigurationTests.swift */; }; D8D900A4249DB599004042E7 /* QuerySort+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D900A3249DB599004042E7 /* QuerySort+SQLite.swift */; }; + D8E9992425013C2F0006170A /* DataStoreHubEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E9992325013C2F0006170A /* DataStoreHubEventsTests.swift */; }; + D8E99926250142790006170A /* HubEventsIntegrationTestBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E99925250142790006170A /* HubEventsIntegrationTestBase.swift */; }; FA0427C82396C27400D25AB0 /* SyncEngineStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0427C72396C27400D25AB0 /* SyncEngineStartupTests.swift */; }; FA0427CA2396C35500D25AB0 /* InitialSyncOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0427C92396C35500D25AB0 /* InitialSyncOrchestrator.swift */; }; FA0427CC2396C7E400D25AB0 /* InitialSyncOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0427CB2396C7E400D25AB0 /* InitialSyncOrchestratorTests.swift */; }; @@ -311,10 +315,14 @@ D4BB518039D7C264E092363E /* Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests/Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.release.xcconfig"; sourceTree = ""; }; D59B63DF64CCA73C910ADD66 /* Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.debug.xcconfig"; path = "Target Support Files/Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests/Pods-HostApp-AWSDataStoreCategoryPluginIntegrationTests.debug.xcconfig"; sourceTree = ""; }; D80064F52499297800935DA3 /* MockFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFileManager.swift; sourceTree = ""; }; + D838AB4324FF264600BF4940 /* OutboxStatusEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxStatusEvent.swift; sourceTree = ""; }; + D838AB4524FF268400BF4940 /* SyncQueriesStartedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncQueriesStartedEvent.swift; sourceTree = ""; }; D888E80924A65B3800F4CE3E /* DataStoreLocalStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreLocalStoreTests.swift; sourceTree = ""; }; D888E80B24A65DC200F4CE3E /* LocalStoreIntegrationTestBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStoreIntegrationTestBase.swift; sourceTree = ""; }; D8C5BA58249815A6007C3A68 /* DataStoreConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreConfigurationTests.swift; sourceTree = ""; }; D8D900A3249DB599004042E7 /* QuerySort+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QuerySort+SQLite.swift"; sourceTree = ""; }; + D8E9992325013C2F0006170A /* DataStoreHubEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreHubEventsTests.swift; sourceTree = ""; }; + D8E99925250142790006170A /* HubEventsIntegrationTestBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubEventsIntegrationTestBase.swift; sourceTree = ""; }; DCC3AA75D77D0DB916EC42DB /* Pods-HostApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HostApp.release.xcconfig"; path = "Target Support Files/Pods-HostApp/Pods-HostApp.release.xcconfig"; sourceTree = ""; }; EA320D973669D3843FDF755E /* Pods_HostApp_AWSDataStoreCategoryPluginAuthIntegrationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HostApp_AWSDataStoreCategoryPluginAuthIntegrationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EA53DF78CC578B7C81E72D83 /* Pods-AWSDataStoreCategoryPlugin-AWSDataStoreCategoryPluginTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AWSDataStoreCategoryPlugin-AWSDataStoreCategoryPluginTests.debug.xcconfig"; path = "Target Support Files/Pods-AWSDataStoreCategoryPlugin-AWSDataStoreCategoryPluginTests/Pods-AWSDataStoreCategoryPlugin-AWSDataStoreCategoryPluginTests.debug.xcconfig"; sourceTree = ""; }; @@ -552,6 +560,7 @@ FAED5736238B2DFA008EBED8 /* MutationSync */, FAED5735238B2DEC008EBED8 /* SubscriptionSync */, FA8F4D1C2395AF5E00861D91 /* Support */, + D838AB4224FF260A00BF4940 /* Events */, ); path = Sync; sourceTree = ""; @@ -599,6 +608,7 @@ children = ( 21233DD7247591D100039337 /* amplifyconfiguration.json */, FA6B0EA9249445D50062AA59 /* AWSDataStorePluginConfigurationTests.swift */, + D8E9992325013C2F0006170A /* DataStoreHubEventsTests.swift */, FAB571412395A3E80006A5F8 /* DataStoreEndToEndTests.swift */, D888E80924A65B3800F4CE3E /* DataStoreLocalStoreTests.swift */, D8C5BA58249815A6007C3A68 /* DataStoreConfigurationTests.swift */, @@ -650,6 +660,15 @@ path = Models; sourceTree = ""; }; + D838AB4224FF260A00BF4940 /* Events */ = { + isa = PBXGroup; + children = ( + D838AB4524FF268400BF4940 /* SyncQueriesStartedEvent.swift */, + D838AB4324FF264600BF4940 /* OutboxStatusEvent.swift */, + ); + path = Events; + sourceTree = ""; + }; FA60461123980A6A009E4B97 /* InitialSync */ = { isa = PBXGroup; children = ( @@ -766,6 +785,7 @@ isa = PBXGroup; children = ( FAB5713F23958C210006A5F8 /* SyncEngineIntegrationTestBase.swift */, + D8E99925250142790006170A /* HubEventsIntegrationTestBase.swift */, D888E80B24A65DC200F4CE3E /* LocalStoreIntegrationTestBase.swift */, FAD2BDF5239583B2006EB065 /* TestModelRegistration.swift */, ); @@ -1438,6 +1458,7 @@ 6B3CC61923F5E64F0008ECBC /* RemoteSyncEngine+State.swift in Sources */, B912D1B824296F1E0028F05C /* QueryPaginationInput+SQLite.swift in Sources */, FA6C3FEC23988D0900A73110 /* AWSIncomingEventReconciliationQueue.swift in Sources */, + D838AB4424FF264600BF4940 /* OutboxStatusEvent.swift in Sources */, 6B01B72023A4672500AD0E97 /* RequestRetryable.swift in Sources */, 6B01B72223A4672500AD0E97 /* RequestRetryablePolicy.swift in Sources */, FAC010EA23956D2500FCE7BB /* ReconcileAndLocalSaveOperation+Action.swift in Sources */, @@ -1477,6 +1498,7 @@ 2149E5C72388684F00873955 /* StorageEngine.swift in Sources */, FA3B3F05238F22F5002EFDB3 /* OutgoingMutationQueue+Action.swift in Sources */, 21233E1324763D0700039337 /* StorageEngine+SyncRequirement.swift in Sources */, + D838AB4624FF268400BF4940 /* SyncQueriesStartedEvent.swift in Sources */, FAED573E238B4C2F008EBED8 /* StorageEngineAdapter+UntypedModel.swift in Sources */, B9334BA22433AF3E00C9F407 /* DataStoreConfiguration.swift in Sources */, FA4A9559239ACC1B008E876E /* IncomingSubscriptionEventPublisher.swift in Sources */, @@ -1584,6 +1606,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D8E9992425013C2F0006170A /* DataStoreHubEventsTests.swift in Sources */, D8C5BA59249815A6007C3A68 /* DataStoreConfigurationTests.swift in Sources */, 2149E62D23886D3900873955 /* SyncMetadataTests.swift in Sources */, FAD2BDF6239583B2006EB065 /* TestModelRegistration.swift in Sources */, @@ -1591,6 +1614,7 @@ D888E80C24A65DC200F4CE3E /* LocalStoreIntegrationTestBase.swift in Sources */, FA3841EB23889D6C0070AD5B /* SubscriptionEndToEndTests.swift in Sources */, FAB571422395A3E80006A5F8 /* DataStoreEndToEndTests.swift in Sources */, + D8E99926250142790006170A /* HubEventsIntegrationTestBase.swift in Sources */, FAB5714023958C210006A5F8 /* SyncEngineIntegrationTestBase.swift in Sources */, FA2E6B8D2497C17500779D2F /* AWSDataStorePluginConfigurationTests.swift in Sources */, ); @@ -1814,7 +1838,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = W3DRXD72QU; + DEVELOPMENT_TEAM = 388HH958Q3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1840,7 +1864,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = W3DRXD72QU; + DEVELOPMENT_TEAM = 388HH958Q3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath";