From b2803a905fb9b5cd920692b1c3c066dcd2d471ad Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 15 Sep 2026 20:49:39 +0530 Subject: [PATCH 1/3] refactor(build): Harden JaCoCo coverage report configuration `JacocoReport` skips rather than fails when its execution data is missing, so a wrong or unwritten `.exec` path publishes an empty report instead of breaking the build. Add a `verifyJacocoExecutionData` task that fails when no execution data was produced. It has to be a separate task because a check inside the report task would never run once that task is skipped. Resolve the execution data through a `fileTree` over the known `.exec` locations rather than a single hardcoded path. Where AGP writes unit test coverage depends on whether the `jacoco` plugin is applied before or after AGP: applying it first makes AGP redirect output to `outputs/unit_test_code_coverage` instead of the Gradle JaCoCo plugin's `build/jacoco` default. A file tree only matches files that exist, so it picks up whichever path is actually written and keeps working if that ordering changes. Rename `jacocoLocalDebugUnitTestReport` to `jacocoTestReport`. The task is registered for KMP and Android KMP modules too, where it runs `jvmTest` or `testAndroidHostTest` and has nothing to do with a local debug variant. Move the JaCoCo version into the version catalog alongside the other pinned tool versions. --- .github/workflows/ci.yml | 2 +- config/jacoco/jacoco.gradle | 40 +++++++++++++++++++++++++++++-------- gradle/libs.versions.toml | 1 + 3 files changed, 34 insertions(+), 9 deletions(-) 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/config/jacoco/jacoco.gradle b/config/jacoco/jacoco.gradle index 831d27b071..2a9319fdd2 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 { @@ -48,29 +48,53 @@ def excludesList = [ 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 +102,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" From c647355bc6ff2e1a5d82e72899a624c1e1d2a46e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 15 Sep 2026 20:51:29 +0530 Subject: [PATCH 2/3] refactor(build): Limit JaCoCo excludes to generated code The exclude list had grown to cover hand-written classes alongside generated ones, which hid real gaps in the coverage report. Measuring a report built with excludes disabled showed six patterns matching only hand-written Kotlin: `migration/*`, `firebase/base/*`, `firebase/schema/*Reference*`, `FirebaseStorageManager*`, `FirestoreDataStore*` and `LocationSharedFlowCallback*`. Drop them so the 240 lines they hid, 61 of which are already covered, are measured like any other source. Replace `**/*Module*` with `**/di/**`. The old pattern matched 159 classes on substring alone and would have hidden any hand-written class merely named "...Module..." anywhere in the tree. Every one of its non-generated matches lives under `di/`, and the generated `..._HiltModule` classes outside it stay excluded via `**/*Hilt*`. `**/di/**` also picks up `di/coroutines`, which the old single-segment matching missed. Cover the generated code that was leaking into the report in the other direction. Mapping every source file under `app/build/generated` against the report found Room's auto-migration implementations being measured, so broaden `**/LocalDatabase_Impl*` to `**/LocalDatabase_*`. Add `**/*_MembersInjector*`, `**/*_GeneratedInjector*`, `**/*_AssistedFactory*` and `**/*_ComponentTreeDeps*` for the remaining Dagger artifacts; those carry no lines, but leaving them in contradicts what this list is for. All ten generated source roots are now fully excluded. Reported coverage for `:app` moves from 78.46% to 77.44%, reflecting previously hidden code rather than any change in what the tests exercise. --- config/jacoco/jacoco.gradle | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/config/jacoco/jacoco.gradle b/config/jacoco/jacoco.gradle index 2a9319fdd2..9253f3d30d 100644 --- a/config/jacoco/jacoco.gradle +++ b/config/jacoco/jacoco.gradle @@ -28,22 +28,26 @@ 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 From 1e3e447c6b379b4e8b108f28d48fa2f453e70145 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 15 Sep 2026 21:37:47 +0530 Subject: [PATCH 3/3] test(firebase): Cover Firestore references, storage and uuid generation Adds unit tests for the firebase data layer, which the narrowed JaCoCo excludes made visible. Recovers 91 of the 222 uncovered lines in the package (58.9% -> 83.6%), taking :app from 77.44% to 78.26%. The three addMutationToBatch implementations are the substantive ones: each rejects unknown mutation types, a branch nothing exercised before. The rest pin down subcollection wiring and the CancellationException handlers that let an aborted sync return empty rather than fail. Adds a shared canceledTask() helper to FirestoreTestUtil for driving those cancellation paths. FirestoreDataStore is only partly covered: its remaining lines chain several Firestore layers deep or call the static Firebase.messaging, so only the applyMutations user-mismatch precondition is tested. --- .../firebase/FirebaseStorageManagerTest.kt | 63 +++++++++++ .../remote/firebase/FirestoreDataStoreTest.kt | 95 ++++++++++++++++ .../data/remote/firebase/FirestoreTestUtil.kt | 13 +++ .../firebase/FirestoreUuidGeneratorTest.kt | 47 ++++++++ .../CaptureLocationResultConverterTest.kt | 56 +++++++++ .../firebase/schema/GroundFirestoreTest.kt | 75 +++++++++++++ .../schema/JobCollectionReferenceTest.kt | 54 +++++++++ .../schema/LoiDocumentReferenceTest.kt | 106 ++++++++++++++++++ .../SubmissionCollectionReferenceTest.kt | 49 ++++++++ .../schema/SubmissionDocumentReferenceTest.kt | 100 +++++++++++++++++ .../schema/SurveyDocumentReferenceTest.kt | 65 +++++++++++ .../TermsOfServiceDocumentReferenceTest.kt | 74 ++++++++++++ 12 files changed, 797 insertions(+) create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/FirebaseStorageManagerTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreUuidGeneratorTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/CaptureLocationResultConverterTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/GroundFirestoreTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/JobCollectionReferenceTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiDocumentReferenceTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionCollectionReferenceTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionDocumentReferenceTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SurveyDocumentReferenceTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/TermsOfServiceDocumentReferenceTest.kt 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") + } +}