diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginLazyLoadTests/LL1/AWSDataStoreLazyLoadPostComment4V2StressTests.swift b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginLazyLoadTests/LL1/AWSDataStoreLazyLoadPostComment4V2StressTests.swift new file mode 100644 index 0000000000..4071051c52 --- /dev/null +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginLazyLoadTests/LL1/AWSDataStoreLazyLoadPostComment4V2StressTests.swift @@ -0,0 +1,211 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Combine +import XCTest + +import Amplify +import AWSPluginsCore +import AWSDataStorePlugin + +extension AWSDataStoreLazyLoadPostComment4V2Tests { + + static let loggingContext = "multiSaveWithInterruptions" + + /// Test performing save's and stop/start concurrently. + /// + /// This test was validated prior to [PR 3492](https://github.com/aws-amplify/amplify-swift/pull/3492) + /// and will fail. The failure will show up when the test asserts that a queried comment from AppSync should contain the associated + /// post, but comment's post is `nil`. See the PR for changes in adding transactional support for commiting the two writes (saving the model and + /// mutation event) and MutationEvent dequeuing logic. + /// + /// - Given: A set of models (post and comment) created and saved to DataStore. + /// - When: A detached task will interrupt DataStore by calling `DataStore.stop()`, + /// followed by restarting it (`DataStore.start()`), while saving comment and posts. + /// - Then: + /// - DataStore should sync data in the correct order of what was saved/submitted to it + /// - the post should be synced before the comment + /// - it should not skip over an item, ie. a comment saved but post is missing. + /// - The remote store should contain all items synced + /// - comments and post should exist. + /// - the comment should also have the post reference. + /// + func testMultiSaveWithInterruptions() async throws { + await setup(withModels: PostComment4V2Models()) + let amplify = AmplifyTestExecutor() + + Amplify.Logging.info("[\(AWSDataStoreLazyLoadPostComment4V2Tests.loggingContext)] Begin saving data with interruptions") + let savesSyncedExpectation = expectation(description: "Outbox is empty after saving (with interruptions)") + savesSyncedExpectation.assertForOverFulfill = false + try await amplify.multipleSavesWithInterruptions(savesSyncedExpectation) + await fulfillment(of: [savesSyncedExpectation], timeout: 120) + + Amplify.Logging.info("[\(AWSDataStoreLazyLoadPostComment4V2Tests.loggingContext)] Outbox is empty, begin asserting data") + let savedModels = await amplify.savedModels + for savedModel in savedModels { + let savedComment = savedModel.0 + let savedPost = savedModel.1 + + try await assertQueryComment(savedComment, post: savedPost) + try await assertQueryPost(savedPost) + } + Amplify.Logging.info("[\(AWSDataStoreLazyLoadPostComment4V2Tests.loggingContext)] All models match remote store, begin clean up.") + try await cleanUp(savedModels) + } + + actor AmplifyTestExecutor { + var savedModels = [(Comment, Post)]() + + /// The minimum number of iterations, through trial and error, found to reproduce the bug. + private let count = 15 + + /// `isOutboxEmpty` is used to return the flow back to the caller via fulfilling the `savesSyncedExpectation`. + /// By listening to the OutboxEvent after performing the operations, the last outboxEvent to be `true` while `index` + /// is the last index, will be when `savesSyncedExpectation` is fulfilled and returned execution back to the caller. + private var isOutboxEmpty = false + + private var index = 0 + private var subscribeToOutboxEventTask: Task? + private var outboxEventsCount = 0 + + /// Perform saving the comment/post in one detached task while another detached task will + /// perform the interruption (stop/start). Repeat with a bit of delay to allow DataStore some + /// time to kick off its start sequence- this will always be the case since the last operation of + /// each detached task is a `save` (implicit `start`) or a `start`. + func multipleSavesWithInterruptions(_ savesSyncedExpectation: XCTestExpectation) async throws { + subscribeToOutboxEvent(savesSyncedExpectation) + while isOutboxEmpty == false { + try await Task.sleep(seconds: 1) + } + for i in 0..