diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt index 768c89a..dc184da 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt @@ -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 { diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersJson.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersJson.kt new file mode 100644 index 0000000..91e895d --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinersJson.kt @@ -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(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 diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinersClient.kt b/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinersClient.kt new file mode 100644 index 0000000..5a34fe8 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinersClient.kt @@ -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: `. + * - 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" + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/net/PollOutcome.kt b/watch/wearos/app/src/main/java/com/doctate/watch/net/PollOutcome.kt new file mode 100644 index 0000000..abf8c0f --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/net/PollOutcome.kt @@ -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 +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/net/SnapshotCache.kt b/watch/wearos/app/src/main/java/com/doctate/watch/net/SnapshotCache.kt new file mode 100644 index 0000000..afefff6 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/net/SnapshotCache.kt @@ -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" + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt index 9096409..763fd61 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncService.kt @@ -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 { diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncWorkerLoop.kt b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncWorkerLoop.kt index 8a5b66e..944fa72 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncWorkerLoop.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/sync/SyncWorkerLoop.kt @@ -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, + 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) { 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" diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinersJsonTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinersJsonTest.kt new file mode 100644 index 0000000..264a29b --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinersJsonTest.kt @@ -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() + } +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinersClientTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinersClientTest.kt new file mode 100644 index 0000000..ae55701 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinersClientTest.kt @@ -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() +} diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/net/SnapshotCacheTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/net/SnapshotCacheTest.kt new file mode 100644 index 0000000..467696f --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/net/SnapshotCacheTest.kt @@ -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() + } +}