diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index cd4f0bf277d..ff10d720603 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -2,6 +2,11 @@ * [changed] Removed the "beta" suffix from the version of the Firebase Data Connect Android SDK, thus graduating it from "beta" to "generally available". ([#6792](https://github.com/firebase/firebase-android-sdk/pull/6792)) +* [changed] DataConnectOperationException added, enabling support for partial + errors; that is, any data that was received and/or was able to be decoded is + now available via the "response" property of the exception thrown when a + query or mutation is executed. + ([#6794](https://github.com/firebase/firebase-android-sdk/pull/6794)) # 16.0.0-beta05 * [changed] Changed gRPC proto package to v1 (was v1beta). diff --git a/firebase-dataconnect/api.txt b/firebase-dataconnect/api.txt index 19fb52985f5..d919cc593db 100644 --- a/firebase-dataconnect/api.txt +++ b/firebase-dataconnect/api.txt @@ -42,6 +42,47 @@ package com.google.firebase.dataconnect { ctor public DataConnectException(String message, Throwable? cause = null); } + public class DataConnectOperationException extends com.google.firebase.dataconnect.DataConnectException { + ctor public DataConnectOperationException(String message, Throwable? cause = null, com.google.firebase.dataconnect.DataConnectOperationFailureResponse response); + method public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse getResponse(); + property public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse response; + } + + public interface DataConnectOperationFailureResponse { + method public Data? getData(); + method public java.util.List getErrors(); + method public java.util.Map? getRawData(); + method public String toString(); + property public abstract Data? data; + property public abstract java.util.List errors; + property public abstract java.util.Map? rawData; + } + + public static interface DataConnectOperationFailureResponse.ErrorInfo { + method public boolean equals(Object? other); + method public String getMessage(); + method public java.util.List getPath(); + method public int hashCode(); + method public String toString(); + property public abstract String message; + property public abstract java.util.List path; + } + + public sealed interface DataConnectPathSegment { + } + + @kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.Field implements com.google.firebase.dataconnect.DataConnectPathSegment { + ctor public DataConnectPathSegment.Field(String field); + method public String getField(); + property public final String field; + } + + @kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.ListIndex implements com.google.firebase.dataconnect.DataConnectPathSegment { + ctor public DataConnectPathSegment.ListIndex(int index); + method public int getIndex(); + property public final int index; + } + public final class DataConnectSettings { ctor public DataConnectSettings(String host = "firebasedataconnect.googleapis.com", boolean sslEnabled = true); method public String getHost(); diff --git a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql index ffc8281e0fd..37a8a56ba36 100644 --- a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql +++ b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql @@ -88,3 +88,14 @@ query getPersonAuth($id: String!) @auth(level: USER_ANON) { age } } + +query getPersonWithPartialFailure($id: String!) @auth(level: PUBLIC) { + person1: person(id: $id) { name } + person2: person(id: $id) @check(expr: "false", message: "c8azjdwz2x") { name } +} + +mutation createPersonWithPartialFailure($id: String!, $name: String!) @auth(level: PUBLIC) { + person1: person_insert(data: { id: $id, name: $name }) + person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) @check(expr: "false", message: "ecxpjy4qfy") +} + diff --git a/firebase-dataconnect/emulator/emulator.sh b/firebase-dataconnect/emulator/emulator.sh index 68ccf3331a7..c534aaacd12 100755 --- a/firebase-dataconnect/emulator/emulator.sh +++ b/firebase-dataconnect/emulator/emulator.sh @@ -16,9 +16,8 @@ set -euo pipefail -echo "[$0] PID=$$" - -readonly SELF_DIR="$(dirname "$0")" +export FIREBASE_DATACONNECT_POSTGRESQL_STRING='postgresql://postgres:postgres@localhost:5432?sslmode=disable' +echo "[$0] export FIREBASE_DATACONNECT_POSTGRESQL_STRING='$FIREBASE_DATACONNECT_POSTGRESQL_STRING'" readonly FIREBASE_ARGS=( firebase diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt new file mode 100644 index 00000000000..b9844714b01 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OperationExecutionErrorsIntegrationTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright 2025 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.dataconnect + +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.CreatePersonMutation +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonQuery +import com.google.firebase.dataconnect.testutil.shouldSatisfy +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.property.Arb +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializer +import org.junit.Test + +class OperationExecutionErrorsIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema: PersonSchema by lazy { PersonSchema(dataConnectFactory) } + private val dataConnect: FirebaseDataConnect by lazy { personSchema.dataConnect } + + @Test + fun executeQueryFailsWithNullDataNonEmptyErrors() = runTest { + val queryRef = + dataConnect.query( + operationName = GetPersonQuery.operationName, + variables = Arb.incompatibleVariables().next(rs), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "jwdbzka4k5", + expectedCause = null, + expectedRawData = null, + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNullDataNonEmptyErrors() = runTest { + val mutationRef = + dataConnect.mutation( + operationName = CreatePersonMutation.operationName, + variables = Arb.incompatibleVariables().next(rs), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedCause = null, + expectedRawData = null, + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeQueryFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest { + val id = Arb.alphanumericString().next() + val queryRef = + dataConnect.query( + operationName = GetPersonQuery.operationName, + variables = GetPersonQuery.Variables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = mapOf("person" to null), + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun executeMutationFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = CreatePersonMutation.operationName, + variables = CreatePersonMutation.Variables(id, name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = mapOf("person_insert" to mapOf("id" to id)), + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute() + val queryRef = + dataConnect.query( + operationName = "getPersonWithPartialFailure", + variables = GetPersonWithPartialFailureVariables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "c8azjdwz2x", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null), + expectedData = GetPersonWithPartialFailureData(name), + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = "createPersonWithPartialFailure", + variables = CreatePersonWithPartialFailureVariables(id = id, name = name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "ecxpjy4qfy", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null), + expectedData = CreatePersonWithPartialFailureData(id), + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute() + val queryRef = + dataConnect.query( + operationName = "getPersonWithPartialFailure", + variables = GetPersonWithPartialFailureVariables(id), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { queryRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "c8azjdwz2x", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null), + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Test + fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest { + val id = Arb.alphanumericString().next() + val name = Arb.alphanumericString().next() + val mutationRef = + dataConnect.mutation( + operationName = "createPersonWithPartialFailure", + variables = CreatePersonWithPartialFailureVariables(id = id, name = name), + dataDeserializer = serializer(), + variablesSerializer = serializer(), + optionsBuilder = {}, + ) + + val exception = shouldThrow { mutationRef.execute() } + + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = "ecxpjy4qfy", + expectedCause = null, + expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null), + expectedData = null, + errorsValidator = { it.shouldHaveAtLeastSize(1) }, + ) + } + + @Serializable private data class IncompatibleVariables(val jwdbzka4k5: String) + + @Serializable private data class IncompatibleData(val btzjhbfz7h: String) + + private fun Arb.Companion.incompatibleVariables(string: Arb = Arb.alphanumericString()) = + string.map { IncompatibleVariables(it) } + + @Serializable private data class GetPersonWithPartialFailureVariables(val id: String) + + @Serializable + private data class GetPersonWithPartialFailureData(val person1: Person, val person2: Nothing?) { + constructor(person1Name: String) : this(Person(person1Name), null) + + @Serializable private data class Person(val name: String) + } + + @Serializable + private data class CreatePersonWithPartialFailureVariables(val id: String, val name: String) + + @Serializable + private data class CreatePersonWithPartialFailureData( + val person1: Person, + val person2: Nothing? + ) { + constructor(person1Id: String) : this(Person(person1Id), null) + + @Serializable private data class Person(val id: String) + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt index 21d9dc9d4dc..a6ed2cfd47a 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt @@ -54,6 +54,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { ) object CreatePersonMutation { + const val operationName = "createPerson" + @Serializable data class Data(val person_insert: PersonKey) { @Serializable data class PersonKey(val id: String) @@ -63,7 +65,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun createPerson(variables: CreatePersonMutation.Variables) = dataConnect.mutation( - operationName = "createPerson", + operationName = CreatePersonMutation.operationName, variables = variables, dataDeserializer = serializer(), variablesSerializer = serializer(), @@ -141,6 +143,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun deletePerson(id: String) = deletePerson(DeletePersonMutation.Variables(id = id)) object GetPersonQuery { + const val operationName = "getPerson" + @Serializable data class Data(val person: Person?) { @Serializable data class Person(val name: String, val age: Int? = null) @@ -151,7 +155,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) { fun getPerson(variables: GetPersonQuery.Variables) = dataConnect.query( - operationName = "getPerson", + operationName = GetPersonQuery.operationName, variables = variables, dataDeserializer = serializer(), variablesSerializer = serializer(), diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt deleted file mode 100644 index 07e87c212a8..00000000000 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2024 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.dataconnect - -import java.util.Objects - -// See https://spec.graphql.org/draft/#sec-Errors -internal class DataConnectError( - val message: String, - val path: List, - val locations: List, -) { - - override fun hashCode(): Int = Objects.hash(message, path, locations) - - override fun equals(other: Any?): Boolean = - (other is DataConnectError) && - other.message == message && - other.path == path && - other.locations == locations - - override fun toString(): String = - StringBuilder() - .also { sb -> - path.forEachIndexed { segmentIndex, segment -> - when (segment) { - is PathSegment.Field -> { - if (segmentIndex != 0) { - sb.append('.') - } - sb.append(segment.field) - } - is PathSegment.ListIndex -> { - sb.append('[') - sb.append(segment.index) - sb.append(']') - } - } - } - - if (locations.isNotEmpty()) { - if (sb.isNotEmpty()) { - sb.append(' ') - } - sb.append("at ") - sb.append(locations.joinToString(", ")) - } - - if (path.isNotEmpty() || locations.isNotEmpty()) { - sb.append(": ") - } - - sb.append(message) - } - .toString() - - sealed interface PathSegment { - @JvmInline - value class Field(val field: String) : PathSegment { - override fun toString(): String = field - } - - @JvmInline - value class ListIndex(val index: Int) : PathSegment { - override fun toString(): String = index.toString() - } - } - - class SourceLocation(val line: Int, val column: Int) { - override fun hashCode(): Int = Objects.hash(line, column) - override fun equals(other: Any?): Boolean = - other is SourceLocation && other.line == line && other.column == column - override fun toString(): String = "$line:$column" - } -} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt new file mode 100644 index 00000000000..cb1ef1b8526 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationException.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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.dataconnect + +/** + * The exception thrown when an error occurs in the execution of a Firebase Data Connect operation + * (that is, a query or mutation). This exception means that a response was, indeed, received from + * the backend but either the response included one or more errors or the client could not + * successfully process the result (for example, decoding the response data failed). + */ +public open class DataConnectOperationException( + message: String, + cause: Throwable? = null, + public val response: DataConnectOperationFailureResponse<*>, +) : DataConnectException(message, cause) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt new file mode 100644 index 00000000000..386fb8bce9d --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectOperationFailureResponse.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2025 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.dataconnect + +// Googlers see go/dataconnect:sdk:partial-errors for design details. + +/** The data and errors provided by the backend in the response message. */ +public interface DataConnectOperationFailureResponse { + + /** + * The raw, un-decoded data provided by the backend in the response message. Will be `null` if, + * and only if, the backend explicitly sent null for the data or if the data was not present in + * the response. + * + * Otherwise, the values in the map will be one of the following: + * * `null` + * * [String] + * * [Boolean] + * * [Double] + * * [List] containing any of the types in this list of types + * * [Map] with [String] keys and values of of the types in this list of types + * + * Consider using [toJson] to get a higher-level object. + */ + public val rawData: Map? + + /** + * The list of errors provided by the backend in the response message; may be empty. + * + * See https://spec.graphql.org/draft/#sec-Errors for details. + */ + public val errors: List + + /** + * The successfully-decoded [rawData], if any. + * + * Will be `null` if [rawData] is `null`, or if decoding the [rawData] failed. + */ + public val data: Data? + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String + + /** + * Information about the error, as provided in the response payload from the backend. + * + * See https://spec.graphql.org/draft/#sec-Errors for details. + */ + public interface ErrorInfo { + /** The error's message. */ + public val message: String + + /** The path of the field in the response data to which this error relates. */ + public val path: List + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of the same implementation of + * [ErrorInfo] whose public properties compare equal using the `==` operator to the + * corresponding properties of this object. + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return a string representation of this object, suitable for logging the error indicated by + * this object; it will include the path formatted into a human-readable string (if the path is + * not empty), and the message. + */ + override fun toString(): String + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt new file mode 100644 index 00000000000..3bae99ef78f --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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.dataconnect + +/** The "segment" of a path to a field in the response data. */ +public sealed interface DataConnectPathSegment { + + /** A named field in a path to a field in the response data. */ + @JvmInline + public value class Field(public val field: String) : DataConnectPathSegment { + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return returns simply [field]. + */ + override fun toString(): String = field + } + + /** An index of a list in a path to a field in the response data. */ + @JvmInline + public value class ListIndex(public val index: Int) : DataConnectPathSegment { + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return returns simply the string representation of [index]. + */ + override fun toString(): String = index.toString() + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt index 638fffb913e..332cd5251e4 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt @@ -22,7 +22,7 @@ import kotlinx.serialization.encoding.Decoder internal class DataConnectUntypedData( val data: Map?, - val errors: List + val errors: List ) { override fun equals(other: Any?) = diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt index b2d2270056b..26e9ce49c51 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt @@ -17,15 +17,16 @@ package com.google.firebase.dataconnect.core import com.google.firebase.dataconnect.* -import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.DataConnectPathSegment +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toErrorInfoImpl import com.google.firebase.dataconnect.core.LoggerGlobals.warn import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.ListValue import com.google.protobuf.Struct import com.google.protobuf.Value import google.firebase.dataconnect.proto.GraphqlError -import google.firebase.dataconnect.proto.SourceLocation import google.firebase.dataconnect.proto.executeMutationRequest import google.firebase.dataconnect.proto.executeQueryRequest import io.grpc.Status @@ -52,7 +53,7 @@ internal class DataConnectGrpcClient( data class OperationResult( val data: Struct?, - val errors: List, + val errors: List, ) suspend fun executeQuery( @@ -74,7 +75,7 @@ internal class DataConnectGrpcClient( return OperationResult( data = if (response.hasData()) response.data else null, - errors = response.errorsList.map { it.toDataConnectError() } + errors = response.errorsList.map { it.toErrorInfoImpl() } ) } @@ -97,7 +98,7 @@ internal class DataConnectGrpcClient( return OperationResult( data = if (response.hasData()) response.data else null, - errors = response.errorsList.map { it.toDataConnectError() } + errors = response.errorsList.map { it.toErrorInfoImpl() } ) } @@ -138,50 +139,72 @@ internal class DataConnectGrpcClient( internal object DataConnectGrpcClientGlobals { private fun ListValue.toPathSegment() = valuesList.map { - when (val kind = it.kindCase) { - Value.KindCase.STRING_VALUE -> DataConnectError.PathSegment.Field(it.stringValue) - Value.KindCase.NUMBER_VALUE -> - DataConnectError.PathSegment.ListIndex(it.numberValue.toInt()) - else -> DataConnectError.PathSegment.Field("invalid PathSegment kind: $kind") + when (it.kindCase) { + Value.KindCase.STRING_VALUE -> DataConnectPathSegment.Field(it.stringValue) + Value.KindCase.NUMBER_VALUE -> DataConnectPathSegment.ListIndex(it.numberValue.toInt()) + // The cases below are expected to never occur; however, implement some logic for them + // to avoid things like throwing exceptions in those cases. + Value.KindCase.NULL_VALUE -> DataConnectPathSegment.Field("null") + Value.KindCase.BOOL_VALUE -> DataConnectPathSegment.Field(it.boolValue.toString()) + Value.KindCase.LIST_VALUE -> DataConnectPathSegment.Field(it.listValue.toCompactString()) + Value.KindCase.STRUCT_VALUE -> + DataConnectPathSegment.Field(it.structValue.toCompactString()) + else -> DataConnectPathSegment.Field(it.toString()) } } - private fun List.toSourceLocations(): List = - buildList { - this@toSourceLocations.forEach { - add(DataConnectError.SourceLocation(line = it.line, column = it.column)) - } - } - - fun GraphqlError.toDataConnectError() = - DataConnectError( + fun GraphqlError.toErrorInfoImpl() = + DataConnectOperationFailureResponseImpl.ErrorInfoImpl( message = message, path = path.toPathSegment(), - this.locationsList.toSourceLocations() ) fun DataConnectGrpcClient.OperationResult.deserialize( deserializer: DeserializationStrategy, serializersModule: SerializersModule?, - ): T = + ): T { if (deserializer === DataConnectUntypedData) { - @Suppress("UNCHECKED_CAST") - DataConnectUntypedData(data?.toMap(), errors) as T - } else if (data === null) { - if (errors.isNotEmpty()) { - throw DataConnectException("operation failed: errors=$errors") - } else { - throw DataConnectException("no data included in result") - } - } else if (errors.isNotEmpty()) { - throw DataConnectException("operation failed: errors=$errors (data=$data)") - } else { - try { - decodeFromStruct(data, deserializer, serializersModule) - } catch (dataConnectException: DataConnectException) { - throw dataConnectException - } catch (throwable: Throwable) { - throw DataConnectException("decoding response data failed: $throwable", throwable) - } + @Suppress("UNCHECKED_CAST") return DataConnectUntypedData(data?.toMap(), errors) as T + } + + val decodedData: Result? = + data?.let { data -> runCatching { decodeFromStruct(data, deserializer, serializersModule) } } + + if (errors.isNotEmpty()) { + throw DataConnectOperationException( + "operation encountered errors during execution: $errors", + response = + DataConnectOperationFailureResponseImpl( + rawData = data?.toMap(), + data = decodedData?.getOrNull(), + errors = errors, + ) + ) + } + + if (decodedData == null) { + throw DataConnectOperationException( + "no data was included in the response from the server", + response = + DataConnectOperationFailureResponseImpl( + rawData = null, + data = null, + errors = emptyList(), + ) + ) } + + return decodedData.getOrElse { exception -> + throw DataConnectOperationException( + "decoding data from the server's response failed: ${exception.message}", + cause = exception, + response = + DataConnectOperationFailureResponseImpl( + rawData = data?.toMap(), + data = null, + errors = emptyList(), + ) + ) + } + } } diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt new file mode 100644 index 00000000000..85434a64b47 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImpl.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import com.google.firebase.dataconnect.DataConnectPathSegment +import java.util.Objects + +internal class DataConnectOperationFailureResponseImpl( + override val rawData: Map?, + override val data: Data?, + override val errors: List +) : DataConnectOperationFailureResponse { + + override fun toString(): String = + "DataConnectOperationFailureResponseImpl(rawData=$rawData, data=$data, errors=$errors)" + + internal class ErrorInfoImpl( + override val message: String, + override val path: List, + ) : ErrorInfo { + + override fun equals(other: Any?): Boolean = + other is ErrorInfoImpl && other.message == message && other.path == path + + override fun hashCode(): Int = Objects.hash("ErrorInfoImpl", message, path) + + override fun toString(): String = buildString { + path.forEachIndexed { segmentIndex, segment -> + when (segment) { + is DataConnectPathSegment.Field -> { + if (segmentIndex != 0) { + append('.') + } + append(segment.field) + } + is DataConnectPathSegment.ListIndex -> { + append('[').append(segment.index).append(']') + } + } + } + + if (path.isNotEmpty()) { + append(": ") + } + + append(message) + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt index 0ba6a34b34a..94a3a63a68d 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt @@ -16,7 +16,7 @@ package com.google.firebase.dataconnect.util -import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toErrorInfoImpl import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto import com.google.protobuf.ListValue @@ -136,6 +136,10 @@ internal object ProtoUtil { fun Struct.toCompactString(keySortSelector: ((String) -> String)? = null): String = Value.newBuilder().setStructValue(this).build().toCompactString(keySortSelector) + /** Generates and returns a string similar to [Struct.toString] but more compact. */ + fun ListValue.toCompactString(keySortSelector: ((String) -> String)? = null): String = + Value.newBuilder().setListValue(this).build().toCompactString(keySortSelector) + /** Generates and returns a string similar to [Value.toString] but more compact. */ fun Value.toCompactString(keySortSelector: ((String) -> String)? = null): String { val charArrayWriter = CharArrayWriter() @@ -204,7 +208,7 @@ internal object ProtoUtil { fun ExecuteQueryResponse.toStructProto(): Struct = buildStructProto { if (hasData()) put("data", data) - putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + putList("errors") { errorsList.forEach { add(it.toErrorInfoImpl().toString()) } } } fun ExecuteMutationRequest.toCompactString(): String = toStructProto().toCompactString() @@ -219,7 +223,7 @@ internal object ProtoUtil { fun ExecuteMutationResponse.toStructProto(): Struct = buildStructProto { if (hasData()) put("data", data) - putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + putList("errors") { errorsList.forEach { add(it.toErrorInfoImpl().toString()) } } } fun EmulatorInfo.toStructProto(): Struct = buildStructProto { diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt deleted file mode 100644 index 204b6f1fc48..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright 2024 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. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError -import com.google.firebase.dataconnect.testutil.property.arbitrary.fieldPathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.listIndexPathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.pathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.sourceLocation -import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText -import io.kotest.assertions.assertSoftly -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.kotest.matchers.types.shouldBeSameInstanceAs -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.Codepoint -import io.kotest.property.arbitrary.az -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.constant -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.list -import io.kotest.property.arbitrary.next -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class DataConnectErrorUnitTest { - - @Test - fun `properties should be the same objects given to the constructor`() = runTest { - val messages = Arb.dataConnect.string() - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - val sourceLocations = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, messages, paths, sourceLocations) { message, path, locations -> - val dataConnectError = DataConnectError(message = message, path = path, locations = locations) - assertSoftly { - dataConnectError.message shouldBeSameInstanceAs message - dataConnectError.path shouldBeSameInstanceAs path - dataConnectError.locations shouldBeSameInstanceAs locations - } - } - } - - @Test - fun `toString() should incorporate the message`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.toString() shouldContainWithNonAbuttingText dataConnectError.message - } - } - - @Test - fun `toString() should incorporate the fields from the path separated by dots`() = runTest { - val paths = Arb.list(Arb.dataConnect.fieldPathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(path = paths)) { dataConnectError -> - val expectedSubstring = dataConnectError.path.joinToString(".") - dataConnectError.toString() shouldContainWithNonAbuttingText expectedSubstring - } - } - - @Test - fun `toString() should incorporate the list indexes from the path surround by square brackets`() = - runTest { - val paths = Arb.list(Arb.dataConnect.listIndexPathSegment(), 1..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(path = paths)) { dataConnectError -> - val expectedSubstring = dataConnectError.path.joinToString(separator = "") { "[$it]" } - dataConnectError.toString() shouldContainWithNonAbuttingText expectedSubstring - } - } - - @Test - fun `toString() should incorporate the fields and list indexes from the path`() { - // Use an example instead of Arb here because using Arb would essentially be re-writing the - // logic that is implemented in DataConnectError.toString(). - val path = - listOf( - PathSegment.Field("foo"), - PathSegment.ListIndex(99), - PathSegment.Field("bar"), - PathSegment.ListIndex(22), - PathSegment.ListIndex(33) - ) - val dataConnectError = Arb.dataConnect.dataConnectError(path = Arb.constant(path)).next() - - dataConnectError.toString() shouldContainWithNonAbuttingText "foo[99].bar[22][33]" - } - - @Test - fun `toString() should incorporate the locations`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - assertSoftly { - dataConnectError.locations.forEach { - dataConnectError.toString() shouldContainWithNonAbuttingText "${it.line}:${it.column}" - } - } - } - } - - @Test - fun `equals() should return true for the exact same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.equals(dataConnectError) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError1 -> - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = List(dataConnectError1.path.size) { dataConnectError1.path[it] }, - locations = List(dataConnectError1.locations.size) { dataConnectError1.locations[it] }, - ) - dataConnectError1.equals(dataConnectError2) shouldBe true - dataConnectError2.equals(dataConnectError1) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - dataConnectError.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.sourceLocation()) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), otherTypes) { - dataConnectError, - other -> - dataConnectError.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false when only message differs`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), Arb.string()) { - dataConnectError1, - newMessage -> - assume(dataConnectError1.message != newMessage) - val dataConnectError2 = - DataConnectError( - message = newMessage, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when message differs only in character case`() = runTest { - val message = Arb.string(1..100, Codepoint.az()) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(message = message)) { dataConnectError - -> - val dataConnectError1 = - DataConnectError( - message = dataConnectError.message.uppercase(), - path = dataConnectError.path, - locations = dataConnectError.locations, - ) - val dataConnectError2 = - DataConnectError( - message = dataConnectError.message.lowercase(), - path = dataConnectError.path, - locations = dataConnectError.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when path differs`() = runTest { - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), paths) { - dataConnectError1, - otherPath -> - assume(dataConnectError1.path != otherPath) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = otherPath, - locations = dataConnectError1.locations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `equals() should return false when locations differ`() = runTest { - val location = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), location) { - dataConnectError1, - otherLocations -> - assume(dataConnectError1.locations != otherLocations) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = otherLocations, - ) - dataConnectError1.equals(dataConnectError2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value each time it is invoked on a given object`() = - runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError -> - val hashCode1 = dataConnectError.hashCode() - dataConnectError.hashCode() shouldBe hashCode1 - dataConnectError.hashCode() shouldBe hashCode1 - } - } - - @Test - fun `hashCode() should return the same value on equal objects`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError()) { dataConnectError1 -> - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if message is different`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), Arb.string()) { - dataConnectError1, - newMessage -> - assume(dataConnectError1.message.hashCode() != newMessage.hashCode()) - val dataConnectError2 = - DataConnectError( - message = newMessage, - path = dataConnectError1.path, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if path is different`() = runTest { - val paths = Arb.list(Arb.dataConnect.pathSegment(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), paths) { dataConnectError1, newPath - -> - assume(dataConnectError1.path.hashCode() != newPath.hashCode()) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = newPath, - locations = dataConnectError1.locations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - @Test - fun `hashCode() should return a different value if locations is different`() = runTest { - val locations = Arb.list(Arb.dataConnect.sourceLocation(), 0..5) - checkAll(propTestConfig, Arb.dataConnect.dataConnectError(), locations) { - dataConnectError1, - newLocations -> - assume(dataConnectError1.locations.hashCode() != newLocations.hashCode()) - val dataConnectError2 = - DataConnectError( - message = dataConnectError1.message, - path = dataConnectError1.path, - locations = newLocations, - ) - dataConnectError1.hashCode() shouldNotBe dataConnectError2.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt new file mode 100644 index 00000000000..d4029102a80 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectPathSegmentUnitTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2025 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. + */ +@file:OptIn(ExperimentalKotest::class) +@file:Suppress("ReplaceCallWithBinaryOperator") + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.string +import io.kotest.property.assume +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + +/** Unit tests for [DataConnectPathSegment.Field] */ +class DataConnectPathSegmentFieldUnitTest { + + @Test + fun `constructor should set field property`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { field -> + val pathSegment = DataConnectPathSegment.Field(field) + pathSegment.field shouldBeSameInstanceAs field + } + } + + @Test + fun `toString() should return a string equal to the field property`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.toString() shouldBeSameInstanceAs pathSegment.field + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.equals(pathSegment) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment1: DataConnectPathSegment.Field -> + val pathSegment2 = DataConnectPathSegment.Field(pathSegment1.field) + pathSegment1.equals(pathSegment2) shouldBe true + pathSegment2.equals(pathSegment1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + pathSegment.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), listIndexPathSegmentArb()) + checkAll(propTestConfig, fieldPathSegmentArb(), otherTypes) { + pathSegment: DataConnectPathSegment.Field, + other -> + pathSegment.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when field differs`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb(), fieldPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.Field, + pathSegment2: DataConnectPathSegment.Field -> + assume(pathSegment1.field != pathSegment2.field) + pathSegment1.equals(pathSegment2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment: DataConnectPathSegment.Field -> + val hashCode1 = pathSegment.hashCode() + pathSegment.hashCode() shouldBe hashCode1 + pathSegment.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb()) { pathSegment1: DataConnectPathSegment.Field -> + val pathSegment2 = DataConnectPathSegment.Field(pathSegment1.field) + pathSegment1.hashCode() shouldBe pathSegment2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if field is different`() = runTest { + checkAll(propTestConfig, fieldPathSegmentArb(), fieldPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.Field, + pathSegment2: DataConnectPathSegment.Field -> + assume(pathSegment1.field.hashCode() != pathSegment2.field.hashCode()) + pathSegment1.hashCode() shouldNotBe pathSegment2.hashCode() + } + } +} + +/** Unit tests for [DataConnectPathSegment.ListIndex] */ +class DataConnectPathSegmentListIndexUnitTest { + + @Test + fun `constructor should set index property`() = runTest { + checkAll(propTestConfig, Arb.int()) { listIndex -> + val pathSegment = DataConnectPathSegment.ListIndex(listIndex) + pathSegment.index shouldBe listIndex + } + } + + @Test + fun `toString() should return a string equal to the index property`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.toString() shouldBe "${pathSegment.index}" + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.equals(pathSegment) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex -> + val pathSegment2 = DataConnectPathSegment.ListIndex(pathSegment1.index) + pathSegment1.equals(pathSegment2) shouldBe true + pathSegment2.equals(pathSegment1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + pathSegment.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), fieldPathSegmentArb()) + checkAll(propTestConfig, listIndexPathSegmentArb(), otherTypes) { + pathSegment: DataConnectPathSegment.ListIndex, + other -> + pathSegment.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when field differs`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb(), listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex, + pathSegment2: DataConnectPathSegment.ListIndex -> + assume(pathSegment1.index != pathSegment2.index) + pathSegment1.equals(pathSegment2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment: DataConnectPathSegment.ListIndex -> + val hashCode1 = pathSegment.hashCode() + pathSegment.hashCode() shouldBe hashCode1 + pathSegment.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex -> + val pathSegment2 = DataConnectPathSegment.ListIndex(pathSegment1.index) + pathSegment1.hashCode() shouldBe pathSegment2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if index is different`() = runTest { + checkAll(propTestConfig, listIndexPathSegmentArb(), listIndexPathSegmentArb()) { + pathSegment1: DataConnectPathSegment.ListIndex, + pathSegment2: DataConnectPathSegment.ListIndex -> + assume(pathSegment1.index.hashCode() != pathSegment2.index.hashCode()) + pathSegment1.hashCode() shouldNotBe pathSegment2.hashCode() + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt index 98c5d441433..48c5a12f878 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt @@ -20,7 +20,6 @@ package com.google.firebase.dataconnect import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.sourceLocation import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText import io.kotest.assertions.assertSoftly import io.kotest.common.ExperimentalKotest @@ -99,7 +98,7 @@ class DataConnectSettingsUnitTest { @Test fun `equals() should return false for a different type`() = runTest { - val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.sourceLocation()) + val otherTypes = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.errorPath()) checkAll(propTestConfig, Arb.dataConnect.dataConnectSettings(), otherTypes) { settings, other -> settings.equals(other) shouldBe false } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt deleted file mode 100644 index 75cf78107b3..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2024 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. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.fieldPathSegment -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PathSegmentFieldUnitTest { - - @Test - fun `field should equal the value given to the constructor`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.field shouldBe field - } - } - - @Test - fun `toString() should equal the field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.toString() shouldBe field - } - } - - @Test - fun `equals() should return true for the same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment()) { segment -> - segment.equals(segment) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment1 = PathSegment.Field(field) - val segment2 = PathSegment.Field(field) - segment1.equals(segment2) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment()) { segment -> - segment.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val others = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.dataConnectSettings()) - checkAll(propTestConfig, Arb.dataConnect.fieldPathSegment(), others) { segment, other -> - segment.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false for a different field`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string(), Arb.dataConnect.string()) { field1, field2 -> - assume(field1 != field2) - val segment1 = PathSegment.Field(field1) - val segment2 = PathSegment.Field(field2) - segment1.equals(segment2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value as the field's hashCode() method`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.string()) { field -> - val segment = PathSegment.Field(field) - segment.hashCode() shouldBe field.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt deleted file mode 100644 index e8a3046d212..00000000000 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2024 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. - */ - -@file:Suppress("ReplaceCallWithBinaryOperator") -@file:OptIn(ExperimentalKotest::class) - -package com.google.firebase.dataconnect - -import com.google.firebase.dataconnect.DataConnectError.PathSegment -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.listIndexPathSegment -import io.kotest.common.ExperimentalKotest -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.PropTestConfig -import io.kotest.property.arbitrary.choice -import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.string -import io.kotest.property.assume -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class PathSegmentListIndexUnitTest { - - @Test - fun `index should equal the value given to the constructor`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.index shouldBe index - } - } - - @Test - fun `toString() should equal the index`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.toString() shouldBe "$index" - } - } - - @Test - fun `equals() should return true for the same instance`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment()) { segment -> - segment.equals(segment) shouldBe true - } - } - - @Test - fun `equals() should return true for an equal field`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment1 = PathSegment.ListIndex(index) - val segment2 = PathSegment.ListIndex(index) - segment1.equals(segment2) shouldBe true - } - } - - @Test - fun `equals() should return false for null`() = runTest { - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment()) { segment -> - segment.equals(null) shouldBe false - } - } - - @Test - fun `equals() should return false for a different type`() = runTest { - val others = Arb.choice(Arb.string(), Arb.int(), Arb.dataConnect.dataConnectSettings()) - checkAll(propTestConfig, Arb.dataConnect.listIndexPathSegment(), others) { segment, other -> - segment.equals(other) shouldBe false - } - } - - @Test - fun `equals() should return false for a different index`() = runTest { - checkAll(propTestConfig, Arb.int(), Arb.int()) { index1, index2 -> - assume(index1 != index2) - val segment1 = PathSegment.ListIndex(index1) - val segment2 = PathSegment.ListIndex(index2) - segment1.equals(segment2) shouldBe false - } - } - - @Test - fun `hashCode() should return the same value as the index's hashCode() method`() = runTest { - checkAll(propTestConfig, Arb.int()) { index -> - val segment = PathSegment.ListIndex(index) - segment.hashCode() shouldBe index.hashCode() - } - } - - private companion object { - val propTestConfig = PropTestConfig(iterations = 20) - } -} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt index 61273ec0d24..5cce39e1c3c 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt @@ -13,27 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:OptIn(ExperimentalKotest::class) + package com.google.firebase.dataconnect.core -import com.google.firebase.dataconnect.DataConnectError -import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.DataConnectOperationException +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.DataConnectUntypedData import com.google.firebase.dataconnect.FirebaseDataConnect import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.deserialize +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule +import com.google.firebase.dataconnect.testutil.RandomSeedTestRule import com.google.firebase.dataconnect.testutil.newMockLogger import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError import com.google.firebase.dataconnect.testutil.property.arbitrary.iterator -import com.google.firebase.dataconnect.testutil.property.arbitrary.operationResult +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors import com.google.firebase.dataconnect.testutil.property.arbitrary.proto import com.google.firebase.dataconnect.testutil.property.arbitrary.struct import com.google.firebase.dataconnect.testutil.shouldHaveLoggedExactlyOneMessageContaining +import com.google.firebase.dataconnect.testutil.shouldSatisfy import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.ListValue +import com.google.protobuf.Struct import com.google.protobuf.Value import google.firebase.dataconnect.proto.ExecuteMutationRequest import google.firebase.dataconnect.proto.ExecuteMutationResponse @@ -43,50 +49,57 @@ import google.firebase.dataconnect.proto.GraphqlError import google.firebase.dataconnect.proto.SourceLocation import io.grpc.Status import io.grpc.StatusException -import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.maps.shouldContainExactly import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe -import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeSameInstanceAs import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig import io.kotest.property.RandomSource import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.enum -import io.kotest.property.arbitrary.filter import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.next import io.kotest.property.arbitrary.string -import io.kotest.property.arbs.firstName -import io.kotest.property.arbs.travel.airline +import io.kotest.property.assume import io.kotest.property.checkAll import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.spyk import io.mockk.verify import java.util.concurrent.atomic.AtomicBoolean +import kotlin.reflect.KClass import kotlinx.coroutines.test.runTest import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.serializer import org.junit.Rule import org.junit.Test +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + class DataConnectGrpcClientUnitTest { @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + @get:Rule val randomSeedTestRule = RandomSeedTestRule() - private val rs = RandomSource.default() + private val rs: RandomSource by randomSeedTestRule.rs private val projectId = Arb.dataConnect.projectId().next(rs) private val connectorConfig = Arb.dataConnect.connectorConfig().next(rs) private val requestId = Arb.dataConnect.requestId().next(rs) @@ -192,7 +205,7 @@ class DataConnectGrpcClientUnitTest { dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) operationResult shouldBe - OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + OperationResult(data = responseData, errors = responseErrors.map { it.errorInfo }) } @Test @@ -209,7 +222,7 @@ class DataConnectGrpcClientUnitTest { dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) operationResult shouldBe - OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + OperationResult(data = responseData, errors = responseErrors.map { it.errorInfo }) } @Test @@ -492,7 +505,7 @@ class DataConnectGrpcClientUnitTest { private data class GraphqlErrorInfo( val graphqlError: GraphqlError, - val dataConnectError: DataConnectError, + val errorInfo: ErrorInfoImpl, ) { companion object { private val randomPathComponents = @@ -510,28 +523,24 @@ class DataConnectGrpcClientUnitTest { fun random(rs: RandomSource): GraphqlErrorInfo { - val dataConnectErrorPath = mutableListOf() + val dataConnectErrorPath = mutableListOf() val graphqlErrorPath = ListValue.newBuilder() repeat(6) { if (rs.random.nextFloat() < 0.33f) { val pathComponent = randomInts.next(rs) - dataConnectErrorPath.add(DataConnectError.PathSegment.ListIndex(pathComponent)) + dataConnectErrorPath.add(DataConnectPathSegment.ListIndex(pathComponent)) graphqlErrorPath.addValues(Value.newBuilder().setNumberValue(pathComponent.toDouble())) } else { val pathComponent = randomPathComponents.next(rs) - dataConnectErrorPath.add(DataConnectError.PathSegment.Field(pathComponent)) + dataConnectErrorPath.add(DataConnectPathSegment.Field(pathComponent)) graphqlErrorPath.addValues(Value.newBuilder().setStringValue(pathComponent)) } } - val dataConnectErrorLocations = mutableListOf() val graphqlErrorLocations = mutableListOf() repeat(3) { val line = randomInts.next(rs) val column = randomInts.next(rs) - dataConnectErrorLocations.add( - DataConnectError.SourceLocation(line = line, column = column) - ) graphqlErrorLocations.add( SourceLocation.newBuilder().setLine(line).setColumn(column).build() ) @@ -547,14 +556,13 @@ class DataConnectGrpcClientUnitTest { } .build() - val dataConnectError = - DataConnectError( + val errorInfo = + ErrorInfoImpl( message = message, path = dataConnectErrorPath.toList(), - locations = dataConnectErrorLocations.toList() ) - return GraphqlErrorInfo(graphqlError, dataConnectError) + return GraphqlErrorInfo(graphqlError, errorInfo) } } } @@ -563,69 +571,147 @@ class DataConnectGrpcClientUnitTest { @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_AGAINST_NOT_NOTHING_EXPECTED_TYPE") class DataConnectGrpcClientOperationResultUnitTest { - private val rs = RandomSource.default() - @Test fun `deserialize() should ignore the module given with DataConnectUntypedData`() { - val errors = listOf(Arb.dataConnect.dataConnectError().next()) - val operationResult = OperationResult(buildStructProto { put("foo", 42.0) }, errors) + val data = buildStructProto { put("foo", 42.0) } + val errors = Arb.dataConnect.operationErrors().next() + val operationResult = OperationResult(data, errors) val result = operationResult.deserialize(DataConnectUntypedData, mockk()) - result shouldBe DataConnectUntypedData(mapOf("foo" to 42.0), errors) + result.shouldHaveDataAndErrors(data, errors) } @Test - fun `deserialize() should treat DataConnectUntypedData specially`() = runTest { - checkAll(iterations = 20, Arb.dataConnect.operationResult()) { operationResult -> + fun `deserialize() with null data should treat DataConnectUntypedData specially`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrors()) { errors -> + val operationResult = OperationResult(null, errors) val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) + result.shouldHaveDataAndErrors(null, errors) + } + } - result.asClue { - if (operationResult.data === null) { - it.data.shouldBeNull() - } else { - it.data shouldBe operationResult.data.toMap() - } - it.errors shouldContainExactly operationResult.errors - } + @Test + fun `deserialize() with non-null data should treat DataConnectUntypedData specially`() = runTest { + checkAll(propTestConfig, Arb.proto.struct(), Arb.dataConnect.operationErrors()) { data, errors + -> + val operationResult = OperationResult(data, errors) + val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) + result.shouldHaveDataAndErrors(data, errors) } } @Test - fun `deserialize() should throw if one or more errors and data is null`() = runTest { - val arb = - Arb.dataConnect - .operationResult() - .filter { it.errors.isNotEmpty() } - .map { it.copy(data = null) } - checkAll(iterations = 5, arb) { operationResult -> - val exception = - shouldThrow { - operationResult.deserialize(mockk(), serializersModule = null) - } - exception.message shouldContain "${operationResult.errors}" + fun `deserialize() successfully deserializes`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { fooValue -> + val dataStruct = buildStructProto { put("foo", fooValue) } + val operationResult = OperationResult(dataStruct, emptyList()) + + val deserializedData = operationResult.deserialize(serializer(), null) + + deserializedData shouldBe TestData(fooValue) } } @Test - fun `deserialize() should throw if one or more errors and data is _not_ null`() = runTest { - val arb = - Arb.dataConnect.operationResult().filter { it.data !== null && it.errors.isNotEmpty() } - checkAll(iterations = 5, arb) { operationResult -> - val exception = - shouldThrow { + fun `deserialize() should throw if one or more errors and data is null`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrors(range = 1..10)) { errors -> + val operationResult = OperationResult(null, errors) + val exception: DataConnectOperationException = + shouldThrow { operationResult.deserialize(mockk(), serializersModule = null) } - exception.message shouldContain "${operationResult.errors}" + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = null, + expectedData = null, + expectedErrors = errors, + ) } } + @Test + fun `deserialize() should throw if one or more errors, data is NOT null, and decoding fails`() = + runTest { + checkAll( + propTestConfig, + Arb.proto.struct(), + Arb.dataConnect.operationErrors(range = 1..10) + ) { dataStruct, errors -> + val operationResult = OperationResult(dataStruct, errors) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(mockk(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = dataStruct, + expectedData = null, + expectedErrors = errors, + ) + } + } + + @Test + fun `deserialize() should throw if one or more errors, data is NOT null, and decoding succeeds`() = + runTest { + checkAll( + propTestConfig, + Arb.dataConnect.string(), + Arb.dataConnect.operationErrors(range = 1..10) + ) { fooValue, errors -> + val dataStruct = buildStructProto { put("foo", fooValue) } + val operationResult = OperationResult(dataStruct, errors) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "operation encountered errors", + expectedMessageSubstringCaseSensitive = errors.toString(), + expectedCause = null, + expectedRawData = dataStruct, + expectedData = TestData(fooValue), + expectedErrors = errors, + ) + } + } + @Test fun `deserialize() should throw if data is null and errors is empty`() { - val operationResult = OperationResult(data = null, errors = emptyList()) - val exception = - shouldThrow { - operationResult.deserialize(mockk(), serializersModule = null) + val operationResult = OperationResult(null, emptyList()) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) } - exception.message shouldContain "no data" + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "no data was included", + expectedCause = null, + expectedRawData = null, + expectedData = null, + expectedErrors = emptyList(), + ) + } + + @Test + fun `deserialize() should throw if decoding fails and error list is empty`() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { dataStruct -> + assume(!dataStruct.containsFields("foo")) + val operationResult = OperationResult(dataStruct, emptyList()) + val exception: DataConnectOperationException = + shouldThrow { + operationResult.deserialize(serializer(), serializersModule = null) + } + exception.shouldSatisfy( + expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed", + expectedCause = SerializationException::class, + expectedRawData = dataStruct, + expectedData = null, + expectedErrors = emptyList(), + ) + } } @Test @@ -642,51 +728,50 @@ class DataConnectGrpcClientOperationResultUnitTest { slot.captured.serializersModule shouldBeSameInstanceAs serializersModule } - @Test - fun `deserialize() successfully deserializes`() = runTest { - val testData = TestData(Arb.firstName().next().name) - val operationResult = OperationResult(encodeToStruct(testData), errors = emptyList()) - - val deserializedData = operationResult.deserialize(serializer(), null) - - deserializedData shouldBe testData - } - - @Test - fun `deserialize() throws if decoding fails`() = runTest { - val data = Arb.proto.struct().next(rs) - val operationResult = OperationResult(data, errors = emptyList()) - shouldThrow { operationResult.deserialize(serializer(), null) } - } - - @Test - fun `deserialize() re-throws DataConnectException`() = runTest { - val data = encodeToStruct(TestData("fe45zhyd3m")) - val operationResult = OperationResult(data = data, errors = emptyList()) - val deserializer: DeserializationStrategy = spyk(serializer()) - val exception = DataConnectException(message = Arb.airline().next().name) - every { deserializer.deserialize(any()) } throws (exception) - - val thrownException = - shouldThrow { operationResult.deserialize(deserializer, null) } - - thrownException shouldBeSameInstanceAs exception - } - - @Test - fun `deserialize() wraps non-DataConnectException in DataConnectException`() = runTest { - val data = encodeToStruct(TestData("rbmkny6b4r")) - val operationResult = OperationResult(data = data, errors = emptyList()) - val deserializer: DeserializationStrategy = spyk(serializer()) - class MyException : Exception("y3cx44q43q") - val exception = MyException() - every { deserializer.deserialize(any()) } throws (exception) + @Serializable data class TestData(val foo: String) - val thrownException = - shouldThrow { operationResult.deserialize(deserializer, null) } + private companion object { + + fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Struct?, + expectedData: T?, + expectedErrors: List, + ) = + shouldSatisfy( + expectedMessageSubstringCaseInsensitive = expectedMessageSubstringCaseInsensitive, + expectedMessageSubstringCaseSensitive = expectedMessageSubstringCaseSensitive, + expectedCause = expectedCause, + expectedRawData = expectedRawData?.toMap(), + expectedData = expectedData, + expectedErrors = expectedErrors, + ) + + fun DataConnectUntypedData.shouldHaveDataAndErrors( + expectedData: Map, + expectedErrors: List, + ) { + assertSoftly { + withClue("data") { data.shouldNotBeNull().shouldContainExactly(expectedData) } + withClue("errors") { errors shouldContainExactly expectedErrors } + } + } - thrownException.cause shouldBeSameInstanceAs exception + fun DataConnectUntypedData.shouldHaveDataAndErrors( + expectedData: Struct, + expectedErrors: List, + ) = shouldHaveDataAndErrors(expectedData.toMap(), expectedErrors) + + fun DataConnectUntypedData.shouldHaveDataAndErrors( + @Suppress("UNUSED_PARAMETER") expectedData: Nothing?, + expectedErrors: List, + ) { + assertSoftly { + withClue("data") { data.shouldBeNull() } + withClue("errors") { errors shouldContainExactly expectedErrors } + } + } } - - @Serializable data class TestData(val foo: String) } diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt new file mode 100644 index 00000000000..994d1b405fa --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectOperationFailureResponseImplUnitTest.kt @@ -0,0 +1,350 @@ +/* + * Copyright 2025 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. + */ + +@file:OptIn(ExperimentalKotest::class) +@file:Suppress("ReplaceCallWithBinaryOperator") + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectPathSegment +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.errorPath as errorPathArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.fieldPathSegment as fieldPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb.listIndexPathSegment as listIndexPathSegmentArb +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationData +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrorInfo +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationFailureResponseImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRawData +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldEndWith +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.bind +import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.string +import io.kotest.property.assume +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val propTestConfig = + PropTestConfig(iterations = 20, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.25)) + +/** Unit tests for [DataConnectOperationFailureResponseImpl] */ +class DataConnectOperationFailureResponseImplUnitTest { + + @Test + fun `constructor should set properties to the given values`() = runTest { + checkAll( + propTestConfig, + Arb.dataConnect.operationRawData(), + Arb.dataConnect.operationData(), + Arb.dataConnect.operationErrors() + ) { rawData, data, errors -> + val response = DataConnectOperationFailureResponseImpl(rawData, data, errors) + assertSoftly { + withClue("rawData") { response.rawData shouldBeSameInstanceAs rawData } + withClue("data") { response.data shouldBeSameInstanceAs data } + withClue("errors") { response.errors shouldBeSameInstanceAs errors } + } + } + } + + @Test + fun `toString() should incorporate property values`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationFailureResponseImpl()) { + response: DataConnectOperationFailureResponseImpl<*> -> + val toStringResult = response.toString() + assertSoftly { + toStringResult shouldStartWith "DataConnectOperationFailureResponseImpl(" + toStringResult shouldEndWith ")" + toStringResult shouldContainWithNonAbuttingText "rawData=${response.rawData}" + toStringResult shouldContainWithNonAbuttingText "data=${response.data}" + toStringResult shouldContainWithNonAbuttingText "errors=${response.errors}" + } + } + } +} + +/** Unit tests for [DataConnectOperationFailureResponseImpl.ErrorInfoImpl] */ +class DataConnectOperationFailureResponseImplErrorInfoImplUnitTest { + + @Test + fun `constructor should set properties to the given values`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), errorPathArb()) { message, path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.message shouldBeSameInstanceAs message + errorInfo.path shouldBeSameInstanceAs path + } + } + + @Test + fun `toString() should return an empty string if both message and path are empty`() { + val errorInfo = ErrorInfoImpl("", emptyList()) + errorInfo.toString() shouldBe "" + } + + @Test + fun `toString() should return the message if message is non-empty and path is empty`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string()) { message -> + val errorInfo = ErrorInfoImpl(message, emptyList()) + errorInfo.toString() shouldBe message + } + } + + @Test + fun `toString() should not do anything different with an empty message`() = runTest { + checkAll(propTestConfig, errorPathArb()) { path -> + assume(path.isNotEmpty()) + val errorInfo = ErrorInfoImpl("", path) + val errorInfoToStringResult = errorInfo.toString() + errorInfoToStringResult shouldEndWith ": " + path.forEachIndexed { index, pathSegment -> + withClue("path[$index]") { + errorInfoToStringResult shouldContainWithNonAbuttingText pathSegment.toString() + } + } + } + } + + @Test + fun `toString() should print field path segments separated by dots`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), Arb.list(fieldPathSegmentArb(), 1..10)) { + message, + path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe path.joinToString(".") + ": $message" + } + } + + @Test + fun `toString() should print list index path segments separated by dots`() = runTest { + checkAll( + propTestConfig, + Arb.dataConnect.string(), + Arb.list(listIndexPathSegmentArb(), 1..10) + ) { message, path -> + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe path.joinToString("") { "[${it.index}]" } + ": $message" + } + } + + @Test + fun `toString() for path is field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe "${segments.field1.field}[${segments.listIndex1}]: $message" + } + } + + @Test + fun `toString() for path is listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.listIndex1, segments.field1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe "[${segments.listIndex1}].${segments.field1.field}: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.field2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}]: $message" + } + } + + @Test + fun `toString() for path is field, field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.field2, segments.listIndex1) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}.${segments.field2.field}[${segments.listIndex1}]: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, field, listIndex`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.field2, segments.listIndex2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}].${segments.field2.field}[${segments.listIndex2}]: $message" + } + } + + @Test + fun `toString() for path is field, listIndex, listIndex, field`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.string(), MyArb.samplePathSegments()) { + message, + segments -> + val path = listOf(segments.field1, segments.listIndex1, segments.listIndex2, segments.field2) + val errorInfo = ErrorInfoImpl(message, path) + errorInfo.toString() shouldBe + "${segments.field1.field}[${segments.listIndex1}][${segments.listIndex2}].${segments.field2.field}: $message" + } + } + + @Test + fun `equals() should return true for the exact same instance`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + errorInfo.equals(errorInfo) shouldBe true + } + } + + @Test + fun `equals() should return true for an equal instance`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo1: ErrorInfoImpl -> + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe true + errorInfo2.equals(errorInfo1) shouldBe true + } + } + + @Test + fun `equals() should return false for null`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + errorInfo.equals(null) shouldBe false + } + } + + @Test + fun `equals() should return false for a different type`() = runTest { + val otherTypes = Arb.choice(Arb.string(), Arb.int(), listIndexPathSegmentArb()) + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), otherTypes) { + errorInfo: ErrorInfoImpl, + other -> + errorInfo.equals(other) shouldBe false + } + } + + @Test + fun `equals() should return false when message differs`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), Arb.dataConnect.string()) { + errorInfo1: ErrorInfoImpl, + otherMessage: String -> + assume(errorInfo1.message != otherMessage) + val errorInfo2 = ErrorInfoImpl(otherMessage, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `equals() should return false when path differs`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + errorInfo1: ErrorInfoImpl, + otherPath: List -> + assume(errorInfo1.path != otherPath) + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, otherPath) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() = + runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo: ErrorInfoImpl -> + val hashCode1 = errorInfo.hashCode() + errorInfo.hashCode() shouldBe hashCode1 + errorInfo.hashCode() shouldBe hashCode1 + } + } + + @Test + fun `hashCode() should return the same value on equal objects`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo()) { errorInfo1: ErrorInfoImpl -> + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, errorInfo1.path) + errorInfo1.hashCode() shouldBe errorInfo2.hashCode() + } + } + + @Test + fun `hashCode() should return a different value if message is different`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), Arb.dataConnect.string()) { + errorInfo1: ErrorInfoImpl, + otherMessage: String -> + assume(errorInfo1.message.hashCode() != otherMessage.hashCode()) + val errorInfo2 = ErrorInfoImpl(otherMessage, errorInfo1.path) + errorInfo1.equals(errorInfo2) shouldBe false + } + } + + @Test + fun `hashCode() should return a different value if path is different`() = runTest { + checkAll(propTestConfig, Arb.dataConnect.operationErrorInfo(), errorPathArb()) { + errorInfo1: ErrorInfoImpl, + otherPath: List -> + assume(errorInfo1.path.hashCode() != otherPath.hashCode()) + val errorInfo2 = ErrorInfoImpl(errorInfo1.message, otherPath) + errorInfo1.equals(errorInfo2) shouldBe false + } + } +} + +private object MyArb { + + fun samplePathSegments( + field: Arb = fieldPathSegmentArb(), + listIndex: Arb = listIndexPathSegmentArb(), + ): Arb = + Arb.bind(field, field, listIndex, listIndex) { field1, field2, listIndex1, listIndex2 -> + SamplePathSegments(field1, field2, listIndex1, listIndex2) + } + + data class SamplePathSegments( + val field1: DataConnectPathSegment.Field, + val field2: DataConnectPathSegment.Field, + val listIndex1: DataConnectPathSegment.ListIndex, + val listIndex2: DataConnectPathSegment.ListIndex, + ) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt index f61a824630e..bf200e4caad 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt @@ -26,9 +26,9 @@ import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResul import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb import com.google.firebase.dataconnect.testutil.property.arbitrary.OperationRefConstructorArguments import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnectError import com.google.firebase.dataconnect.testutil.property.arbitrary.mock import com.google.firebase.dataconnect.testutil.property.arbitrary.mutationRefImpl +import com.google.firebase.dataconnect.testutil.property.arbitrary.operationErrors import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRefConstructorArguments import com.google.firebase.dataconnect.testutil.property.arbitrary.operationRefImpl import com.google.firebase.dataconnect.testutil.property.arbitrary.queryRefImpl @@ -181,7 +181,7 @@ class MutationRefImplUnitTest { @Test fun `execute() handles DataConnectUntypedVariables and DataConnectUntypedData`() = runTest { val variables = DataConnectUntypedVariables("foo" to 42.0) - val errors = listOf(Arb.dataConnect.dataConnectError().next()) + val errors = Arb.dataConnect.operationErrors().next() val data = DataConnectUntypedData(mapOf("bar" to 24.0), errors) val variablesSlot: CapturingSlot = slot() val operationResult = OperationResult(buildStructProto { put("bar", 24.0) }, errors) diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index 44c2a5a4720..89b2c89bb0f 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -18,20 +18,22 @@ package com.google.firebase.dataconnect.testutil.property.arbitrary -import com.google.firebase.dataconnect.DataConnectError -import com.google.firebase.dataconnect.DataConnectError.PathSegment +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType import com.google.firebase.dataconnect.OperationRef import com.google.firebase.dataconnect.core.DataConnectAppCheck import com.google.firebase.dataconnect.core.DataConnectAuth import com.google.firebase.dataconnect.core.DataConnectGrpcClient import com.google.firebase.dataconnect.core.DataConnectGrpcMetadata +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl +import com.google.firebase.dataconnect.core.DataConnectOperationFailureResponseImpl.ErrorInfoImpl import com.google.firebase.dataconnect.core.FirebaseDataConnectImpl import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal import com.google.firebase.dataconnect.core.MutationRefImpl import com.google.firebase.dataconnect.core.OperationRefImpl import com.google.firebase.dataconnect.core.QueryRefImpl import com.google.firebase.dataconnect.testutil.StubOperationRefImpl +import com.google.firebase.dataconnect.util.ProtoUtil.toMap import com.google.protobuf.Struct import io.kotest.assertions.assertSoftly import io.kotest.assertions.withClue @@ -40,11 +42,12 @@ import io.kotest.property.Arb import io.kotest.property.arbitrary.Codepoint import io.kotest.property.arbitrary.alphanumeric import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.bind import io.kotest.property.arbitrary.constant import io.kotest.property.arbitrary.enum import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.string import io.mockk.mockk @@ -75,36 +78,39 @@ internal fun DataConnectArb.dataConnectGrpcMetadata( ) } -internal fun DataConnectArb.fieldPathSegment( - string: Arb = string() -): Arb = arbitrary { PathSegment.Field(string.bind()) } +internal fun DataConnectArb.operationErrorInfo( + message: Arb = string(), + path: Arb> = errorPath(), +): Arb = + Arb.bind(message, path) { message0, path0 -> ErrorInfoImpl(message0, path0) } -internal fun DataConnectArb.listIndexPathSegment( - int: Arb = Arb.int() -): Arb = arbitrary { PathSegment.ListIndex(int.bind()) } +internal fun DataConnectArb.operationRawData(): Arb?> = + Arb.proto.struct().map { it.toMap() }.orNull(nullProbability = 0.33) -internal fun DataConnectArb.pathSegment(): Arb = - Arb.choice(fieldPathSegment(), listIndexPathSegment()) +internal data class SampleOperationData(val value: String) -internal fun DataConnectArb.sourceLocation( - line: Arb = Arb.int(), - column: Arb = Arb.int() -): Arb = arbitrary { - DataConnectError.SourceLocation(line = line.bind(), column = column.bind()) -} +internal fun DataConnectArb.operationData(): Arb = + string().map { SampleOperationData(it) }.orNull(nullProbability = 0.33) -internal fun DataConnectArb.dataConnectError( - message: Arb = string(), - path: Arb> = Arb.list(pathSegment(), 0..5), - locations: Arb> = Arb.list(sourceLocation(), 0..5) -): Arb = arbitrary { - DataConnectError(message = message.bind(), path = path.bind(), locations = locations.bind()) -} +internal fun DataConnectArb.operationErrors( + errorInfoImpl: Arb = operationErrorInfo(), + range: IntRange = 0..10, +): Arb> = Arb.list(errorInfoImpl, range) + +internal fun DataConnectArb.operationFailureResponseImpl( + rawData: Arb?> = operationRawData(), + data: Arb = operationData(), + errors: Arb> = operationErrors(), +): Arb> = + Arb.bind(rawData, data, errors) { rawData0, data0, errors0 -> + DataConnectOperationFailureResponseImpl(rawData0, data0, errors0) + } internal fun DataConnectArb.operationResult( data: Arb = Arb.proto.struct().orNull(nullProbability = 0.2), - errors: Arb> = Arb.list(dataConnectError(), 0..3), -) = arbitrary { DataConnectGrpcClient.OperationResult(data.bind(), errors.bind()) } + errors: Arb> = operationErrors(), +) = + Arb.bind(data, errors) { data0, errors0 -> DataConnectGrpcClient.OperationResult(data0, errors0) } internal fun DataConnectArb.queryRefImpl( variables: Arb, diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt new file mode 100644 index 00000000000..ac1ec9de005 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectOperationExceptionTestUtils.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 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.dataconnect.testutil + +import com.google.firebase.dataconnect.DataConnectOperationException +import com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.reflect.KClass + +fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Map?, + expectedData: T?, + expectedErrors: List, +): Unit = + shouldSatisfy( + expectedMessageSubstringCaseInsensitive = expectedMessageSubstringCaseInsensitive, + expectedMessageSubstringCaseSensitive = expectedMessageSubstringCaseSensitive, + expectedCause = expectedCause, + expectedRawData = expectedRawData, + expectedData = expectedData, + errorsValidator = { it.shouldContainExactly(expectedErrors) }, + ) + +fun DataConnectOperationException.shouldSatisfy( + expectedMessageSubstringCaseInsensitive: String, + expectedMessageSubstringCaseSensitive: String? = null, + expectedCause: KClass<*>?, + expectedRawData: Map?, + expectedData: T?, + errorsValidator: (List) -> Unit, +): Unit { + assertSoftly { + withClue("exception.message") { + message shouldContainWithNonAbuttingTextIgnoringCase expectedMessageSubstringCaseInsensitive + if (expectedMessageSubstringCaseSensitive != null) { + message shouldContainWithNonAbuttingText expectedMessageSubstringCaseSensitive + } + } + withClue("exception.cause") { + if (expectedCause == null) { + cause.shouldBeNull() + } else { + val cause = cause.shouldNotBeNull() + if (!expectedCause.isInstance(cause)) { + io.kotest.assertions.fail( + "cause was an instance of ${cause::class.qualifiedName}, " + + "but expected it to be an instance of ${expectedCause.qualifiedName}" + ) + } + } + } + withClue("exception.response.rawData") { response.rawData shouldBe expectedRawData } + withClue("exception.response.data") { response.data shouldBe expectedData } + withClue("exception.response.errors") { errorsValidator(response.errors) } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt index 17d63fc7ebf..4a3f89a7ba8 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/arbs.kt @@ -19,6 +19,7 @@ package com.google.firebase.dataconnect.testutil.property.arbitrary import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.DataConnectPathSegment import com.google.firebase.dataconnect.DataConnectSettings import io.kotest.property.Arb import io.kotest.property.arbitrary.Codepoint @@ -27,11 +28,15 @@ import io.kotest.property.arbitrary.arabic import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.ascii import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.choose import io.kotest.property.arbitrary.cyrillic import io.kotest.property.arbitrary.double import io.kotest.property.arbitrary.egyptianHieroglyphs import io.kotest.property.arbitrary.filterNot import io.kotest.property.arbitrary.hex +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.list +import io.kotest.property.arbitrary.map import io.kotest.property.arbitrary.merge import io.kotest.property.arbitrary.orNull import io.kotest.property.arbitrary.string @@ -132,6 +137,24 @@ object DataConnectArb { fun serializersModule(): Arb = arbitrary { mockk() }.orNull(nullProbability = 0.333) + + fun fieldPathSegment(string: Arb = string()): Arb = + string.map { DataConnectPathSegment.Field(it) } + + fun listIndexPathSegment(int: Arb = Arb.int()): Arb = + int.map { DataConnectPathSegment.ListIndex(it) } + + fun pathSegment( + field: Arb = fieldPathSegment(), + fieldWeight: Int = 1, + listIndex: Arb = listIndexPathSegment(), + listIndexWeight: Int = 1, + ): Arb = Arb.choose(fieldWeight to field, listIndexWeight to listIndex) + + fun errorPath( + pathSegment: Arb = pathSegment(), + range: IntRange = 0..10, + ): Arb> = Arb.list(pathSegment, range) } val Arb.Companion.dataConnect: DataConnectArb