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:
2026-04-26 23:56:02 +02:00
parent 68a77322d0
commit a82a63f696
10 changed files with 547 additions and 31 deletions
@@ -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()
}
}