915a5d4742
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)
168 lines
7.3 KiB
Kotlin
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"
|
|
)
|
|
}
|