summaryrefslogtreecommitdiff
path: root/android/app/src/test/java/org/terst/doot/widget/PendingRemovalsTest.kt
blob: 415bfd15a1fcbf67069c89bb7b8758a36e72a020 (plain)
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"))
    }
}