diff options
Diffstat (limited to 'android/app/src/test/java/org/terst/doot/widget')
| -rw-r--r-- | android/app/src/test/java/org/terst/doot/widget/PendingRemovalsTest.kt | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/android/app/src/test/java/org/terst/doot/widget/PendingRemovalsTest.kt b/android/app/src/test/java/org/terst/doot/widget/PendingRemovalsTest.kt new file mode 100644 index 0000000..415bfd1 --- /dev/null +++ b/android/app/src/test/java/org/terst/doot/widget/PendingRemovalsTest.kt @@ -0,0 +1,107 @@ +package org.terst.doot.widget + +import org.junit.Assert.* +import org.junit.Test +import org.terst.doot.widget.data.PENDING_REMOVAL_TTL_MS +import org.terst.doot.widget.data.WidgetItem +import org.terst.doot.widget.data.decodePendingRemovals +import org.terst.doot.widget.data.encodePendingRemovals +import org.terst.doot.widget.data.excludingUnexpiredPendingRemovals +import org.terst.doot.widget.data.pendingKey +import org.terst.doot.widget.data.pruneExpired + +class PendingRemovalsTest { + + private fun taskItem(id: String, source: String = "doot") = + WidgetItem(id = id, title = "Task $id", source = source, type = "task") + + @Test + fun `excludingUnexpiredPendingRemovals filters out a pending item`() { + val items = listOf(taskItem("a"), taskItem("b")) + val now = 1_000_000L + val pending = mapOf(pendingKey("b", "doot") to now) + + val result = items.excludingUnexpiredPendingRemovals(pending, now) + + assertEquals(listOf("a"), result.map { it.id }) + } + + @Test + fun `excludingUnexpiredPendingRemovals is a no-op with an empty pending map`() { + val items = listOf(taskItem("a"), taskItem("b")) + assertEquals(items, items.excludingUnexpiredPendingRemovals(emptyMap(), 1_000_000L)) + } + + // This is the actual regression: a slower, earlier-started fetch's response still + // includes an item that a faster, later-started completion has since removed. Without + // pending-removal protection, persisting that stale snapshot resurrects the item -- + // this is what made "check to complete" look like it stopped working after the first + // tap (2026-08-05), even though every completion request was independently succeeding + // server-side. + @Test + fun `a stale fetch snapshot captured before a completion cannot resurrect it`() { + val staleSnapshotStillContainingB = listOf(taskItem("a"), taskItem("b")) + val completedAt = 1_000_000L + val pendingAfterCompletingB = mapOf(pendingKey("b", "doot") to completedAt) + + // The stale fetch's write happens *after* b was completed, wall-clock, even though + // its data was captured before -- exactly the race: slower fetch, later write. + val persisted = staleSnapshotStillContainingB + .excludingUnexpiredPendingRemovals(pendingAfterCompletingB, completedAt + 500) + + assertEquals(listOf("a"), persisted.map { it.id }) + } + + @Test + fun `a pending removal expires and stops protecting after the TTL`() { + val items = listOf(taskItem("a"), taskItem("b")) + val removedAt = 1_000_000L + val pending = mapOf(pendingKey("b", "doot") to removedAt) + + // Just before expiry: still protected. + val stillProtected = items.excludingUnexpiredPendingRemovals(pending, removedAt + PENDING_REMOVAL_TTL_MS - 1) + assertEquals(listOf("a"), stillProtected.map { it.id }) + + // At/after expiry: self-healing kicks in -- a permanently-failed completion must + // not hide its task forever, matching the existing periodic-refresh self-healing + // property elsewhere in this codebase. + val expired = items.excludingUnexpiredPendingRemovals(pending, removedAt + PENDING_REMOVAL_TTL_MS) + assertEquals(listOf("a", "b"), expired.map { it.id }) + } + + @Test + fun `different source with the same id is not confused for the pending item`() { + val items = listOf(taskItem("shared-id", source = "trello")) + val pending = mapOf(pendingKey("shared-id", "doot") to 1_000_000L) + + val result = items.excludingUnexpiredPendingRemovals(pending, 1_000_000L) + + assertEquals(listOf("shared-id"), result.map { it.id }) + } + + @Test + fun `pruneExpired drops old entries and keeps fresh ones`() { + val now = 1_000_000L + val pending = mapOf( + "fresh" to now, + "stale" to now - PENDING_REMOVAL_TTL_MS + ) + + val pruned = pending.pruneExpired(now) + + assertEquals(setOf("fresh"), pruned.keys) + } + + @Test + fun `encode and decode round-trip`() { + val pending = mapOf(pendingKey("a", "doot") to 42L) + val decoded = decodePendingRemovals(encodePendingRemovals(pending)) + assertEquals(pending, decoded) + } + + @Test + fun `decodePendingRemovals treats null and malformed input as empty`() { + assertEquals(emptyMap<String, Long>(), decodePendingRemovals(null)) + assertEquals(emptyMap<String, Long>(), decodePendingRemovals("not json")) + } +} |
