1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
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"))
}
}
|