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" ) }