feat(watch): conditional-GET oneliner polling with snapshot cache
OnelinersClient does GET /api/oneliners with X-API-Key + If-None-Match, classifies 200/304/transient/network. Mirrors doctate-client-core::server_sync::poll_once exactly. JSON parsing lives in domain.OnelinersJson (org.json) so the wire DTOs are first-class in-memory types. SnapshotCache persists the last successful response + its ETag in filesDir/recordings/snapshot_cache.json. SyncWorkerLoop primes the case store from this cache on cold start before the first network call, so the watch never flashes an empty list while the service boots. Worker loop integration: when the pending queue is empty and a poller is wired, runOnce polls instead of just publishing Idle. Success runs mergeServerSnapshot + reconcileWithServerSnapshot, then writes the new snapshot to the cache. Phase 4's poller-null behaviour remains as the test/skeleton path. 13 new tests (OnelinersJsonTest 3, OnelinersClientTest 6, SnapshotCacheTest 4) covering parse round-trip, 200/304/transient/network classification, If-None-Match header presence, corrupted-cache resilience. 71 tests green total.
This commit is contained in:
@@ -8,6 +8,8 @@ import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.domain.DiskCaseStore
|
||||
import com.doctate.watch.domain.StartupCleanup
|
||||
import com.doctate.watch.net.HttpClientProvider
|
||||
import com.doctate.watch.net.OnelinersClient
|
||||
import com.doctate.watch.net.SnapshotCache
|
||||
import com.doctate.watch.net.UploadClient
|
||||
import com.doctate.watch.settings.BuildConfigSettings
|
||||
import com.doctate.watch.settings.Settings
|
||||
@@ -36,6 +38,10 @@ class DoctateApp : Application() {
|
||||
val settings: Settings by lazy { BuildConfigSettings() }
|
||||
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
|
||||
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
|
||||
val onelinersClient: OnelinersClient by lazy { OnelinersClient(httpClient, settings) }
|
||||
val snapshotCache: SnapshotCache by lazy {
|
||||
SnapshotCache(File(filesDir, "recordings/snapshot_cache.json"))
|
||||
}
|
||||
|
||||
/** Persistent disk-backed marker store. Single source of truth for the case list. */
|
||||
val caseStore: CaseStore by lazy {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JSON ↔ Kotlin DTO marshallers for the `/api/oneliners` wire format.
|
||||
* Server-side source of truth: `doctate-common/src/oneliners.rs`. Field
|
||||
* names are snake_case to match the Rust serde output.
|
||||
*
|
||||
* Lives in `domain` (not `net`) because the parsed shape — including the
|
||||
* `OnelinerState` sealed interface — is the in-memory model the rest of
|
||||
* the app reasons about, not a transport-only concern.
|
||||
*/
|
||||
internal fun parseOnelinersResponse(text: String): OnelinersResponse {
|
||||
val obj = JSONObject(text)
|
||||
val arr = obj.getJSONArray("oneliners")
|
||||
val entries = ArrayList<OnelinerEntry>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
entries += parseOnelinerEntry(arr.getJSONObject(i))
|
||||
}
|
||||
return OnelinersResponse(
|
||||
asOf = obj.getString("as_of"),
|
||||
windowHours = obj.getInt("window_hours"),
|
||||
oneliners = entries,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun onelinersResponseToJson(response: OnelinersResponse): String {
|
||||
val arr = JSONArray()
|
||||
for (e in response.oneliners) arr.put(onelinerEntryToJson(e))
|
||||
return JSONObject().apply {
|
||||
put("as_of", response.asOf)
|
||||
put("window_hours", response.windowHours)
|
||||
put("oneliners", arr)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseOnelinerEntry(obj: JSONObject): OnelinerEntry = OnelinerEntry(
|
||||
caseId = obj.getString("case_id"),
|
||||
oneliner = obj.optJSONObject("oneliner")?.let { onelinerFromJson(it) },
|
||||
createdAt = obj.getString("created_at"),
|
||||
lastRecordingAt = obj.optStringOrNull("last_recording_at"),
|
||||
updatedAt = obj.optStringOrNull("updated_at"),
|
||||
)
|
||||
|
||||
private fun onelinerEntryToJson(entry: OnelinerEntry): JSONObject = JSONObject().apply {
|
||||
put("case_id", entry.caseId)
|
||||
put("oneliner", entry.oneliner?.let { onelinerToJson(it) } ?: JSONObject.NULL)
|
||||
put("created_at", entry.createdAt)
|
||||
put("last_recording_at", entry.lastRecordingAt ?: JSONObject.NULL)
|
||||
put("updated_at", entry.updatedAt ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
/** `optString` returns "" for null/missing; we want a real null. */
|
||||
private fun JSONObject.optStringOrNull(key: String): String? =
|
||||
if (has(key) && !isNull(key)) getString(key) else null
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
import com.doctate.watch.domain.parseOnelinersResponse
|
||||
import com.doctate.watch.settings.Settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Conditional-GET client for `/api/oneliners`. Wire format mirrors
|
||||
* `doctate-client-core::server_sync::poll_once`:
|
||||
*
|
||||
* - Always sends `X-API-Key`.
|
||||
* - If [poll] receives a non-null `etag`, sends `If-None-Match: <etag>`.
|
||||
* - 200 → parse body, surface new ETag (if any).
|
||||
* - 304 → no body.
|
||||
* - 408/429/5xx → [PollOutcome.TransientHttp] (worker backs off).
|
||||
* - Anything else (4xx) → also surfaced as TransientHttp; we don't want
|
||||
* the polling loop to die over a one-off auth glitch — the upload
|
||||
* path's terminal-vs-transient distinction is more nuanced.
|
||||
*/
|
||||
class OnelinersClient(
|
||||
private val http: OkHttpClient,
|
||||
private val settings: Settings,
|
||||
) {
|
||||
suspend fun poll(etag: String?): PollOutcome = withContext(Dispatchers.IO) {
|
||||
val builder = Request.Builder()
|
||||
.url(settings.serverUrl.trimEnd('/') + ONELINERS_PATH)
|
||||
.header(API_KEY_HEADER, settings.apiKey)
|
||||
.get()
|
||||
if (etag != null) builder.header(IF_NONE_MATCH, etag)
|
||||
|
||||
try {
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseOnelinersResponse(body)
|
||||
val newEtag = response.header(ETAG)
|
||||
PollOutcome.Success(parsed, newEtag)
|
||||
}
|
||||
304 -> PollOutcome.NotModified
|
||||
else -> PollOutcome.TransientHttp(response.code)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
PollOutcome.Network(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val API_KEY_HEADER = "X-API-Key"
|
||||
const val ONELINERS_PATH = "/api/oneliners"
|
||||
const val IF_NONE_MATCH = "If-None-Match"
|
||||
const val ETAG = "ETag"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
import com.doctate.watch.domain.OnelinersResponse
|
||||
|
||||
/** Outcome of one `/api/oneliners` poll. Mirrors `PollOutcome` in `server_sync.rs`. */
|
||||
sealed interface PollOutcome {
|
||||
/** 200 OK — body parsed, etag returned (may be null if server didn't ship one). */
|
||||
data class Success(val response: OnelinersResponse, val etag: String?) : PollOutcome
|
||||
|
||||
/** 304 — client cache is current. */
|
||||
data object NotModified : PollOutcome
|
||||
|
||||
/** Transient HTTP failure (5xx, 408, 429). Caller backs off and retries. */
|
||||
data class TransientHttp(val code: Int) : PollOutcome
|
||||
|
||||
/** Network/IO/parse failure. Treated as transient. */
|
||||
data class Network(val cause: Throwable) : PollOutcome
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
import android.util.Log
|
||||
import com.doctate.watch.domain.OnelinersResponse
|
||||
import com.doctate.watch.domain.onelinersResponseToJson
|
||||
import com.doctate.watch.domain.parseOnelinersResponse
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Disk-persisted copy of the most recent `/api/oneliners` response and
|
||||
* its ETag. Loaded on cold-start so the watch can render its case list
|
||||
* before any network round-trip — the user never sees a blank list flash
|
||||
* just because the service hasn't polled yet.
|
||||
*
|
||||
* Mirrors `doctate-client-core::snapshot_cache`.
|
||||
*/
|
||||
data class CachedSnapshot(
|
||||
val etag: String,
|
||||
val fetchedAt: String,
|
||||
val response: OnelinersResponse,
|
||||
)
|
||||
|
||||
class SnapshotCache(private val file: File) {
|
||||
|
||||
fun load(): CachedSnapshot? {
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val obj = JSONObject(file.readText(Charsets.UTF_8))
|
||||
CachedSnapshot(
|
||||
etag = obj.getString("etag"),
|
||||
fetchedAt = obj.getString("fetched_at"),
|
||||
response = parseOnelinersResponse(obj.getString("response")),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "snapshot cache load failed: ${e.message} — starting fresh")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun store(snapshot: CachedSnapshot) {
|
||||
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
val payload = JSONObject().apply {
|
||||
put("etag", snapshot.etag)
|
||||
put("fetched_at", snapshot.fetchedAt)
|
||||
put("response", onelinersResponseToJson(snapshot.response))
|
||||
}.toString()
|
||||
tmp.writeText(payload, Charsets.UTF_8)
|
||||
Files.move(
|
||||
tmp.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
if (file.exists() && !file.delete()) {
|
||||
Log.w(TAG, "snapshot cache clear failed: ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SnapshotCache"
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,8 @@ class SyncService : Service() {
|
||||
caseStore = app.caseStore,
|
||||
state = app.syncStateProvider,
|
||||
kickChannel = kickChannel,
|
||||
poller = app.onelinersClient,
|
||||
snapshotCache = app.snapshotCache,
|
||||
)
|
||||
return scope.launch {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,14 @@ import android.util.Log
|
||||
import com.doctate.watch.audio.PendingStore
|
||||
import com.doctate.watch.audio.PendingUpload
|
||||
import com.doctate.watch.domain.CaseStore
|
||||
import com.doctate.watch.net.OnelinersClient
|
||||
import com.doctate.watch.net.PollOutcome
|
||||
import com.doctate.watch.net.SnapshotCache
|
||||
import com.doctate.watch.net.UploadResult
|
||||
import com.doctate.watch.domain.parseOnelinersResponse // re-export only for callers
|
||||
import com.doctate.watch.domain.OnelinersResponse
|
||||
import com.doctate.watch.domain.OnelinerEntry
|
||||
import com.doctate.watch.net.CachedSnapshot
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -13,25 +20,21 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.selects.onTimeout
|
||||
import kotlinx.coroutines.selects.select
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Pure upload + poll worker, testable on the JVM. Mirrors the loop in
|
||||
* `doctate-client-core::server_sync::run` — uploads have priority over
|
||||
* polling (no point asking the server about state if we still have the
|
||||
* doctor's audio in our pocket).
|
||||
* polling.
|
||||
*
|
||||
* The poll branch is wired in Phase 6; for now the empty-queue arm just
|
||||
* idles on the kick channel + interval.
|
||||
* The worker exposes [runOnce] for direct test invocation and [run] for
|
||||
* the production infinite-loop variant. Splitting them keeps tests off
|
||||
* the `runTest` ↔ infinite-loop tarpit (advanceUntilIdle has no
|
||||
* stopping condition against an unconditional while).
|
||||
*
|
||||
* Backoff is purely in-process state. After a process restart it drops
|
||||
* back to [initialBackoffMs] and tries again immediately — which is the
|
||||
* right behaviour: "the watch came back to life, give the server one
|
||||
* fresh chance" is friendlier than "wait 60 s because we left the loop
|
||||
* mid-backoff".
|
||||
*
|
||||
* Architecturally split into [runOnce] (single iteration, suspends only
|
||||
* on real I/O — testable directly) and [run] (infinite loop, suspends
|
||||
* indefinitely on the idle branch — used by the production service).
|
||||
* If [poller] is null the loop is upload-only — this is the Phase 4
|
||||
* shape; Phase 6 wires in the real `OnelinersClient` so the idle branch
|
||||
* does conditional GET against `/api/oneliners`.
|
||||
*/
|
||||
class SyncWorkerLoop(
|
||||
private val pendingStore: PendingStore,
|
||||
@@ -39,6 +42,8 @@ class SyncWorkerLoop(
|
||||
private val caseStore: CaseStore,
|
||||
private val state: SyncStateProvider,
|
||||
private val kickChannel: Channel<SyncKick>,
|
||||
private val poller: OnelinersClient? = null,
|
||||
private val snapshotCache: SnapshotCache? = null,
|
||||
private val initialBackoffMs: Long = INITIAL_BACKOFF_MS,
|
||||
private val maxBackoffMs: Long = MAX_BACKOFF_MS,
|
||||
private val pollIntervalMsProvider: () -> Long = { DEFAULT_POLL_INTERVAL_MS },
|
||||
@@ -51,33 +56,33 @@ class SyncWorkerLoop(
|
||||
|
||||
/** Outcome of a single iteration. */
|
||||
sealed interface Step {
|
||||
/** A pending upload was processed, regardless of HTTP outcome. */
|
||||
/** A pending upload was processed. */
|
||||
data class Uploaded(val result: UploadResult) : Step
|
||||
/** Queue was empty when this iteration started. */
|
||||
/** Idle branch ran a poll. */
|
||||
data class Polled(val outcome: PollOutcome) : Step
|
||||
/** Idle branch with no poller wired (test/Phase-4 shape). */
|
||||
data object Idle : Step
|
||||
}
|
||||
|
||||
private var backoffMs: Long = initialBackoffMs
|
||||
private var etag: String? = null
|
||||
|
||||
/**
|
||||
* Run a single iteration. Uploads have priority over polling.
|
||||
*
|
||||
* - Pending non-empty: do exactly one upload and apply its outcome
|
||||
* (delete pair + mark synced on success, delete on terminal, keep
|
||||
* for retry on transient with backoff already incremented).
|
||||
* - Pending empty: publish Idle and return — caller decides how to
|
||||
* wait for the next wake-up (kick channel + poll timeout in
|
||||
* production; immediate return in tests).
|
||||
*/
|
||||
suspend fun runOnce(): Step {
|
||||
val pending = pendingStore.scan()
|
||||
val next = pending.firstOrNull()
|
||||
if (next != null) {
|
||||
publish(WorkerPhase.Uploading, pending)
|
||||
val result = uploader.upload(next.caseId, next.recordedAt, next.file)
|
||||
applyResult(next, result)
|
||||
applyUploadResult(next, result)
|
||||
return Step.Uploaded(result)
|
||||
}
|
||||
if (poller != null) {
|
||||
publish(WorkerPhase.Polling, emptyList())
|
||||
val outcome = poller.poll(etag)
|
||||
applyPollOutcome(outcome)
|
||||
publish(WorkerPhase.Idle, emptyList())
|
||||
return Step.Polled(outcome)
|
||||
}
|
||||
publish(WorkerPhase.Idle, emptyList())
|
||||
return Step.Idle
|
||||
}
|
||||
@@ -85,10 +90,22 @@ class SyncWorkerLoop(
|
||||
/** Infinite loop variant for production. Idle branch waits on kick or poll interval. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun run() {
|
||||
// Cold-start prime: load the cached snapshot so the UI has data
|
||||
// before any network call lands. Etag is hydrated so the very first
|
||||
// poll can be a 304.
|
||||
snapshotCache?.load()?.let { cached ->
|
||||
try {
|
||||
caseStore.mergeServerSnapshot(cached.response)
|
||||
etag = cached.etag
|
||||
Log.i(TAG, "cold-start primed from snapshot cache")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "snapshot prime failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
while (currentCoroutineContext().isActive) {
|
||||
when (runOnce()) {
|
||||
is Step.Uploaded -> Unit // proceed to next iteration immediately
|
||||
Step.Idle -> waitForKickOrTimeout()
|
||||
is Step.Uploaded -> Unit // proceed immediately
|
||||
is Step.Polled, Step.Idle -> waitForKickOrTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +118,7 @@ class SyncWorkerLoop(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyResult(next: PendingUpload, result: UploadResult) {
|
||||
private suspend fun applyUploadResult(next: PendingUpload, result: UploadResult) {
|
||||
when (result) {
|
||||
is UploadResult.Success -> {
|
||||
pendingStore.deletePair(next.file)
|
||||
@@ -117,7 +134,6 @@ class SyncWorkerLoop(
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(maxBackoffMs)
|
||||
}
|
||||
is UploadResult.Terminal -> {
|
||||
// Server says "do not retry" (4xx) — drop the pair so the queue can drain.
|
||||
Log.w(TAG, "terminal: ${result.reason} → dropping caseId=${next.caseId.take(8)}")
|
||||
pendingStore.deletePair(next.file)
|
||||
state.recordFailure(nowMs(), result.reason)
|
||||
@@ -126,6 +142,40 @@ class SyncWorkerLoop(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyPollOutcome(outcome: PollOutcome) {
|
||||
when (outcome) {
|
||||
is PollOutcome.Success -> {
|
||||
caseStore.mergeServerSnapshot(outcome.response)
|
||||
caseStore.reconcileWithServerSnapshot(outcome.response)
|
||||
if (outcome.etag != null) {
|
||||
snapshotCache?.store(
|
||||
CachedSnapshot(
|
||||
etag = outcome.etag,
|
||||
fetchedAt = Instant.ofEpochMilli(nowMs()).toString(),
|
||||
response = outcome.response,
|
||||
),
|
||||
)
|
||||
etag = outcome.etag
|
||||
} else {
|
||||
Log.w(TAG, "server returned 200 without ETag — conditional requests disabled")
|
||||
etag = null
|
||||
}
|
||||
state.recordSuccess(nowMs())
|
||||
}
|
||||
is PollOutcome.NotModified -> {
|
||||
state.recordSuccess(nowMs())
|
||||
}
|
||||
is PollOutcome.TransientHttp -> {
|
||||
state.recordFailure(nowMs(), "poll HTTP ${outcome.code}")
|
||||
Log.w(TAG, "poll transient: HTTP ${outcome.code}")
|
||||
}
|
||||
is PollOutcome.Network -> {
|
||||
state.recordFailure(nowMs(), "poll: ${outcome.cause.message}")
|
||||
Log.w(TAG, "poll network: ${outcome.cause.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(phase: WorkerPhase, pending: List<PendingUpload>) {
|
||||
val ids = pending.map { it.caseId }.toSet()
|
||||
when (phase) {
|
||||
@@ -135,8 +185,9 @@ class SyncWorkerLoop(
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only accessor. */
|
||||
/** Test-only accessors. */
|
||||
internal val currentBackoffMs: Long get() = backoffMs
|
||||
internal val currentEtag: String? get() = etag
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncWorkerLoop"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.doctate.watch.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class OnelinersJsonTest {
|
||||
@Test fun parses_full_response_with_all_oneliner_kinds() {
|
||||
val json = """
|
||||
{
|
||||
"as_of": "2026-04-26T12:00:00Z",
|
||||
"window_hours": 72,
|
||||
"oneliners": [
|
||||
{
|
||||
"case_id": "c-ready",
|
||||
"oneliner": {"kind":"ready","text":"Hello","generated_at":"2026-04-26T11:00:00Z"},
|
||||
"created_at": "2026-04-26T10:00:00Z",
|
||||
"last_recording_at": "2026-04-26T10:30:00Z",
|
||||
"updated_at": "2026-04-26T11:00:00Z"
|
||||
},
|
||||
{
|
||||
"case_id": "c-empty",
|
||||
"oneliner": {"kind":"empty","generated_at":"2026-04-26T11:01:00Z"},
|
||||
"created_at": "2026-04-26T10:01:00Z",
|
||||
"last_recording_at": null,
|
||||
"updated_at": null
|
||||
},
|
||||
{
|
||||
"case_id": "c-error",
|
||||
"oneliner": {"kind":"error","generated_at":"2026-04-26T11:02:00Z"},
|
||||
"created_at": "2026-04-26T10:02:00Z",
|
||||
"last_recording_at": "2026-04-26T10:30:00Z",
|
||||
"updated_at": "2026-04-26T11:02:00Z"
|
||||
},
|
||||
{
|
||||
"case_id": "c-manual",
|
||||
"oneliner": {"kind":"manual","text":"Doctor","set_at":"2026-04-26T11:03:00Z"},
|
||||
"created_at": "2026-04-26T10:03:00Z",
|
||||
"last_recording_at": "2026-04-26T10:30:00Z",
|
||||
"updated_at": "2026-04-26T11:03:00Z"
|
||||
},
|
||||
{
|
||||
"case_id": "c-no-oneliner",
|
||||
"oneliner": null,
|
||||
"created_at": "2026-04-26T10:04:00Z",
|
||||
"last_recording_at": null,
|
||||
"updated_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val parsed = parseOnelinersResponse(json)
|
||||
assertThat(parsed.asOf).isEqualTo("2026-04-26T12:00:00Z")
|
||||
assertThat(parsed.windowHours).isEqualTo(72)
|
||||
assertThat(parsed.oneliners.map { it.caseId })
|
||||
.containsExactly("c-ready", "c-empty", "c-error", "c-manual", "c-no-oneliner").inOrder()
|
||||
|
||||
val ready = parsed.oneliners[0].oneliner as OnelinerState.Ready
|
||||
assertThat(ready.text).isEqualTo("Hello")
|
||||
assertThat(parsed.oneliners[1].oneliner).isInstanceOf(OnelinerState.Empty::class.java)
|
||||
assertThat(parsed.oneliners[2].oneliner).isInstanceOf(OnelinerState.Error::class.java)
|
||||
val manual = parsed.oneliners[3].oneliner as OnelinerState.Manual
|
||||
assertThat(manual.text).isEqualTo("Doctor")
|
||||
assertThat(parsed.oneliners[4].oneliner).isNull()
|
||||
}
|
||||
|
||||
@Test fun roundtrip_preserves_all_fields() {
|
||||
val original = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 24,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = "c-1",
|
||||
oneliner = OnelinerState.Ready("hi", "2026-04-26T11:00:00Z"),
|
||||
createdAt = "2026-04-26T10:00:00Z",
|
||||
lastRecordingAt = "2026-04-26T10:30:00Z",
|
||||
updatedAt = "2026-04-26T11:00:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
val parsed = parseOnelinersResponse(onelinersResponseToJson(original))
|
||||
assertThat(parsed).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test fun null_optional_fields_round_trip_as_null() {
|
||||
val original = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 24,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = "c-no-data",
|
||||
oneliner = null,
|
||||
createdAt = "2026-04-26T10:00:00Z",
|
||||
lastRecordingAt = null,
|
||||
updatedAt = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
val parsed = parseOnelinersResponse(onelinersResponseToJson(original))
|
||||
assertThat(parsed.oneliners.single().oneliner).isNull()
|
||||
assertThat(parsed.oneliners.single().lastRecordingAt).isNull()
|
||||
assertThat(parsed.oneliners.single().updatedAt).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
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
|
||||
|
||||
class OnelinersClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: OnelinersClient
|
||||
|
||||
@Before fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val settings = object : Settings {
|
||||
override val serverUrl = server.url("/").toString().trimEnd('/')
|
||||
override val apiKey = "test-key"
|
||||
}
|
||||
client = OnelinersClient(OkHttpClient(), settings)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test fun success_returns_parsed_body_and_etag() = runTest {
|
||||
val body = """
|
||||
{
|
||||
"as_of":"2026-04-26T12:00:00Z",
|
||||
"window_hours":72,
|
||||
"oneliners":[]
|
||||
}
|
||||
""".trimIndent()
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("ETag", "W/\"abc-72\"")
|
||||
.setBody(body),
|
||||
)
|
||||
|
||||
val outcome = client.poll(etag = null)
|
||||
assertThat(outcome).isInstanceOf(PollOutcome.Success::class.java)
|
||||
val success = outcome as PollOutcome.Success
|
||||
assertThat(success.etag).isEqualTo("W/\"abc-72\"")
|
||||
assertThat(success.response.windowHours).isEqualTo(72)
|
||||
}
|
||||
|
||||
@Test fun not_modified_returns_NotModified() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", "W/\"abc-72\""))
|
||||
val outcome = client.poll(etag = "W/\"abc-72\"")
|
||||
assertThat(outcome).isEqualTo(PollOutcome.NotModified)
|
||||
}
|
||||
|
||||
@Test fun if_none_match_header_is_sent_when_etag_provided() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(304))
|
||||
client.poll(etag = "W/\"prev-tag\"")
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.getHeader("If-None-Match")).isEqualTo("W/\"prev-tag\"")
|
||||
assertThat(request.getHeader("X-API-Key")).isEqualTo("test-key")
|
||||
}
|
||||
|
||||
@Test fun no_if_none_match_when_etag_null() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(emptyResponseBody()))
|
||||
client.poll(etag = null)
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.getHeader("If-None-Match")).isNull()
|
||||
}
|
||||
|
||||
@Test fun http_503_classifies_as_TransientHttp() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(503))
|
||||
val outcome = client.poll(etag = null)
|
||||
assertThat(outcome).isEqualTo(PollOutcome.TransientHttp(503))
|
||||
}
|
||||
|
||||
@Test fun network_failure_yields_Network_outcome() = runTest {
|
||||
server.shutdown() // server is down → connection refused
|
||||
val outcome = client.poll(etag = null)
|
||||
assertThat(outcome).isInstanceOf(PollOutcome.Network::class.java)
|
||||
}
|
||||
|
||||
private fun emptyResponseBody() = """
|
||||
{"as_of":"2026-04-26T12:00:00Z","window_hours":72,"oneliners":[]}
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.doctate.watch.net
|
||||
|
||||
import com.doctate.watch.domain.OnelinerEntry
|
||||
import com.doctate.watch.domain.OnelinerState
|
||||
import com.doctate.watch.domain.OnelinersResponse
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class SnapshotCacheTest {
|
||||
@get:Rule val tempDir = TemporaryFolder()
|
||||
|
||||
private fun newCache(): SnapshotCache = SnapshotCache(File(tempDir.root, "cache.json"))
|
||||
|
||||
@Test fun load_returns_null_when_file_missing() {
|
||||
assertThat(newCache().load()).isNull()
|
||||
}
|
||||
|
||||
@Test fun store_then_load_roundtrips_full_snapshot() {
|
||||
val cache = newCache()
|
||||
val snap = CachedSnapshot(
|
||||
etag = "W/\"abc-72\"",
|
||||
fetchedAt = "2026-04-26T12:00:00Z",
|
||||
response = OnelinersResponse(
|
||||
asOf = "2026-04-26T12:00:00Z",
|
||||
windowHours = 72,
|
||||
oneliners = listOf(
|
||||
OnelinerEntry(
|
||||
caseId = "c-1",
|
||||
oneliner = OnelinerState.Ready("hello", "2026-04-26T11:00:00Z"),
|
||||
createdAt = "2026-04-26T10:00:00Z",
|
||||
lastRecordingAt = "2026-04-26T10:30:00Z",
|
||||
updatedAt = "2026-04-26T11:00:00Z",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
cache.store(snap)
|
||||
val loaded = cache.load()
|
||||
assertThat(loaded).isEqualTo(snap)
|
||||
}
|
||||
|
||||
@Test fun load_returns_null_on_corrupted_file() {
|
||||
val cache = newCache()
|
||||
File(tempDir.root, "cache.json").writeText("not json", Charsets.UTF_8)
|
||||
assertThat(cache.load()).isNull()
|
||||
}
|
||||
|
||||
@Test fun clear_removes_file() {
|
||||
val cache = newCache()
|
||||
cache.store(
|
||||
CachedSnapshot(
|
||||
etag = "x",
|
||||
fetchedAt = "2026-04-26T12:00:00Z",
|
||||
response = OnelinersResponse("2026-04-26T12:00:00Z", 72, emptyList()),
|
||||
),
|
||||
)
|
||||
cache.clear()
|
||||
assertThat(File(tempDir.root, "cache.json").exists()).isFalse()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user