diff --git a/firebase-firestore/ktx/ktx.gradle b/firebase-firestore/ktx/ktx.gradle index e11389638cd..ffbfb8f652f 100644 --- a/firebase-firestore/ktx/ktx.gradle +++ b/firebase-firestore/ktx/ktx.gradle @@ -23,7 +23,7 @@ firebaseLibrary { } android { - compileSdkVersion project.targetSdkVersion + compileSdkVersion 28 defaultConfig { minSdkVersion project.minSdkVersion multiDexEnabled true @@ -34,21 +34,22 @@ android { main.java.srcDirs += 'src/main/kotlin' test.java { srcDir 'src/test/kotlin' - srcDir '../src/testUtil/java' - srcDir '../src/roboUtil/java' + srcDir 'src/test/java' } } testOptions.unitTests.includeAndroidResources = true + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" - implementation project(':firebase-common') implementation project(':firebase-common:ktx') implementation project(':firebase-firestore') implementation 'androidx.annotation:annotation:1.1.0' - testImplementation project(':firebase-database-collection') testImplementation 'org.mockito:mockito-core:2.25.0' testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.9.8' diff --git a/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestAccessHelper.java b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestAccessHelper.java new file mode 100644 index 00000000000..bab88979493 --- /dev/null +++ b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestAccessHelper.java @@ -0,0 +1,31 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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.google.firebase.firestore; + +import com.google.firebase.firestore.model.DocumentKey; + +public final class TestAccessHelper { + + /** Makes the DocumentReference constructor accessible. */ + public static DocumentReference createDocumentReference(DocumentKey documentKey) { + // We can use null here because the tests only use this as a wrapper for documentKeys. + return new DocumentReference(documentKey, null); + } + + /** Makes the getKey() method accessible. */ + public static DocumentKey referenceKey(DocumentReference documentReference) { + return documentReference.getKey(); + } +} diff --git a/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestUtil.java b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestUtil.java new file mode 100644 index 00000000000..d2d032ea3ae --- /dev/null +++ b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/TestUtil.java @@ -0,0 +1,179 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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.google.firebase.firestore; + +import static com.google.firebase.firestore.testutil.TestUtil.doc; +import static com.google.firebase.firestore.testutil.TestUtil.docSet; +import static com.google.firebase.firestore.testutil.TestUtil.key; +import static org.mockito.Mockito.mock; + +import androidx.annotation.Nullable; +import com.google.android.gms.tasks.Task; +import com.google.firebase.database.collection.ImmutableSortedSet; +import com.google.firebase.firestore.core.DocumentViewChange; +import com.google.firebase.firestore.core.DocumentViewChange.Type; +import com.google.firebase.firestore.core.ViewSnapshot; +import com.google.firebase.firestore.local.QueryData; +import com.google.firebase.firestore.model.Document; +import com.google.firebase.firestore.model.DocumentKey; +import com.google.firebase.firestore.model.DocumentSet; +import com.google.firebase.firestore.model.ResourcePath; +import com.google.firebase.firestore.model.value.ObjectValue; +import com.google.firebase.firestore.remote.WatchChangeAggregator; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Assert; +import org.robolectric.Robolectric; + +public class TestUtil { + + private static final FirebaseFirestore FIRESTORE = mock(FirebaseFirestore.class); + + public static FirebaseFirestore firestore() { + return FIRESTORE; + } + + public static CollectionReference collectionReference(String path) { + return new CollectionReference(ResourcePath.fromString(path), FIRESTORE); + } + + public static DocumentReference documentReference(String path) { + return new DocumentReference(key(path), FIRESTORE); + } + + public static DocumentSnapshot documentSnapshot( + String path, Map data, boolean isFromCache) { + if (data == null) { + return DocumentSnapshot.fromNoDocument( + FIRESTORE, key(path), isFromCache, /*hasPendingWrites=*/ false); + } else { + return DocumentSnapshot.fromDocument( + FIRESTORE, doc(path, 1L, data), isFromCache, /*hasPendingWrites=*/ false); + } + } + + public static Query query(String path) { + return new Query(com.google.firebase.firestore.testutil.TestUtil.query(path), FIRESTORE); + } + + /** + * A convenience method for creating a particular query snapshot for tests. + * + * @param path To be used in constructing the query. + * @param oldDocs Provides the prior set of documents in the QuerySnapshot. Each entry maps to a + * document, with the key being the document id, and the value being the document contents. + * @param docsToAdd Specifies data to be added into the query snapshot as of now. Each entry maps + * to a document, with the key being the document id, and the value being the document + * contents. + * @param isFromCache Whether the query snapshot is cache result. + * @return A query snapshot that consists of both sets of documents. + */ + public static QuerySnapshot querySnapshot( + String path, + Map oldDocs, + Map docsToAdd, + boolean hasPendingWrites, + boolean isFromCache) { + DocumentSet oldDocuments = docSet(Document.keyComparator()); + ImmutableSortedSet mutatedKeys = DocumentKey.emptyKeySet(); + for (Map.Entry pair : oldDocs.entrySet()) { + String docKey = path + "/" + pair.getKey(); + oldDocuments = + oldDocuments.add( + doc( + docKey, + 1L, + pair.getValue(), + hasPendingWrites + ? Document.DocumentState.SYNCED + : Document.DocumentState.LOCAL_MUTATIONS)); + + if (hasPendingWrites) { + mutatedKeys = mutatedKeys.insert(key(docKey)); + } + } + DocumentSet newDocuments = docSet(Document.keyComparator()); + List documentChanges = new ArrayList<>(); + for (Map.Entry pair : docsToAdd.entrySet()) { + String docKey = path + "/" + pair.getKey(); + Document docToAdd = + doc( + docKey, + 1L, + pair.getValue(), + hasPendingWrites + ? Document.DocumentState.SYNCED + : Document.DocumentState.LOCAL_MUTATIONS); + newDocuments = newDocuments.add(docToAdd); + documentChanges.add(DocumentViewChange.create(Type.ADDED, docToAdd)); + + if (hasPendingWrites) { + mutatedKeys = mutatedKeys.insert(key(docKey)); + } + } + ViewSnapshot viewSnapshot = + new ViewSnapshot( + com.google.firebase.firestore.testutil.TestUtil.query(path), + newDocuments, + oldDocuments, + documentChanges, + isFromCache, + mutatedKeys, + true, + /* excludesMetadataChanges= */ false); + return new QuerySnapshot(query(path), viewSnapshot, FIRESTORE); + } + + /** + * An implementation of TargetMetadataProvider that provides controlled access to the + * `TargetMetadataProvider` callbacks. Any target accessed via these callbacks must be registered + * beforehand via `setSyncedKeys()`. + */ + public static class TestTargetMetadataProvider + implements WatchChangeAggregator.TargetMetadataProvider { + final Map> syncedKeys = new HashMap<>(); + final Map queryData = new HashMap<>(); + + @Override + public ImmutableSortedSet getRemoteKeysForTarget(int targetId) { + return syncedKeys.get(targetId) != null + ? syncedKeys.get(targetId) + : DocumentKey.emptyKeySet(); + } + + @Nullable + @Override + public QueryData getQueryDataForTarget(int targetId) { + return queryData.get(targetId); + } + + /** Sets or replaces the local state for the provided query data. */ + public void setSyncedKeys(QueryData queryData, ImmutableSortedSet keys) { + this.queryData.put(queryData.getTargetId(), queryData); + this.syncedKeys.put(queryData.getTargetId(), keys); + } + } + + public static T waitFor(Task task) { + if (!task.isComplete()) { + Robolectric.flushBackgroundThreadScheduler(); + } + Assert.assertTrue( + "Expected task to be completed after background thread flush", task.isComplete()); + return task.getResult(); + } +} diff --git a/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/testutil/TestUtil.java b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/testutil/TestUtil.java new file mode 100644 index 00000000000..c2ce41b0d8a --- /dev/null +++ b/firebase-firestore/ktx/src/test/java/com/google/firebase/firestore/testutil/TestUtil.java @@ -0,0 +1,618 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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.google.firebase.firestore.testutil; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; + +import androidx.annotation.NonNull; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Charsets; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.google.firebase.Timestamp; +import com.google.firebase.database.collection.ImmutableSortedMap; +import com.google.firebase.database.collection.ImmutableSortedSet; +import com.google.firebase.firestore.Blob; +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.TestAccessHelper; +import com.google.firebase.firestore.UserDataConverter; +import com.google.firebase.firestore.core.Filter; +import com.google.firebase.firestore.core.Filter.Operator; +import com.google.firebase.firestore.core.OrderBy; +import com.google.firebase.firestore.core.OrderBy.Direction; +import com.google.firebase.firestore.core.Query; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; +import com.google.firebase.firestore.local.LocalViewChanges; +import com.google.firebase.firestore.local.QueryData; +import com.google.firebase.firestore.local.QueryPurpose; +import com.google.firebase.firestore.model.DatabaseId; +import com.google.firebase.firestore.model.Document; +import com.google.firebase.firestore.model.DocumentKey; +import com.google.firebase.firestore.model.DocumentSet; +import com.google.firebase.firestore.model.FieldPath; +import com.google.firebase.firestore.model.MaybeDocument; +import com.google.firebase.firestore.model.NoDocument; +import com.google.firebase.firestore.model.ResourcePath; +import com.google.firebase.firestore.model.SnapshotVersion; +import com.google.firebase.firestore.model.UnknownDocument; +import com.google.firebase.firestore.model.mutation.DeleteMutation; +import com.google.firebase.firestore.model.mutation.FieldMask; +import com.google.firebase.firestore.model.mutation.FieldTransform; +import com.google.firebase.firestore.model.mutation.MutationResult; +import com.google.firebase.firestore.model.mutation.PatchMutation; +import com.google.firebase.firestore.model.mutation.Precondition; +import com.google.firebase.firestore.model.mutation.SetMutation; +import com.google.firebase.firestore.model.mutation.TransformMutation; +import com.google.firebase.firestore.model.value.FieldValue; +import com.google.firebase.firestore.model.value.ObjectValue; +import com.google.firebase.firestore.remote.RemoteEvent; +import com.google.firebase.firestore.remote.TargetChange; +import com.google.firebase.firestore.remote.WatchChange.DocumentChange; +import com.google.firebase.firestore.remote.WatchChangeAggregator; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import javax.annotation.Nullable; + +/** A set of utilities for tests */ +public class TestUtil { + + /** A string sentinel that can be used with patchMutation() to mark a field for deletion. */ + public static final String DELETE_SENTINEL = ""; + + public static final long ARBITRARY_SEQUENCE_NUMBER = 2; + + @SuppressWarnings("unchecked") + public static Map map(Object... entries) { + Map res = new HashMap<>(); + for (int i = 0; i < entries.length; i += 2) { + res.put((String) entries[i], (T) entries[i + 1]); + } + return res; + } + + public static Blob blob(int... bytes) { + return Blob.fromByteString(byteString(bytes)); + } + + public static ByteString byteString(int... bytes) { + byte[] primitive = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + primitive[i] = (byte) bytes[i]; + } + return ByteString.copyFrom(primitive); + } + + public static FieldMask fieldMask(String... fields) { + FieldPath[] mask = new FieldPath[fields.length]; + for (int i = 0; i < fields.length; i++) { + mask[i] = field(fields[i]); + } + return FieldMask.fromSet(new HashSet<>(Arrays.asList(mask))); + } + + public static final Map EMPTY_MAP = new HashMap<>(); + + public static FieldValue wrap(Object value) { + DatabaseId databaseId = DatabaseId.forProject("project"); + UserDataConverter dataConverter = new UserDataConverter(databaseId); + // HACK: We use parseQueryValue() since it accepts scalars as well as arrays / objects, and + // our tests currently use wrap() pretty generically so we don't know the intent. + return dataConverter.parseQueryValue(value); + } + + public static ObjectValue wrapObject(Map value) { + // Cast is safe here because value passed in is a map + return (ObjectValue) wrap(value); + } + + public static ObjectValue wrapObject(Object... entries) { + return wrapObject(map(entries)); + } + + public static DocumentKey key(String key) { + return DocumentKey.fromPathString(key); + } + + public static ResourcePath path(String key) { + return ResourcePath.fromString(key); + } + + public static Query query(String path) { + return Query.atPath(path(path)); + } + + public static FieldPath field(String path) { + return FieldPath.fromSegments(Arrays.asList(path.split("\\."))); + } + + public static DocumentReference ref(String key) { + return TestAccessHelper.createDocumentReference(key(key)); + } + + public static DatabaseId dbId(String project, String database) { + return DatabaseId.forDatabase(project, database); + } + + public static DatabaseId dbId(String project) { + return DatabaseId.forProject(project); + } + + public static SnapshotVersion version(long versionMicros) { + long seconds = versionMicros / 1000000; + int nanos = (int) (versionMicros % 1000000L) * 1000; + return new SnapshotVersion(new Timestamp(seconds, nanos)); + } + + public static Document doc(String key, long version, Map data) { + return new Document( + key(key), version(version), wrapObject(data), Document.DocumentState.SYNCED); + } + + public static Document doc(DocumentKey key, long version, Map data) { + return new Document(key, version(version), wrapObject(data), Document.DocumentState.SYNCED); + } + + public static Document doc( + String key, long version, ObjectValue data, Document.DocumentState documentState) { + return new Document(key(key), version(version), data, documentState); + } + + public static Document doc( + String key, long version, Map data, Document.DocumentState documentState) { + return new Document(key(key), version(version), wrapObject(data), documentState); + } + + public static NoDocument deletedDoc(String key, long version) { + return deletedDoc(key, version, /*hasCommittedMutations=*/ false); + } + + public static NoDocument deletedDoc(String key, long version, boolean hasCommittedMutations) { + return new NoDocument(key(key), version(version), hasCommittedMutations); + } + + public static UnknownDocument unknownDoc(String key, long version) { + return new UnknownDocument(key(key), version(version)); + } + + public static DocumentSet docSet(Comparator comparator, Document... documents) { + DocumentSet set = DocumentSet.emptySet(comparator); + for (Document document : documents) { + set = set.add(document); + } + return set; + } + + public static ImmutableSortedSet keySet(DocumentKey... keys) { + ImmutableSortedSet keySet = DocumentKey.emptyKeySet(); + for (DocumentKey key : keys) { + keySet = keySet.insert(key); + } + return keySet; + } + + public static Filter filter(String key, String operator, Object value) { + return Filter.create(field(key), operatorFromString(operator), wrap(value)); + } + + public static Operator operatorFromString(String s) { + if (s.equals("<")) { + return Operator.LESS_THAN; + } else if (s.equals("<=")) { + return Operator.LESS_THAN_OR_EQUAL; + } else if (s.equals("==")) { + return Operator.EQUAL; + } else if (s.equals(">")) { + return Operator.GREATER_THAN; + } else if (s.equals(">=")) { + return Operator.GREATER_THAN_OR_EQUAL; + } else if (s.equals("array-contains")) { + return Operator.ARRAY_CONTAINS; + } else { + throw new IllegalStateException("Unknown operator: " + s); + } + } + + public static OrderBy orderBy(String key) { + return orderBy(key, "asc"); + } + + public static OrderBy orderBy(String key, String dir) { + Direction direction; + if (dir.equals("asc")) { + direction = Direction.ASCENDING; + } else if (dir.equals("desc")) { + direction = Direction.DESCENDING; + } else { + throw new IllegalArgumentException("Unknown direction: " + dir); + } + return OrderBy.getInstance(direction, field(key)); + } + + public static void testEquality(List> equalityGroups) { + for (int i = 0; i < equalityGroups.size(); i++) { + List group = equalityGroups.get(i); + for (Object value : group) { + for (List otherGroup : equalityGroups) { + for (Object otherValue : otherGroup) { + if (otherGroup == group) { + assertEquals(value, otherValue); + } else { + assertNotEquals(value, otherValue); + } + } + } + } + } + } + + public static QueryData queryData(int targetId, QueryPurpose queryPurpose, String path) { + return new QueryData(query(path), targetId, ARBITRARY_SEQUENCE_NUMBER, queryPurpose); + } + + public static ImmutableSortedMap docUpdates(MaybeDocument... docs) { + ImmutableSortedMap res = + ImmutableSortedMap.Builder.emptyMap(DocumentKey.comparator()); + for (MaybeDocument doc : docs) { + res = res.insert(doc.getKey(), doc); + } + return res; + } + + public static ImmutableSortedMap docUpdates(Document... docs) { + ImmutableSortedMap res = + ImmutableSortedMap.Builder.emptyMap(DocumentKey.comparator()); + for (Document doc : docs) { + res = res.insert(doc.getKey(), doc); + } + return res; + } + + public static TargetChange targetChange( + ByteString resumeToken, + boolean current, + @Nullable Collection addedDocuments, + @Nullable Collection modifiedDocuments, + @Nullable Collection removedDocuments) { + ImmutableSortedSet addedDocumentKeys = DocumentKey.emptyKeySet(); + ImmutableSortedSet modifiedDocumentKeys = DocumentKey.emptyKeySet(); + ImmutableSortedSet removedDocumentKeys = DocumentKey.emptyKeySet(); + + if (addedDocuments != null) { + for (Document document : addedDocuments) { + addedDocumentKeys = addedDocumentKeys.insert(document.getKey()); + } + } + + if (modifiedDocuments != null) { + for (Document document : modifiedDocuments) { + modifiedDocumentKeys = modifiedDocumentKeys.insert(document.getKey()); + } + } + + if (removedDocuments != null) { + for (MaybeDocument document : removedDocuments) { + removedDocumentKeys = removedDocumentKeys.insert(document.getKey()); + } + } + + return new TargetChange( + resumeToken, current, addedDocumentKeys, modifiedDocumentKeys, removedDocumentKeys); + } + + public static TargetChange ackTarget(Document... docs) { + return targetChange(ByteString.EMPTY, true, Arrays.asList(docs), null, null); + } + + public static Map activeQueries(Iterable targets) { + Query query = query("foo"); + Map listenMap = new HashMap<>(); + for (Integer targetId : targets) { + QueryData queryData = + new QueryData(query, targetId, ARBITRARY_SEQUENCE_NUMBER, QueryPurpose.LISTEN); + listenMap.put(targetId, queryData); + } + return listenMap; + } + + public static Map activeQueries(Integer... targets) { + return activeQueries(asList(targets)); + } + + public static Map activeLimboQueries( + String docKey, Iterable targets) { + Query query = query(docKey); + Map listenMap = new HashMap<>(); + for (Integer targetId : targets) { + QueryData queryData = + new QueryData(query, targetId, ARBITRARY_SEQUENCE_NUMBER, QueryPurpose.LIMBO_RESOLUTION); + listenMap.put(targetId, queryData); + } + return listenMap; + } + + public static Map activeLimboQueries(String docKey, Integer... targets) { + return activeLimboQueries(docKey, asList(targets)); + } + + public static RemoteEvent addedRemoteEvent( + MaybeDocument doc, List updatedInTargets, List removedFromTargets) { + DocumentChange change = + new DocumentChange(updatedInTargets, removedFromTargets, doc.getKey(), doc); + WatchChangeAggregator aggregator = + new WatchChangeAggregator( + new WatchChangeAggregator.TargetMetadataProvider() { + @Override + public ImmutableSortedSet getRemoteKeysForTarget(int targetId) { + return DocumentKey.emptyKeySet(); + } + + @Override + public QueryData getQueryDataForTarget(int targetId) { + return queryData(targetId, QueryPurpose.LISTEN, doc.getKey().toString()); + } + }); + aggregator.handleDocumentChange(change); + return aggregator.createRemoteEvent(doc.getVersion()); + } + + public static RemoteEvent updateRemoteEvent( + MaybeDocument doc, List updatedInTargets, List removedFromTargets) { + return updateRemoteEvent(doc, updatedInTargets, removedFromTargets, Collections.emptyList()); + } + + public static RemoteEvent updateRemoteEvent( + MaybeDocument doc, + List updatedInTargets, + List removedFromTargets, + List limboTargets) { + DocumentChange change = + new DocumentChange(updatedInTargets, removedFromTargets, doc.getKey(), doc); + WatchChangeAggregator aggregator = + new WatchChangeAggregator( + new WatchChangeAggregator.TargetMetadataProvider() { + @Override + public ImmutableSortedSet getRemoteKeysForTarget(int targetId) { + return DocumentKey.emptyKeySet().insert(doc.getKey()); + } + + @Override + public QueryData getQueryDataForTarget(int targetId) { + boolean isLimbo = + !(updatedInTargets.contains(targetId) || removedFromTargets.contains(targetId)); + QueryPurpose purpose = + isLimbo ? QueryPurpose.LIMBO_RESOLUTION : QueryPurpose.LISTEN; + return queryData(targetId, purpose, doc.getKey().toString()); + } + }); + aggregator.handleDocumentChange(change); + return aggregator.createRemoteEvent(doc.getVersion()); + } + + public static SetMutation setMutation(String path, Map values) { + return new SetMutation(key(path), wrapObject(values), Precondition.NONE); + } + + public static PatchMutation patchMutation(String path, Map values) { + return patchMutation(path, values, null); + } + + public static PatchMutation patchMutation( + String path, Map values, @Nullable List updateMask) { + ObjectValue objectValue = ObjectValue.emptyObject(); + ArrayList objectMask = new ArrayList<>(); + for (Entry entry : values.entrySet()) { + FieldPath fieldPath = field(entry.getKey()); + objectMask.add(fieldPath); + if (!entry.getValue().equals(DELETE_SENTINEL)) { + FieldValue parsedValue = wrap(entry.getValue()); + objectValue = objectValue.set(fieldPath, parsedValue); + } + } + + boolean merge = updateMask != null; + + // We sort the fieldMaskPaths to make the order deterministic in tests. (Otherwise, when we + // flatten a Set to a proto repeated field, we'll end up comparing in iterator order and + // possibly consider {foo,bar} != {bar,foo}.) + SortedSet fieldMaskPaths = new TreeSet<>(merge ? updateMask : objectMask); + + return new PatchMutation( + key(path), + objectValue, + FieldMask.fromSet(fieldMaskPaths), + merge ? Precondition.NONE : Precondition.exists(true)); + } + + public static DeleteMutation deleteMutation(String path) { + return new DeleteMutation(key(path), Precondition.NONE); + } + + /** + * Creates a TransformMutation by parsing any FieldValue sentinels in the provided data. The data + * is expected to use dotted-notation for nested fields (i.e. { "foo.bar": FieldValue.foo() } and + * must not contain any non-sentinel data. + */ + public static TransformMutation transformMutation(String path, Map data) { + UserDataConverter dataConverter = new UserDataConverter(DatabaseId.forProject("project")); + ParsedUpdateData result = dataConverter.parseUpdateData(data); + + // The order of the transforms doesn't matter, but we sort them so tests can assume a particular + // order. + ArrayList fieldTransforms = new ArrayList<>(result.getFieldTransforms()); + Collections.sort( + fieldTransforms, (ft1, ft2) -> ft1.getFieldPath().compareTo(ft2.getFieldPath())); + + return new TransformMutation(key(path), fieldTransforms); + } + + public static MutationResult mutationResult(long version) { + return new MutationResult(version(version), null); + } + + public static LocalViewChanges viewChanges( + int targetId, List addedKeys, List removedKeys) { + ImmutableSortedSet added = DocumentKey.emptyKeySet(); + for (String keyPath : addedKeys) { + added = added.insert(key(keyPath)); + } + ImmutableSortedSet removed = DocumentKey.emptyKeySet(); + for (String keyPath : removedKeys) { + removed = removed.insert(key(keyPath)); + } + return new LocalViewChanges(targetId, added, removed); + } + + /** Creates a resume token to match the given snapshot version. */ + @Nullable + public static ByteString resumeToken(long snapshotVersion) { + if (snapshotVersion == 0) { + return null; + } + + String snapshotString = "snapshot-" + snapshotVersion; + return ByteString.copyFrom(snapshotString, Charsets.UTF_8); + } + + @NonNull + private static ByteString resumeToken(SnapshotVersion snapshotVersion) { + if (snapshotVersion.equals(SnapshotVersion.NONE)) { + return ByteString.EMPTY; + } else { + return ByteString.copyFromUtf8(snapshotVersion.toString()); + } + } + + public static ByteString streamToken(String contents) { + return ByteString.copyFrom(contents, Charsets.UTF_8); + } + + private static Map fromJsonString(String json) { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(json, new TypeReference>() {}); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static Map fromSingleQuotedString(String json) { + return fromJsonString(json.replace("'", "\"")); + } + + /** Converts the values of an ImmutableSortedMap into a list, preserving key order. */ + public static List values(ImmutableSortedMap map) { + List result = new ArrayList<>(); + for (Map.Entry entry : map) { + result.add(entry.getValue()); + } + return result; + } + + /** + * Asserts that the actual set is equal to the expected one. + * + * @param expected A list of the expected contents of the set, in order. + * @param actual The set to compare against. + * @param The type of the values of in common between the expected list and actual set. + */ + // PORTING NOTE: JUnit and XCTest use reversed conventions on expected and actual values :-(. + public static void assertSetEquals(List expected, ImmutableSortedSet actual) { + List actualList = Lists.newArrayList(actual); + assertEquals(expected, actualList); + } + + /** + * Asserts that the actual set is equal to the expected one. + * + * @param expected A list of the expected contents of the set, in order. + * @param actual The set to compare against. + * @param The type of the values of in common between the expected list and actual set. + */ + // PORTING NOTE: JUnit and XCTest use reversed conventions on expected and actual values :-(. + public static void assertSetEquals(List expected, Set actual) { + Set expectedSet = Sets.newHashSet(expected); + assertEquals(expectedSet, actual); + } + + /** Asserts that the given runnable block fails with an internal error. */ + public static void assertFails(Runnable block) { + try { + block.run(); + } catch (AssertionError e) { + assertThat(e).hasMessageThat().startsWith("INTERNAL ASSERTION FAILED:"); + // Otherwise success + return; + } + fail("Should have failed"); + } + + public static void assertDoesNotThrow(Runnable block) { + try { + block.run(); + } catch (Exception e) { + fail("Should not have thrown " + e); + } + } + + // TODO: We could probably do some de-duplication between assertFails / expectError. + /** Expects runnable to throw an exception with a specific error message. */ + public static void expectError(Runnable runnable, String exceptionMessage) { + expectError(runnable, exceptionMessage, /*context=*/ null); + } + + /** + * Expects runnable to throw an exception with a specific error message. An optional context (e.g. + * "for bad_data") can be provided which will be displayed in any resulting failure message. + */ + public static void expectError(Runnable runnable, String exceptionMessage, String context) { + boolean exceptionThrown = false; + try { + runnable.run(); + } catch (Throwable throwable) { + exceptionThrown = true; + String contextMessage = "Expected exception message was incorrect"; + if (context != null) { + contextMessage += " (" + context + ")"; + } + assertEquals(contextMessage, exceptionMessage, throwable.getMessage()); + } + if (!exceptionThrown) { + context = (context == null) ? "" : context; + fail( + "Expected exception with message '" + + exceptionMessage + + "' but no exception was thrown" + + context); + } + } +}