/* * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). * You may not use this file except in compliance with the License. * A copy of the License is located at * * http://aws.amazon.com/apache2.0 * * or in the "license" file accompanying this file. This file is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing * permissions and limitations under the License. */ package com.amplifyframework.datastore; import android.content.Context; import androidx.annotation.RawRes; import com.amplifyframework.AmplifyException; import com.amplifyframework.api.ApiCategory; import com.amplifyframework.api.ApiException; import com.amplifyframework.api.aws.AWSApiPlugin; import com.amplifyframework.api.graphql.GraphQLResponse; import com.amplifyframework.core.Amplify; import com.amplifyframework.core.AmplifyConfiguration; import com.amplifyframework.core.category.CategoryConfiguration; import com.amplifyframework.core.category.CategoryType; import com.amplifyframework.core.model.ModelSchema; import com.amplifyframework.core.model.query.predicate.QueryPredicate; import com.amplifyframework.core.model.temporal.Temporal; import com.amplifyframework.datastore.appsync.AppSyncClient; import com.amplifyframework.datastore.appsync.ModelMetadata; import com.amplifyframework.datastore.appsync.ModelWithMetadata; import com.amplifyframework.datastore.appsync.SynchronousAppSync; import com.amplifyframework.hub.HubChannel; import com.amplifyframework.logging.AndroidLoggingPlugin; import com.amplifyframework.logging.LogLevel; import com.amplifyframework.testmodels.commentsblog.AmplifyModelProvider; import com.amplifyframework.testmodels.commentsblog.Author; import com.amplifyframework.testmodels.commentsblog.Blog; import com.amplifyframework.testmodels.commentsblog.BlogOwner; import com.amplifyframework.testmodels.commentsblog.Comment; import com.amplifyframework.testmodels.commentsblog.Post; import com.amplifyframework.testmodels.commentsblog.PostStatus; import com.amplifyframework.testutils.HubAccumulator; import com.amplifyframework.testutils.ModelAssert; import com.amplifyframework.testutils.Resources; import com.amplifyframework.testutils.sync.SynchronousApi; import com.amplifyframework.testutils.sync.SynchronousDataStore; import org.junit.AfterClass; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Ignore; import org.junit.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.TimeUnit; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static com.amplifyframework.datastore.DataStoreHubEventFilters.publicationOf; import static com.amplifyframework.datastore.DataStoreHubEventFilters.receiptOf; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; /** * Tests the functions of {@link AWSDataStorePlugin}. * This test expects a backend API that has support for the {@link Blog} family of models, * which were defined by the schema in: * testmodels/src/main/java/com/amplifyframework/testmodels/commentsblog/schema.graphql. */ public final class BasicCloudSyncInstrumentationTest { private static final int TIMEOUT_SECONDS = 60; private static SynchronousApi api; private static SynchronousAppSync appSync; private static SynchronousDataStore dataStore; /** * Once, before any/all tests in this class, setup miscellaneous dependencies, * including synchronous API, AppSync, and DataStore interfaces. The API and AppSync instances * are used to arrange/validate data. The DataStore interface will delegate to an * {@link AWSDataStorePlugin}, which is the thing we're actually testing. * @throws AmplifyException On failure to read config, setup API or DataStore categories */ @BeforeClass public static void setup() throws AmplifyException { Amplify.addPlugin(new AndroidLoggingPlugin(LogLevel.VERBOSE)); StrictMode.enable(); Context context = getApplicationContext(); @RawRes int configResourceId = Resources.getRawResourceId(context, "amplifyconfigurationupdated"); // Setup an API CategoryConfiguration apiCategoryConfiguration = AmplifyConfiguration.fromConfigFile(context, configResourceId) .forCategoryType(CategoryType.API); ApiCategory apiCategory = new ApiCategory(); apiCategory.addPlugin(new AWSApiPlugin()); apiCategory.configure(apiCategoryConfiguration, context); // To arrange and verify state, we need to access the supporting AppSync API api = SynchronousApi.delegatingTo(apiCategory); appSync = SynchronousAppSync.using(AppSyncClient.via(apiCategory)); long tenMinutesAgo = new Date().getTime() - TimeUnit.MINUTES.toMillis(10); Temporal.DateTime tenMinutesAgoDateTime = new Temporal.DateTime(new Date(tenMinutesAgo), 0); DataStoreCategory dataStoreCategory = DataStoreCategoryConfigurator.begin() .api(apiCategory) .clearDatabase(true) .context(context) .modelProvider(AmplifyModelProvider.getInstance()) .resourceId(configResourceId) .timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .dataStoreConfiguration(DataStoreConfiguration.builder() .syncExpression(BlogOwner.class, () -> BlogOwner.CREATED_AT.gt(tenMinutesAgoDateTime)) .syncExpression(Blog.class, () -> Blog.CREATED_AT.gt(tenMinutesAgoDateTime)) .syncExpression(Post.class, () -> Post.CREATED_AT.gt(tenMinutesAgoDateTime)) .syncExpression(Comment.class, () -> Comment.CREATED_AT.gt(tenMinutesAgoDateTime)) .syncExpression(Author.class, () -> Author.CREATED_AT.gt(tenMinutesAgoDateTime)) .build()) .finish(); dataStore = SynchronousDataStore.delegatingTo(dataStoreCategory); } /** * Clear the DataStore after each test. Without calling clear in between tests, all tests after the first will fail * with this error: android.database.sqlite.SQLiteReadOnlyDatabaseException: attempt to write a readonly database. * @throws DataStoreException On failure to clear DataStore. */ @AfterClass public static void teardown() throws DataStoreException { if (dataStore != null) { try { dataStore.clear(); } catch (Exception error) { // ok to ignore since problem encountered during tear down of the test. } } } /** * Save a BlogOwner via DataStore, wait a bit, check API to see if the BlogOwner is there, remotely. * @throws DataStoreException On failure to save item into DataStore (first step) * @throws ApiException On failure to retrieve a valid response from API when checking * for remote presence of saved item * @throws AmplifyException On failure to arrange a {@link DataStoreCategory} via the * {@link DataStoreCategoryConfigurator} */ @Test public void syncUpToCloudIsWorking() throws AmplifyException { // Start listening for model publication events on the Hub. BlogOwner localCharley = BlogOwner.builder() .name("Charley Crockett") .build(); String modelName = BlogOwner.class.getSimpleName(); HubAccumulator publishedMutationsAccumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, localCharley.getId()), 1) .start(); // Save Charley Crockett, a guy who has a blog, into the DataStore. dataStore.save(localCharley); // Wait for a Hub event telling us that our Charley model got published to the cloud. publishedMutationsAccumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Try to get Charley from the backend. BlogOwner remoteCharley = api.get(BlogOwner.class, localCharley.getId()); // A Charley is a Charley is a Charley, right? assertEquals(localCharley.getId(), remoteCharley.getId()); assertEquals(localCharley.getName(), remoteCharley.getName()); } /** * The sync engine should receive mutations for its managed models, through its * subscriptions. When we create a model remotely, the sync engine should respond * by processing the subscription event and saving the model locally. * @throws DataStoreException On failure to query the local data store for * local presence of arranged data (second step) * @throws AmplifyException On failure to arrange a {@link DataStoreCategory} via the * {@link DataStoreCategoryConfigurator} */ @Test public void syncDownFromCloudIsWorking() throws AmplifyException { // This model will get saved to the cloud. BlogOwner jameson = BlogOwner.builder() .name("Jameson Williams") .createdAt(new Temporal.DateTime(new Date(), 0)) .build(); // Start watching locally, to see if it shows up on the client. HubAccumulator receiptAccumulator = HubAccumulator.create(HubChannel.DATASTORE, receiptOf(jameson.getId()), 1) .start(); // Act: create the model in the cloud ModelSchema schema = ModelSchema.fromModelClass(BlogOwner.class); GraphQLResponse> createResponse = appSync.create(jameson, schema); ModelMetadata metadata = createResponse.getData().getSyncMetadata(); assertEquals(Integer.valueOf(1), metadata.getVersion()); // Wait for the events to show up on Hub. receiptAccumulator.awaitFirst(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Jameson should be in the local DataStore. BlogOwner owner = dataStore.get(BlogOwner.class, jameson.getId()); assertEquals("Jameson Williams", owner.getName()); assertEquals(jameson.getId(), owner.getId()); } /** * Verify that updating an item shortly after creating it succeeds locally. * * Note: If this test periodically fails, consider the immediate update save may still be in process since * we are only waiting on 1 hub event. Because we call back to back, I haven't seen this happen yet. * * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createThenUpdate() throws DataStoreException, ApiException { // Setup BlogOwner richard = BlogOwner.builder() .name("Richard") .build(); BlogOwner updatedRichard = richard.copyOfBuilder() .name("Richard McClellan") .build(); String modelName = BlogOwner.class.getSimpleName(); // Expect at least 1 mutation to be published to AppSync. HubAccumulator richardAccumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, richard.getId()), 1) .start(); // Create an item, then update it and save it again. dataStore.save(richard); dataStore.save(updatedRichard); // Verify that at least 1 mutations was published. richardAccumulator.await(60, TimeUnit.SECONDS); // Verify that the updatedRichard is saved in the DataStore. BlogOwner localRichard = dataStore.get(BlogOwner.class, richard.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, localRichard); // Verify that the updatedRichard is saved on the backend. BlogOwner remoteRichard = api.get(BlogOwner.class, richard.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, remoteRichard); } /** * Verify that updating an item shortly after creating it succeeds. This can be tricky because the _version * returned in the response from the create request must be included in the input for the subsequent update request. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createWaitThenUpdate() throws DataStoreException, ApiException { // Setup BlogOwner richard = BlogOwner.builder() .name("Richard") .build(); BlogOwner updatedRichard = richard.copyOfBuilder() .name("Richard McClellan") .build(); String modelName = BlogOwner.class.getSimpleName(); HubAccumulator accumulator1 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, richard.getId()), 1); HubAccumulator accumulator2 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, richard.getId()), 1); // Create an item, then update it and save it again. accumulator1.start(); dataStore.save(richard); // Verify first save published accumulator1.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Update item and save accumulator2.start(); dataStore.save(updatedRichard); // Verify update published accumulator2.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that the updatedRichard is saved in the DataStore. BlogOwner localRichard = dataStore.get(BlogOwner.class, richard.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, localRichard); // Verify that the updatedRichard is saved on the backend. BlogOwner remoteRichard = api.get(BlogOwner.class, richard.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedRichard, remoteRichard); } /** * Verify that updating a different field of an item immediately after creating it succeeds. * * Note: If this test periodically fails, consider the immediate update save may still be in process since * we are only waiting on 1 hub event. Because we call back to back, I haven't seen this happen yet. * * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createThenUpdateDifferentField() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Richard") .build(); BlogOwner updatedOwner = owner.copyOfBuilder() .wea("pon") .build(); String modelName = BlogOwner.class.getSimpleName(); // Expect at least 1 mutation to be published to AppSync. HubAccumulator accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1) .start(); // Create an item, then update it with different field and save it again. dataStore.save(owner); dataStore.save(updatedOwner); // Verify that mutation(s) were published. accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that the updatedOwner is saved in the DataStore. BlogOwner localOwner = dataStore.get(BlogOwner.class, owner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, localOwner); // Verify that the updatedOwner is saved on the backend. BlogOwner remoteOwner = api.get(BlogOwner.class, owner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, remoteOwner); } /** * Verify that updating a different field of an item succeeds, after verifying the initial item has been published. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createWaitThenUpdateDifferentField() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Richard") .build(); BlogOwner updatedOwner = owner.copyOfBuilder() .wea("pon") .build(); String modelName = BlogOwner.class.getSimpleName(); HubAccumulator accumulator1 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1); HubAccumulator accumulator2 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1); // Create an item, then accumulator1.start(); dataStore.save(owner); // Verify save published accumulator1.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Update item with different field and save it again. accumulator2.start(); dataStore.save(updatedOwner); // Verify update save published accumulator2.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that the updatedOwner is saved in the DataStore. BlogOwner localOwner = dataStore.get(BlogOwner.class, owner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, localOwner); // Verify that the updatedOwner is saved on the backend. BlogOwner remoteOwner = api.get(BlogOwner.class, owner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, remoteOwner); } /** * Verify that updating a different field of the last created shortly after creating two items succeeds. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Ignore("Test passes locally but fails inconsistently on CI. Ignoring the test pending further investigation.") @Test public void create1ThenCreate2ThenUpdate2() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Jean") .build(); BlogOwner anotherOwner = BlogOwner.builder() .name("Richard") .build(); BlogOwner updatedOwner = anotherOwner.copyOfBuilder() .wea("pon") .build(); String modelName = BlogOwner.class.getSimpleName(); // Expect two mutations to be published to AppSync. HubAccumulator accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, anotherOwner.getId()), 2) .start(); // Create an item, then update it with different field and save it again. dataStore.save(owner); dataStore.save(anotherOwner); dataStore.save(updatedOwner); // Verify that 2 mutations were published. accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that the updatedOwner is saved in the DataStore. BlogOwner localOwner = dataStore.get(BlogOwner.class, anotherOwner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, localOwner); // Verify that the updatedOwner is saved on the backend. BlogOwner remoteOwner = api.get(BlogOwner.class, anotherOwner.getId()); ModelAssert.assertEqualsIgnoringTimestamps(updatedOwner, remoteOwner); } /** * Verify that creating a new item, then immediately deleting succeeds. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test @Ignore("Inconsistent Test. Needs investigation") public void createThenDelete() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Jean") .build(); dataStore.save(owner); dataStore.delete(owner); // Verify that the owner is deleted from the local data store. assertThrows(NoSuchElementException.class, () -> dataStore.get(BlogOwner.class, owner.getId())); } /** * Verify that creating a new item, waiting for it to post, then immediately delete succeeds. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createWaitThenDelete() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Jean") .build(); String modelName = BlogOwner.class.getSimpleName(); HubAccumulator accumulator1 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1); HubAccumulator accumulator2 = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1); accumulator1.start(); dataStore.save(owner); accumulator1.awaitFirst(TIMEOUT_SECONDS, TimeUnit.SECONDS); accumulator2.start(); dataStore.delete(owner); accumulator2.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that the owner is deleted from the local data store. assertThrows(NoSuchElementException.class, () -> dataStore.get(BlogOwner.class, owner.getId())); } /** * The test is to create a new Post with Comment, reassign Comment to a different Post. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createPost1WithCommentThenReassignCommentToPost2() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder().name("Owner").build(); Blog blog = Blog.builder().name("MyBlog").owner(owner).build(); Post firstPost = Post.builder().title("First Post").status(PostStatus.ACTIVE).rating(3).blog(blog).build(); Post secondPost = Post.builder().title("Second Post").status(PostStatus.ACTIVE).rating(5).blog(blog).build(); Comment comment = Comment.builder().content("Some comment").post(firstPost).build(); String modelName = Comment.class.getSimpleName(); // Save first post and comment. Then verify that first post and comment were saved. HubAccumulator accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, comment.getId()), 1) .start(); dataStore.save(owner); dataStore.save(blog); dataStore.save(firstPost); dataStore.save(comment); accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); Comment localComment = dataStore.get(Comment.class, comment.getId()); Assert.assertEquals(comment.getPost().getId(), localComment.getPost().getId()); Comment remoteComment = api.get(Comment.class, comment.getId()); Assert.assertEquals(comment.getPost().getId(), remoteComment.getPost().getId()); // Reassign comment to second post, save and sync Comment commentCopy = comment.copyOfBuilder().post(secondPost).build(); accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, commentCopy.getId()), 1).start(); dataStore.save(secondPost); dataStore.save(commentCopy); accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Verify that comment was reassigned Comment currentLocalComment = dataStore.get(Comment.class, comment.getId()); Assert.assertEquals(secondPost.getId(), currentLocalComment.getPost().getId()); Comment currentRemoteComment = api.get(Comment.class, comment.getId()); Assert.assertEquals(secondPost.getId(), currentRemoteComment.getPost().getId()); } /** * The test is to test consecutive updates with predicate. * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createWaitThenUpdate10TimesWithPredicate() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder() .name("Blogger") .wea("ryt") .build(); String modelName = BlogOwner.class.getSimpleName(); QueryPredicate predicate = BlogOwner.WEA.beginsWith("r"); // Setup 10 updates List weas = Arrays.asList("ron", "rth", "rer", "rly", "ren", "rel", "ral", "rec", "rin", "reh"); List owners = new ArrayList<>(); for (int i = 0; i < weas.size(); i++) { BlogOwner updatedOwner = owner.copyOfBuilder().wea(weas.get(i)).build(); owners.add(updatedOwner); } HubAccumulator accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1) .start(); // Create an item. dataStore.save(owner); // Wait for the sync. accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Make 10 consecutive updates with predicate HubAccumulator updateAccumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 10).start(); for (int i = 0; i < weas.size(); i++) { dataStore.save(owners.get(i), predicate); } updateAccumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); BlogOwner lastUpdate = owners.get(owners.size() - 1); BlogOwner localOwner = dataStore.get(BlogOwner.class, lastUpdate.getId()); ModelAssert.assertEqualsIgnoringTimestamps(lastUpdate, localOwner); BlogOwner remoteOwner = api.get(BlogOwner.class, lastUpdate.getId()); ModelAssert.assertEqualsIgnoringTimestamps(lastUpdate, remoteOwner); } /** * Create new item, then immediately update a different field. * Wait for sync round trip. Then update the first field. * * Note: If this test periodically fails, consider the immediate update save may still be in process since * we are only waiting on 1 hub event. Because we call back to back, I haven't seen this happen yet. * * @throws DataStoreException On failure to save or query items from DataStore. * @throws ApiException On failure to query the API. */ @Test public void createItemThenUpdateThenWaitThenUpdate() throws DataStoreException, ApiException { // Setup BlogOwner owner = BlogOwner.builder().name("ownerName").build(); BlogOwner updatedOwner = owner.copyOfBuilder().wea("pon").build(); String modelName = BlogOwner.class.getSimpleName(); // Expect at least 1 update (2 is possible) HubAccumulator accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, owner.getId()), 1) .start(); // Create new and then immediately update dataStore.save(owner); dataStore.save(updatedOwner); accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); // Update the field BlogOwner diffFieldUpdated = updatedOwner.copyOfBuilder().name("ownerUpdatedName").build(); accumulator = HubAccumulator.create(HubChannel.DATASTORE, publicationOf(modelName, diffFieldUpdated.getId()), 1).start(); dataStore.save(diffFieldUpdated); accumulator.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); BlogOwner localOwner = dataStore.get(BlogOwner.class, diffFieldUpdated.getId()); ModelAssert.assertEqualsIgnoringTimestamps(diffFieldUpdated, localOwner); BlogOwner remoteOwner = api.get(BlogOwner.class, diffFieldUpdated.getId()); ModelAssert.assertEqualsIgnoringTimestamps(diffFieldUpdated, remoteOwner); } }