diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db02d9e7f5..b3bdd129e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: run: ./gradlew testLocalDebugUnitTest jvmTest testAndroidHostTest - name: Generate codecov report - run: ./gradlew jacocoLocalDebugUnitTestReport + run: ./gradlew jacocoTestReport - name: Upload coverage to Codecov if: github.actor != 'dependabot[bot]' diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirebaseStorageManagerTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirebaseStorageManagerTest.kt new file mode 100644 index 0000000000..752425606a --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirebaseStorageManagerTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase + +import android.net.Uri +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.storage.StorageReference +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FirebaseStorageManagerTest { + private val rootReference: StorageReference = mock() + private val childReference: StorageReference = mock() + + private val storageManager = FirebaseStorageManager().apply { storageReference = rootReference } + + @Test + fun `getRemoteMediaPath nests the filename under the survey's submissions`() { + val path = FirebaseStorageManager.getRemoteMediaPath("survey-1", "task-1-uuid.jpg") + + assertThat(path).isEqualTo("user-media/surveys/survey-1/submissions/task-1-uuid.jpg") + } + + @Test + fun `getRemoteMediaPath keeps survey and filename in separate path segments`() { + val path = FirebaseStorageManager.getRemoteMediaPath("a/b", "c.jpg") + + // The survey id is interpolated verbatim; this pins the segment order, not any escaping. + assertThat(path).isEqualTo("user-media/surveys/a/b/submissions/c.jpg") + } + + @Test + fun `getDownloadUrl resolves the url of the requested path`() = runTest { + val expected = Uri.parse("https://example.com/user-media/photo.jpg") + whenever(rootReference.child("user-media/surveys/s/submissions/photo.jpg")) + .thenReturn(childReference) + whenever(childReference.downloadUrl).thenReturn(Tasks.forResult(expected)) + + val url = storageManager.getDownloadUrl("user-media/surveys/s/submissions/photo.jpg") + + assertThat(url).isEqualTo(expected) + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt new file mode 100644 index 0000000000..0e247b6ece --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase + +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.WriteBatch +import com.google.firebase.functions.FirebaseFunctions +import kotlin.test.assertFailsWith +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.groundplatform.android.FakeData +import org.groundplatform.domain.model.User +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.mutation.LocationOfInterestMutation +import org.groundplatform.domain.model.mutation.Mutation +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FirestoreDataStoreTest { + private val batch: WriteBatch = mock() + private val firestore: FirebaseFirestore = mock() + private val functions: FirebaseFunctions = mock() + + private val dataStore by lazy { + val provider: FirebaseFirestoreProvider = mock { on { get() } doReturn firestore } + FirestoreDataStore(functions, provider, UnconfinedTestDispatcher()) + } + + @Test + fun `applyMutations commits an empty batch without touching any document`() = runTest { + whenever(firestore.batch()).thenReturn(batch) + whenever(batch.commit()).thenReturn(Tasks.forResult(null)) + + dataStore.applyMutations(listOf(), FakeData.USER) + + verify(batch).commit() + } + + @Test + fun `applyMutations rejects a mutation belonging to another user`() = runTest { + whenever(firestore.batch()).thenReturn(batch) + val otherUsersMutation = newLoiMutation(userId = "someone-else") + + // Guards against uploading one user's edits under another user's credentials. + val error = + assertFailsWith { + dataStore.applyMutations(listOf(otherUsersMutation), CURRENT_USER) + } + + assertThat(error).hasMessageThat().contains("someone-else") + verify(batch, never()).commit() + } + + private fun newLoiMutation(userId: String) = + LocationOfInterestMutation( + jobId = "jobId", + geometry = Point(Coordinates(88.0, -23.1)), + id = 1L, + locationOfInterestId = "loiId", + type = Mutation.Type.CREATE, + syncStatus = Mutation.SyncStatus.PENDING, + userId = userId, + surveyId = "surveyId", + clientTimestamp = 987654321L, + collectionId = "collectionId", + ) + + private companion object { + val CURRENT_USER = User("current-user", "current@example.com", "Current") + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreTestUtil.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreTestUtil.kt index 6de60ea2bd..770ba5cc07 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreTestUtil.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreTestUtil.kt @@ -16,8 +16,11 @@ package org.groundplatform.android.data.remote.firebase +import com.google.android.gms.tasks.Task import com.google.firebase.firestore.DocumentSnapshot import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.mockito.kotlin.whenever /** @@ -29,3 +32,13 @@ fun newDocumentSnapshot(id: String = "", data: Map? = null): Docume whenever(mockSnapshot.data).thenReturn(data) return mockSnapshot } + +/** + * Returns a [Task] in the completed-but-cancelled state, as produced when a Firebase call is + * abandoned. Awaiting it throws [kotlinx.coroutines.CancellationException]. + */ +fun canceledTask(): Task = mock { + on { isComplete } doReturn true + on { isCanceled } doReturn true + on { exception } doReturn null +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreUuidGeneratorTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreUuidGeneratorTest.kt new file mode 100644 index 0000000000..e2f631801f --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreUuidGeneratorTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FirestoreUuidGeneratorTest { + + @Test + fun `generateUuid returns the id Firestore reserved for a new document`() = runTest { + val document: DocumentReference = mock() + whenever(document.id).thenReturn("reserved-id") + val collection: CollectionReference = mock() + whenever(collection.document()).thenReturn(document) + val firestore: FirebaseFirestore = mock() + whenever(firestore.collection(FirestoreUuidGenerator.ID_COLLECTION)).thenReturn(collection) + val provider: FirebaseFirestoreProvider = mock { on { get() } doReturn firestore } + + // The document is never written; Firestore only allocates the id locally. + assertThat(FirestoreUuidGenerator(provider).generateUuid()).isEqualTo("reserved-id") + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/CaptureLocationResultConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/CaptureLocationResultConverterTest.kt new file mode 100644 index 0000000000..ac68190c71 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/CaptureLocationResultConverterTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.common.truth.Truth.assertThat +import org.groundplatform.android.data.remote.firebase.schema.CaptureLocationResultConverter.toCaptureLocationTaskData +import org.groundplatform.android.data.remote.firebase.schema.CaptureLocationResultConverter.toJSONObject +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.submission.CaptureLocationTaskData +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CaptureLocationResultConverterTest { + + @Test + fun `toJSONObject writes accuracy, altitude and geometry`() { + val json = CAPTURE_LOCATION.toJSONObject() + + assertThat(json.getDouble("accuracy")).isEqualTo(5.0) + assertThat(json.getDouble("altitude")).isEqualTo(100.0) + assertThat(json.has("geometry")).isTrue() + } + + @Test + fun `round trip preserves the captured location`() { + val restored = CAPTURE_LOCATION.toJSONObject().toCaptureLocationTaskData() + + assertThat(restored).isEqualTo(CAPTURE_LOCATION) + } + + private companion object { + val CAPTURE_LOCATION = + CaptureLocationTaskData( + location = Point(Coordinates(10.0, 20.0)), + altitude = 100.0, + accuracy = 5.0, + ) + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/GroundFirestoreTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/GroundFirestoreTest.kt new file mode 100644 index 0000000000..db2c1666a2 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/GroundFirestoreTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.WriteBatch +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GroundFirestoreTest { + private val firestore: FirebaseFirestore = mock() + private val groundFirestore = GroundFirestore(firestore) + + @Test + fun `surveys points at the surveys collection`() { + val surveys = collectionAt("surveys") + whenever(firestore.collection("surveys")).thenReturn(surveys) + + assertThat(groundFirestore.surveys().toString()).isEqualTo("surveys") + } + + @Test + fun `termsOfService points at the config collection`() { + val config = collectionAt("config") + whenever(firestore.collection("config")).thenReturn(config) + + assertThat(groundFirestore.termsOfService().toString()).isEqualTo("config") + } + + @Test + fun `terms resolves the tos document within config`() { + val config = collectionAt("config") + val tos: DocumentReference = mock() + whenever(tos.path).thenReturn("config/tos") + whenever(config.document("tos")).thenReturn(tos) + whenever(firestore.collection("config")).thenReturn(config) + + assertThat(groundFirestore.termsOfService().terms().toString()).isEqualTo("config/tos") + } + + @Test + fun `batch delegates to the underlying database`() { + val batch: WriteBatch = mock() + whenever(firestore.batch()).thenReturn(batch) + + assertThat(groundFirestore.batch()).isSameInstanceAs(batch) + } + + private fun collectionAt(path: String): CollectionReference { + val reference: CollectionReference = mock() + whenever(reference.path).thenReturn(path) + return reference + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/JobCollectionReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/JobCollectionReferenceTest.kt new file mode 100644 index 0000000000..b233febc60 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/JobCollectionReferenceTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.QueryDocumentSnapshot +import com.google.firebase.firestore.QuerySnapshot +import kotlinx.coroutines.test.runTest +import org.groundplatform.android.data.remote.firebase.canceledTask +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JobCollectionReferenceTest { + private val collectionReference: CollectionReference = mock() + private val jobCollectionReference = JobCollectionReference(collectionReference) + + @Test + fun `get returns no jobs when the collection is empty`() = runTest { + val snapshot: QuerySnapshot = mock() + whenever(snapshot.iterator()).thenReturn(mutableListOf().iterator()) + whenever(collectionReference.get()).thenReturn(Tasks.forResult(snapshot)) + + assertThat(jobCollectionReference.get()).isEmpty() + } + + @Test + fun `get returns no jobs when the fetch is cancelled`() = runTest { + val cancelled = canceledTask() + whenever(collectionReference.get()).thenReturn(cancelled) + + // Cancellation is swallowed so a survey sync aborted mid-flight doesn't surface as an error. + assertThat(jobCollectionReference.get()).isEmpty() + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiDocumentReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiDocumentReferenceTest.kt new file mode 100644 index 0000000000..4405389516 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiDocumentReferenceTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.SetOptions +import com.google.firebase.firestore.WriteBatch +import kotlin.time.Instant +import org.groundplatform.android.FakeData +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.locationofinterest.LOI_NAME_PROPERTY +import org.groundplatform.domain.model.mutation.LocationOfInterestMutation +import org.groundplatform.domain.model.mutation.Mutation +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LoiDocumentReferenceTest { + private val documentReference: DocumentReference = mock() + private val batch: WriteBatch = mock() + private val loiDocumentReference = LoiDocumentReference(documentReference) + + @Test + fun `addMutationToBatch merges the LOI on CREATE`() { + loiDocumentReference.addMutationToBatch( + newLoiMutation(Mutation.Type.CREATE), + FakeData.USER, + batch, + ) + + verify(batch).set(eq(documentReference), any>(), eq(SetOptions.merge())) + verify(batch, never()).delete(any()) + } + + @Test + fun `addMutationToBatch merges the LOI on UPDATE`() { + loiDocumentReference.addMutationToBatch( + newLoiMutation(Mutation.Type.UPDATE), + FakeData.USER, + batch, + ) + + verify(batch).set(eq(documentReference), any>(), eq(SetOptions.merge())) + verify(batch, never()).delete(any()) + } + + @Test + fun `addMutationToBatch deletes the document on DELETE`() { + loiDocumentReference.addMutationToBatch( + newLoiMutation(Mutation.Type.DELETE), + FakeData.USER, + batch, + ) + + verify(batch).delete(documentReference) + verify(batch, never()).set(any(), any>(), any()) + } + + @Test + fun `addMutationToBatch rejects an unknown mutation type`() { + val mutation = newLoiMutation(Mutation.Type.UNKNOWN) + + assertThrows(IllegalArgumentException::class.java) { + loiDocumentReference.addMutationToBatch(mutation, FakeData.USER, batch) + } + } + + private fun newLoiMutation(type: Mutation.Type) = + LocationOfInterestMutation( + jobId = "jobId", + geometry = Point(Coordinates(88.0, -23.1)), + id = 1L, + locationOfInterestId = "loiId", + type = type, + syncStatus = Mutation.SyncStatus.PENDING, + userId = FakeData.USER.id, + surveyId = "surveyId", + clientTimestamp = Instant.fromEpochSeconds(987654321).toEpochMilliseconds(), + submissionCount = 10, + properties = mapOf(LOI_NAME_PROPERTY to FakeData.LOCATION_OF_INTEREST_NAME), + customId = "a custom loi", + collectionId = "collectionId", + ) +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionCollectionReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionCollectionReferenceTest.kt new file mode 100644 index 0000000000..c7383fba31 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionCollectionReferenceTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SubmissionCollectionReferenceTest { + private val collectionReference: CollectionReference = mock() + private val submissionCollectionReference = SubmissionCollectionReference(collectionReference) + + @Test + fun `submission resolves the document with the given id`() { + val document: DocumentReference = mock() + whenever(document.path).thenReturn("surveys/s1/submissions/sub1") + whenever(collectionReference.document("sub1")).thenReturn(document) + + assertThat(submissionCollectionReference.submission("sub1").toString()) + .isEqualTo("surveys/s1/submissions/sub1") + } + + @Test + fun `toString reports the collection path`() { + whenever(collectionReference.path).thenReturn("surveys/s1/submissions") + + assertThat(submissionCollectionReference.toString()).isEqualTo("surveys/s1/submissions") + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionDocumentReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionDocumentReferenceTest.kt new file mode 100644 index 0000000000..3a62a9a85f --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionDocumentReferenceTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.SetOptions +import com.google.firebase.firestore.WriteBatch +import org.groundplatform.android.FakeData +import org.groundplatform.domain.model.mutation.Mutation +import org.groundplatform.domain.model.mutation.SubmissionMutation +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SubmissionDocumentReferenceTest { + private val documentReference: DocumentReference = mock() + private val batch: WriteBatch = mock() + private val submissionDocumentReference = SubmissionDocumentReference(documentReference) + + @Test + fun `addMutationToBatch merges the submission on CREATE`() { + submissionDocumentReference.addMutationToBatch( + newSubmissionMutation(Mutation.Type.CREATE), + FakeData.USER, + batch, + ) + + verify(batch).set(eq(documentReference), any>(), eq(SetOptions.merge())) + verify(batch, never()).delete(any()) + } + + @Test + fun `addMutationToBatch merges the submission on UPDATE`() { + submissionDocumentReference.addMutationToBatch( + newSubmissionMutation(Mutation.Type.UPDATE), + FakeData.USER, + batch, + ) + + verify(batch).set(eq(documentReference), any>(), eq(SetOptions.merge())) + verify(batch, never()).delete(any()) + } + + @Test + fun `addMutationToBatch deletes the document on DELETE`() { + submissionDocumentReference.addMutationToBatch( + newSubmissionMutation(Mutation.Type.DELETE), + FakeData.USER, + batch, + ) + + verify(batch).delete(documentReference) + verify(batch, never()).set(any(), any>(), any()) + } + + @Test + fun `addMutationToBatch rejects an unknown mutation type`() { + val mutation = newSubmissionMutation(Mutation.Type.UNKNOWN) + + assertThrows(IllegalArgumentException::class.java) { + submissionDocumentReference.addMutationToBatch(mutation, FakeData.USER, batch) + } + } + + private fun newSubmissionMutation(type: Mutation.Type) = + SubmissionMutation( + id = 1L, + submissionId = "submissionId", + surveyId = "surveyId", + locationOfInterestId = "loiId", + userId = FakeData.USER.id, + clientTimestamp = 987654321L, + job = FakeData.JOB, + collectionId = "collectionId", + type = type, + syncStatus = Mutation.SyncStatus.PENDING, + deltas = listOf(), + ) +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SurveyDocumentReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SurveyDocumentReferenceTest.kt new file mode 100644 index 0000000000..b6d4d60aff --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SurveyDocumentReferenceTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import kotlinx.coroutines.test.runTest +import org.groundplatform.android.data.remote.firebase.canceledTask +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SurveyDocumentReferenceTest { + private val documentReference: DocumentReference = mock() + private val surveyDocumentReference = SurveyDocumentReference(documentReference) + + @Test + fun `lois points at the survey's lois subcollection`() { + val lois = collectionAt("surveys/s1/lois") + whenever(documentReference.collection("lois")).thenReturn(lois) + + assertThat(surveyDocumentReference.lois().toString()).isEqualTo("surveys/s1/lois") + } + + @Test + fun `submissions points at the survey's submissions subcollection`() { + val submissions = collectionAt("surveys/s1/submissions") + whenever(documentReference.collection("submissions")).thenReturn(submissions) + + assertThat(surveyDocumentReference.submissions().toString()).isEqualTo("surveys/s1/submissions") + } + + @Test + fun `get returns null when the fetch is cancelled`() = runTest { + val cancelled = canceledTask() + whenever(documentReference.get()).thenReturn(cancelled) + + assertThat(surveyDocumentReference.get()).isNull() + } + + private fun collectionAt(path: String): CollectionReference { + val reference: CollectionReference = mock() + whenever(reference.path).thenReturn(path) + return reference + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TermsOfServiceDocumentReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TermsOfServiceDocumentReferenceTest.kt new file mode 100644 index 0000000000..b99e4149c6 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TermsOfServiceDocumentReferenceTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 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 + * + * https://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 org.groundplatform.android.data.remote.firebase.schema + +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import kotlinx.coroutines.test.runTest +import org.groundplatform.android.data.remote.firebase.canceledTask +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TermsOfServiceDocumentReferenceTest { + private val documentReference: DocumentReference = mock() + private val termsOfServiceDocumentReference = TermsOfServiceDocumentReference(documentReference) + + @Test + fun `get returns the terms held by the document`() = runTest { + val snapshot: DocumentSnapshot = mock() + whenever(snapshot.exists()).thenReturn(true) + whenever(snapshot.id).thenReturn("tos") + whenever(snapshot.toObject(TermsOfServiceDocument::class.java)) + .thenReturn(TermsOfServiceDocument("Terms text")) + whenever(documentReference.get()).thenReturn(Tasks.forResult(snapshot)) + + val terms = termsOfServiceDocumentReference.get() + + assertThat(terms?.id).isEqualTo("tos") + assertThat(terms?.text).isEqualTo("Terms text") + } + + @Test + fun `get returns null when the document is absent`() = runTest { + val snapshot: DocumentSnapshot = mock() + whenever(snapshot.exists()).thenReturn(false) + whenever(documentReference.get()).thenReturn(Tasks.forResult(snapshot)) + + assertThat(termsOfServiceDocumentReference.get()).isNull() + } + + @Test + fun `get returns null when the fetch is cancelled`() = runTest { + val cancelled = canceledTask() + whenever(documentReference.get()).thenReturn(cancelled) + + assertThat(termsOfServiceDocumentReference.get()).isNull() + } + + @Test + fun `terms returns a reference to the same document`() { + whenever(documentReference.path).thenReturn("config/tos") + + assertThat(termsOfServiceDocumentReference.terms().toString()).isEqualTo("config/tos") + } +} diff --git a/config/jacoco/jacoco.gradle b/config/jacoco/jacoco.gradle index 831d27b071..9253f3d30d 100644 --- a/config/jacoco/jacoco.gradle +++ b/config/jacoco/jacoco.gradle @@ -17,7 +17,7 @@ apply plugin: 'jacoco' jacoco { - toolVersion = "0.8.13" + toolVersion = libs.versions.jacocoVersion.get() } tasks.withType(Test).configureEach { @@ -28,49 +28,77 @@ tasks.withType(Test).configureEach { def isAndroidModule = plugins.hasPlugin('com.android.application') || plugins.hasPlugin('com.android.library') def isAndroidKmpModule = plugins.hasPlugin('com.android.kotlin.multiplatform.library') def isKmpModule = plugins.hasPlugin('org.jetbrains.kotlin.multiplatform') +// Generated code only: excluding hand-written classes would hide real gaps. Every pattern here +// matches `:app` classes only; no other module generates code of these kinds. def excludesList = [ '**/databinding/*', '**/proto/*', - '**/migration/*', + // Room. `LocalDatabase_*` covers both the database impl and the generated auto-migrations. '**/local/room/dao/*', + '**/LocalDatabase_*', + // Hilt/Dagger. `**/di/**` rather than `**/*Module*`, which also matched hand-written classes + // merely named "...Module...". '**/*_Factory*', + '**/*_MembersInjector*', + '**/*_GeneratedInjector*', + '**/*_AssistedFactory*', + '**/*_ComponentTreeDeps*', '**/*Hilt*', + '**/di/**', + // Navigation Safe Args. '**/*Args*', '**/*Directions*', - '**/LocalDatabase_Impl*', - '**/*Module*', - '**/data/remote/firebase/base/*', - '**/data/remote/firebase/schema/*Reference*', - '**/data/remote/firebase/FirebaseStorageManager*', - '**/data/remote/firebase/FirestoreDataStore*', - '**/system/channel/LocationSharedFlowCallback*', ] def testTaskName def classDirs def sourceDirs -def execData +// AGP redirects coverage to `outputs/unit_test_code_coverage` when the `jacoco` plugin is applied +// before it, otherwise the Gradle plugin's `build/jacoco` default wins. Accept both. +def execDataCandidates if (isAndroidModule) { testTaskName = 'testLocalDebugUnitTest' classDirs = 'build/intermediates/classes/localDebug/transformLocalDebugClassesWithAsm/dirs/org/groundplatform/android' sourceDirs = ['src/main/java/org/groundplatform/android'] - execData = 'build/jacoco/testLocalDebugUnitTest.exec' + execDataCandidates = [ + 'jacoco/testLocalDebugUnitTest.exec', + 'outputs/unit_test_code_coverage/localDebugUnitTest/testLocalDebugUnitTest.exec', + ] } else if (isAndroidKmpModule) { testTaskName = 'testAndroidHostTest' classDirs = 'build/classes/kotlin/android/main' sourceDirs = ['src/commonMain/kotlin', 'src/androidMain/kotlin'] - execData = 'build/jacoco/testAndroidHostTest.exec' + execDataCandidates = ['jacoco/testAndroidHostTest.exec'] } else if (isKmpModule) { testTaskName = 'jvmTest' classDirs = 'build/classes/kotlin/jvm/main' sourceDirs = ['src/commonMain/kotlin'] - execData = 'build/jacoco/jvmTest.exec' + execDataCandidates = ['jacoco/jvmTest.exec'] } -tasks.register('jacocoLocalDebugUnitTestReport', JacocoReport) { +// A `fileTree` matches only files that exist, so this resolves to whichever candidate was written. +def execData = fileTree(dir: 'build', includes: execDataCandidates) + +// `JacocoReport` skips rather than fails on missing execution data, silently publishing an empty +// report. A check inside that task would never run, so guard from a separate one. +def verifyExecutionData = tasks.register('verifyJacocoExecutionData') { dependsOn tasks.named(testTaskName) + group = "Verification" + description = "Fails if '$testTaskName' produced no JaCoCo execution data." + doLast { + if (execData.empty) { + throw new GradleException( + "No JaCoCo execution data found under 'build' at any of $execDataCandidates. " + + "The coverage report would be silently empty. Verify where '$testTaskName' " + + "writes its .exec file.") + } + } +} + +tasks.register('jacocoTestReport', JacocoReport) { + dependsOn verifyExecutionData group = "Reporting" - description = "Run tests and generate coverage reports" + description = "Runs '$testTaskName' and generates a JaCoCo coverage report." reports { csv.required = false xml.required = true @@ -78,5 +106,5 @@ tasks.register('jacocoLocalDebugUnitTestReport', JacocoReport) { } classDirectories.from = fileTree(dir: classDirs, excludes: excludesList) sourceDirectories.from = files(sourceDirs) - executionData.from = files(execData) + executionData.from = execData } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 931e9c524f..847bd066ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ groundPlatformVersion = "259e72a" gsonVersion = "2.14.0" hiltJetpackVersion = "1.4.0" hiltVersion = "2.60.1" +jacocoVersion = "0.8.13" jvmToolchainVersion = "17" jsonVersion = "20260814" junitKtx = "1.3.0"