Files
honeyDueKMP/composeApp/src/commonTest/kotlin/com/tt/honeyDue/data/DataManagerTaskCacheTest.kt
T
Trey t 915a5d4742 test: characterize getTasksForResidence filter contract
Locks down the contract that becomes the primary path for residence
detail in Phase 3:
- filters _allTasks by residenceId
- returns empty shell for residence with no tasks (vs null for cache miss)
- returns null when _allTasks itself is null (caller must hit API)
2026-05-01 18:30:58 -07:00

168 lines
7.3 KiB
Kotlin

package com.tt.honeyDue.data
import com.tt.honeyDue.models.TaskResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.BeforeTest
/**
* Regression tests for the gitea#2 task-cache bug:
* `DataManager.updateTask` was a no-op when both `_allTasks` was null AND
* `_tasksByResidence[residenceId]` was empty — exactly the cache state
* after a fresh register-then-bulkCreateTasks flow. The just-created
* tasks would only appear after an app restart.
*
* After the fix, `updateTask` must seed `_allTasks` from empty rather
* than skipping the update.
*/
class DataManagerTaskCacheTest {
@BeforeTest
fun resetState() {
DataManager.clear()
}
/// Onboarding-flow scenario: brand-new user, fresh launch, no kanban
/// has ever been fetched, then a task arrives via bulkCreateTasks →
/// DataManager.updateTask. The new task MUST land in `_allTasks` and
/// be visible to any observer.
@Test
fun updateTask_seedsAllTasks_whenCacheIsEmpty() {
// Given: fresh DataManager, kanban never loaded
assertEquals(null, DataManager.allTasks.value, "_allTasks must start null after clear()")
// When: a new task arrives via the same path bulkCreateTasks uses
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
// Then: _allTasks must contain that task in the right column
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks, "updateTask must seed _allTasks even when it was null")
val upcoming = allTasks.columns.firstOrNull { it.name == "upcoming_tasks" }
assertNotNull(upcoming, "the seeded kanban must include an upcoming_tasks column")
assertTrue(
upcoming.tasks.any { it.id == 1 },
"the new task must land in upcoming_tasks; got columns=${allTasks.columns.map { it.name to it.tasks.map { t -> t.id } }}"
)
assertEquals(upcoming.tasks.size, upcoming.count, "column count must match tasks.size")
}
/// Reasonable-defaults sanity check for the bulk-create scenario:
/// multiple tasks land across different kanban columns and end up
/// distributed correctly. This exercises the upsert when _allTasks
/// was seeded by a previous call.
@Test
fun updateTask_distributesAcrossColumns_whenSeedingThenAdding() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 100, column = "upcoming_tasks"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val overdue = allTasks.columns.first { it.name == "overdue_tasks" }
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
assertEquals(setOf(1), overdue.tasks.map { it.id }.toSet())
assertEquals(setOf(2, 3), upcoming.tasks.map { it.id }.toSet())
}
/// Replacement contract: calling updateTask with the same id twice
/// must not duplicate; the second call replaces the first wherever it
/// lives. Catches the "always-append" implementation mistake.
@Test
fun updateTask_replacesExistingTaskById_acrossColumns() {
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "upcoming_tasks", title = "v1"))
DataManager.updateTask(sampleTask(id = 5, residenceId = 100, column = "in_progress_tasks", title = "v2"))
val allTasks = DataManager.allTasks.value
assertNotNull(allTasks)
val upcoming = allTasks.columns.first { it.name == "upcoming_tasks" }
val inProgress = allTasks.columns.first { it.name == "in_progress_tasks" }
assertTrue(upcoming.tasks.none { it.id == 5 }, "task 5 must move out of upcoming_tasks")
assertEquals(1, inProgress.tasks.count { it.id == 5 }, "task 5 must appear once in in_progress_tasks")
assertEquals("v2", inProgress.tasks.first { it.id == 5 }.title)
}
/// Characterization: getTasksForResidence filters _allTasks by
/// residence id. This is the helper that becomes the primary path
/// for residence-detail in Phase 3 (collapse the dual cache).
@Test
fun getTasksForResidence_filtersAllTasksByResidenceId() {
// Seed _allTasks with tasks across two residences via the upsert path.
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
DataManager.updateTask(sampleTask(id = 2, residenceId = 100, column = "overdue_tasks"))
DataManager.updateTask(sampleTask(id = 3, residenceId = 200, column = "upcoming_tasks"))
val r100 = DataManager.getTasksForResidence(100)
assertNotNull(r100)
val r100Ids = r100.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(1, 2), r100Ids)
val r200 = DataManager.getTasksForResidence(200)
assertNotNull(r200)
val r200Ids = r200.columns.flatMap { it.tasks }.map { it.id }.toSet()
assertEquals(setOf(3), r200Ids)
// Counts on each column must match the filtered task lists.
for (column in r100.columns) {
assertEquals(column.tasks.size, column.count, "column ${column.name} count mismatch")
}
}
/// Characterization: residenceId with no tasks returns a non-null
/// shell so the residence-detail screen can distinguish "loading"
/// (null) from "loaded, no tasks" (non-null with empty columns).
@Test
fun getTasksForResidence_returnsEmptyShellForResidenceWithNoTasks() {
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
val r999 = DataManager.getTasksForResidence(999)
assertNotNull(r999, "residence with no tasks must return an empty shell, not null")
assertEquals(0, r999.columns.sumOf { it.tasks.size })
}
/// Characterization: when _allTasks is null entirely (cache never
/// populated), getTasksForResidence returns null — caller must call
/// the API path. Phase 3's getTasksByResidence relies on this.
@Test
fun getTasksForResidence_returnsNullWhenAllTasksIsNull() {
DataManager.clear()
assertEquals(null, DataManager.getTasksForResidence(100))
}
/// Lockdown: updateTask must NOT touch `_tasksByResidence`. That cache
/// is being deleted in Phase 3; until then, updateTask must leave it
/// alone. If a future commit re-introduces the conditional write
/// branch this test catches it.
@Test
fun updateTask_doesNotMutate_tasksByResidence() {
val before = DataManager.tasksByResidence.value
DataManager.updateTask(sampleTask(id = 1, residenceId = 100, column = "upcoming_tasks"))
assertEquals(
before,
DataManager.tasksByResidence.value,
"updateTask must not write to _tasksByResidence — that cache is deprecated"
)
}
private fun sampleTask(
id: Int,
residenceId: Int,
column: String,
title: String = "Task $id"
) = TaskResponse(
id = id,
residenceId = residenceId,
createdById = 1,
title = title,
kanbanColumn = column,
createdAt = "2026-04-25T00:00:00Z",
updatedAt = "2026-04-25T00:00:00Z"
)
}