feat(watch): scaffold upload client and app service locator
Builds the network foundation for the Wear OS vertical PoC: OkHttp-based UploadClient mirroring doctate-client-core's wire protocol (multipart /api/upload, X-API-Key, transient/terminal classification), BuildConfig-backed Settings sourced from local.properties, CaseId factory, DoctateApp service locator, MockWebServer-based UploadClientTest for wire-format assertions, plus the manifest/permissions/network-security- config plumbing for emulator-loopback cleartext. MainActivity is still the placeholder UI; audio recording, ViewModel, and recording screen come in follow-up commits.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.doctate.watch.settings.Settings
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UploadClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: UploadClient
|
||||
private lateinit var audioFile: File
|
||||
|
||||
@Before fun setUp() {
|
||||
server = MockWebServer().apply { start() }
|
||||
val settings = object : Settings {
|
||||
override val serverUrl = server.url("/").toString().trimEnd('/')
|
||||
override val apiKey = "key-dr_test"
|
||||
}
|
||||
client = UploadClient(OkHttpClient(), settings)
|
||||
audioFile = copyAssetToTempFile("sample.m4a")
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
server.shutdown()
|
||||
audioFile.delete()
|
||||
}
|
||||
|
||||
@Test fun upload_sends_correct_multipart_and_returns_success_on_200() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"case_id":"550e8400-e29b-41d4-a716-446655440000","recorded_at":"2026-04-23T10:30:00Z","status":"received"}"""
|
||||
)
|
||||
)
|
||||
|
||||
val result = client.upload(
|
||||
caseId = "550e8400-e29b-41d4-a716-446655440000",
|
||||
recordedAt = "2026-04-23T10:30:00Z",
|
||||
audio = audioFile,
|
||||
)
|
||||
|
||||
val req = server.takeRequest()
|
||||
assertThat(req.method).isEqualTo("POST")
|
||||
assertThat(req.path).isEqualTo("/api/upload")
|
||||
assertThat(req.getHeader("X-API-Key")).isEqualTo("key-dr_test")
|
||||
val body = req.body.readUtf8()
|
||||
assertThat(body).contains("name=\"case_id\"")
|
||||
assertThat(body).contains("550e8400-e29b-41d4-a716-446655440000")
|
||||
assertThat(body).contains("name=\"recorded_at\"")
|
||||
assertThat(body).contains("2026-04-23T10:30:00Z")
|
||||
assertThat(body).contains("name=\"audio\"")
|
||||
assertThat(body).contains("Content-Type: audio/mp4")
|
||||
assertThat(result).isInstanceOf(UploadResult.Success::class.java)
|
||||
}
|
||||
|
||||
@Test fun upload_classifies_5xx_as_transient() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(500).setBody("oh no"))
|
||||
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
|
||||
assertThat(result).isInstanceOf(UploadResult.Transient::class.java)
|
||||
}
|
||||
|
||||
@Test fun upload_classifies_401_as_terminal() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("nope"))
|
||||
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
|
||||
assertThat(result).isInstanceOf(UploadResult.Terminal::class.java)
|
||||
}
|
||||
|
||||
@Test fun upload_classifies_io_error_as_transient() = runTest {
|
||||
server.shutdown()
|
||||
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
|
||||
assertThat(result).isInstanceOf(UploadResult.Transient::class.java)
|
||||
}
|
||||
|
||||
private fun copyAssetToTempFile(assetName: String): File {
|
||||
val ctx = InstrumentationRegistry.getInstrumentation().context
|
||||
val tmp = File.createTempFile("upload-test-", ".m4a", ctx.cacheDir)
|
||||
ctx.assets.open(assetName).use { input ->
|
||||
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user