UI Test SuiteZZ: Cleanup tests (iOS parity)

Port SuiteZZ_CleanupTests.swift. Deletes test-prefixed residences/tasks/
contractors/documents via authenticated APILayer + TaskApi calls. Runs
alphabetically last via the SuiteZZ_ prefix. Each step is idempotent —
logs failures but never blocks the next run. Preserves one seed "Test
House" residence so AAA_SeedTests has a deterministic starting point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 14:47:41 -05:00
parent 6980ed772b
commit d42406cbec

View File

@@ -0,0 +1,253 @@
package com.tt.honeyDue
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.PersistenceManager
import com.tt.honeyDue.fixtures.TestUser
import com.tt.honeyDue.models.LoginRequest
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.TaskApi
import com.tt.honeyDue.storage.TaskCacheManager
import com.tt.honeyDue.storage.TaskCacheStorage
import com.tt.honeyDue.storage.ThemeStorageManager
import com.tt.honeyDue.storage.TokenManager
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
/**
* Phase 3 — Cleanup tests that run alphabetically last via the `SuiteZZ_`
* prefix under JUnit's `NAME_ASCENDING` sorter.
*
* Ports `iosApp/HoneyDueUITests/SuiteZZ_CleanupTests.swift`, adapted to the
* Kotlin fixture naming convention. Rather than relying on a seeded admin
* endpoint (`/admin/settings/clear-all-data` — which the KMM `APILayer` does
* not wrap) we delete by **prefix** using the authenticated user endpoints
* that already exist. This mirrors the names produced by `TestResidence`,
* `TestTask`, and `TestUser.ephemeralUser()`:
*
* - Residences whose name begins with `"Test House"` or `"Test Apt"`
* - Tasks whose title begins with `"Test Task"` or `"Urgent Task"` or `"UITest_"`
* - Documents whose title begins with `"test_"` or `"UITest_"`
* - Contractors whose name begins with `"test_"` or `"UITest_"`
*
* Each step is idempotent — if there is nothing to clean the test passes
* trivially. Failures to delete individual items are logged but do not fail
* the suite; cleanup should never block a subsequent run.
*
* Hits the live dev backend configured in `ApiConfig.CURRENT_ENV`.
*/
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class SuiteZZ_CleanupTests {
private val testUser: TestUser = TestUser.seededTestUser()
private val taskApi = TaskApi()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (!isDataManagerInitialized()) {
DataManager.initialize(
tokenMgr = TokenManager.getInstance(context),
themeMgr = ThemeStorageManager.getInstance(context),
persistenceMgr = PersistenceManager.getInstance(context),
)
}
TaskCacheStorage.initialize(TaskCacheManager.getInstance(context))
}
@Test
fun zz01_cleanupTestTasks() = runBlocking {
val token = ensureLoggedIn() ?: return@runBlocking
// Force refresh so we see anything parallel suites just created.
val tasksResult = APILayer.getTasks(forceRefresh = true)
if (tasksResult !is ApiResult.Success) {
// Nothing to clean — still considered idempotent success.
return@runBlocking
}
val toDelete = tasksResult.data.columns
.flatMap { it.tasks }
.distinctBy { it.id }
.filter { it.title.matchesTestPrefix(TASK_PREFIXES) }
toDelete.forEach { task ->
val res = taskApi.deleteTask(token, task.id)
if (res !is ApiResult.Success) {
println("[SuiteZZ] Failed to delete task ${task.id} '${task.title}': $res")
} else {
DataManager.removeTask(task.id)
}
}
println("[SuiteZZ] zz01 removed ${toDelete.size} test tasks")
}
@Test
fun zz02_cleanupTestDocuments() = runBlocking {
ensureLoggedIn() ?: return@runBlocking
val docsResult = APILayer.getDocuments(forceRefresh = true)
if (docsResult !is ApiResult.Success) return@runBlocking
val toDelete = docsResult.data
.filter { it.title.matchesTestPrefix(DOCUMENT_PREFIXES) }
.mapNotNull { it.id }
toDelete.forEach { id ->
val res = APILayer.deleteDocument(id)
if (res !is ApiResult.Success) {
println("[SuiteZZ] Failed to delete document $id: $res")
}
}
println("[SuiteZZ] zz02 removed ${toDelete.size} test documents")
}
@Test
fun zz03_cleanupTestContractors() = runBlocking {
ensureLoggedIn() ?: return@runBlocking
val contractorsResult = APILayer.getContractors(forceRefresh = true)
if (contractorsResult !is ApiResult.Success) return@runBlocking
val toDelete = contractorsResult.data
.filter { it.name.matchesTestPrefix(CONTRACTOR_PREFIXES) }
.map { it.id }
toDelete.forEach { id ->
val res = APILayer.deleteContractor(id)
if (res !is ApiResult.Success) {
println("[SuiteZZ] Failed to delete contractor $id: $res")
}
}
println("[SuiteZZ] zz03 removed ${toDelete.size} test contractors")
}
@Test
fun zz04_cleanupTestResidences() = runBlocking {
ensureLoggedIn() ?: return@runBlocking
val residencesResult = APILayer.getResidences(forceRefresh = true)
if (residencesResult !is ApiResult.Success) return@runBlocking
// Skip residences we still need: keep one "Test House" so the next
// `AAA_SeedTests` run has something to build on if the seed step is
// skipped. Delete only extras beyond the first Test House match,
// plus every "Test Apt" residence.
val allTestResidences = residencesResult.data
.filter { it.name.matchesTestPrefix(RESIDENCE_PREFIXES) }
val firstTestHouseId = allTestResidences
.firstOrNull { it.name.startsWith("Test House") }
?.id
val toDelete = allTestResidences
.filter { it.id != firstTestHouseId }
.map { it.id }
toDelete.forEach { id ->
val res = APILayer.deleteResidence(id)
if (res !is ApiResult.Success) {
println("[SuiteZZ] Failed to delete residence $id: $res")
}
}
println("[SuiteZZ] zz04 removed ${toDelete.size} test residences (kept seed residence id=$firstTestHouseId)")
}
@Test
fun zz05_cleanupTestUsers() = runBlocking {
// We cannot list-all-users as a normal authenticated user and the
// KMM APILayer does not wrap any admin delete endpoint. Ephemeral
// registration users created by Suite1 (`uitest_<n>`) therefore
// cannot be removed from this client. The Go backend treats them
// as orphan accounts and expires them out of band.
//
// Step left as an idempotent no-op so the numbering matches the
// iOS suite and the method order stays stable.
println("[SuiteZZ] zz05 skipped — no client-side user-delete API")
}
@Test
fun zz99_verifyCleanState() = runBlocking {
ensureLoggedIn() ?: return@runBlocking
val tasksResult = APILayer.getTasks(forceRefresh = true)
if (tasksResult is ApiResult.Success) {
val leftover = tasksResult.data.columns
.flatMap { it.tasks }
.filter { it.title.matchesTestPrefix(TASK_PREFIXES) }
assertTrue(
"Expected no test-prefixed tasks after cleanup, found: ${leftover.map { it.title }}",
leftover.isEmpty(),
)
}
val docsResult = APILayer.getDocuments(forceRefresh = true)
if (docsResult is ApiResult.Success) {
val leftover = docsResult.data.filter { it.title.matchesTestPrefix(DOCUMENT_PREFIXES) }
assertTrue(
"Expected no test-prefixed documents after cleanup, found: ${leftover.map { it.title }}",
leftover.isEmpty(),
)
}
val contractorsResult = APILayer.getContractors(forceRefresh = true)
if (contractorsResult is ApiResult.Success) {
val leftover = contractorsResult.data.filter { it.name.matchesTestPrefix(CONTRACTOR_PREFIXES) }
assertTrue(
"Expected no test-prefixed contractors after cleanup, found: ${leftover.map { it.name }}",
leftover.isEmpty(),
)
}
}
// ---- Helpers ----
/**
* Logs in as the seeded test user so `DataManager.authToken` is
* populated, then returns the active token. Returns null if login
* cannot be established — in which case the cleanup step silently
* no-ops (the backend may already be unreachable).
*/
private suspend fun ensureLoggedIn(): String? {
DataManager.authToken.value?.let { return it }
val loginResult = APILayer.login(
LoginRequest(username = testUser.username, password = testUser.password),
)
return (loginResult as? ApiResult.Success)?.data?.token?.also {
// login() already writes the token into DataManager; return for
// direct use by callers that need the raw Bearer value.
}
}
private fun isDataManagerInitialized(): Boolean = try {
val field = DataManager::class.java.getDeclaredField("_isInitialized")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
val flow = field.get(DataManager) as kotlinx.coroutines.flow.MutableStateFlow<Boolean>
flow.value
} catch (e: Throwable) {
false
}
private fun String.matchesTestPrefix(prefixes: List<String>): Boolean =
prefixes.any { this.startsWith(it, ignoreCase = false) }
private companion object {
// Keep these in sync with the fixtures under
// `composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/fixtures/`.
val TASK_PREFIXES = listOf("Test Task", "Urgent Task", "UITest_", "test_")
val DOCUMENT_PREFIXES = listOf("test_", "UITest_", "Test Doc")
val CONTRACTOR_PREFIXES = listOf("test_", "UITest_", "Test Contractor")
val RESIDENCE_PREFIXES = listOf("Test House", "Test Apt", "UITest_", "test_")
}
}