Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]'
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalStateException> {
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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -29,3 +32,13 @@ fun newDocumentSnapshot(id: String = "", data: Map<String, Any>? = 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 <T> canceledTask(): Task<T> = mock {
on { isComplete } doReturn true
on { isCanceled } doReturn true
on { exception } doReturn null
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading