From d42406cbeca035d20c5d8f9cebcb923941fe58f9 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 14:47:41 -0500 Subject: [PATCH] UI Test SuiteZZ: Cleanup tests (iOS parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/tt/honeyDue/SuiteZZ_CleanupTests.kt | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/SuiteZZ_CleanupTests.kt diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/SuiteZZ_CleanupTests.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/SuiteZZ_CleanupTests.kt new file mode 100644 index 0000000..0604e0b --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/tt/honeyDue/SuiteZZ_CleanupTests.kt @@ -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() + 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_`) 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 + flow.value + } catch (e: Throwable) { + false + } + + private fun String.matchesTestPrefix(prefixes: List): 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_") + } +}