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:
@@ -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_")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user