From af310e0dbc3e0b382ca6c527e1739d9539934867 Mon Sep 17 00:00:00 2001 From: AlperKartkaya Date: Tue, 12 May 2026 19:13:05 +0300 Subject: [PATCH] fix: fix help request edit form hydration --- .../e2e/RequestHelpRepositoryAndroidTest.kt | 86 ++++++++++ .../presentation/RequestHelpScreen.kt | 154 ++++++++++++++++-- .../RequestHelpFormStateMappingTest.kt | 98 +++++++++++ 3 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 android/app/src/androidTest/java/com/neph/e2e/RequestHelpRepositoryAndroidTest.kt create mode 100644 android/app/src/test/java/com/neph/features/requesthelp/presentation/RequestHelpFormStateMappingTest.kt diff --git a/android/app/src/androidTest/java/com/neph/e2e/RequestHelpRepositoryAndroidTest.kt b/android/app/src/androidTest/java/com/neph/e2e/RequestHelpRepositoryAndroidTest.kt new file mode 100644 index 00000000..307f9c8b --- /dev/null +++ b/android/app/src/androidTest/java/com/neph/e2e/RequestHelpRepositoryAndroidTest.kt @@ -0,0 +1,86 @@ +package com.neph.e2e + +import androidx.test.platform.app.InstrumentationRegistry +import com.neph.features.requesthelp.data.RequestHelpContactSubmission +import com.neph.features.requesthelp.data.RequestHelpLocationSubmission +import com.neph.features.requesthelp.data.RequestHelpRepository +import com.neph.features.requesthelp.data.RequestHelpSubmission +import com.neph.features.requesthelp.data.jsonArrayToStringList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test + +class RequestHelpRepositoryAndroidTest { + private val fakeBackend = FakeNephBackend() + private val environmentRule = NephE2ETestEnvironmentRule(fakeBackend) { context, _ -> + RequestHelpRepository.initialize(context) + } + + @get:Rule + val rule = environmentRule + + @Test + fun updateHelpRequestPersistsEditedFieldsInLocalDatabase() = runBlocking { + RequestHelpRepository.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + val created = RequestHelpRepository.createHelpRequest( + token = "access-token-1", + submission = sampleSubmission( + helpTypes = listOf("search_rescue"), + description = "Need rescue near the building.", + district = "Kadıköy", + phone = 5551112233L + ) + ) + + RequestHelpRepository.updateHelpRequest( + token = "access-token-1", + localId = created.requestId, + submission = sampleSubmission( + helpTypes = listOf("shelter"), + description = "Need shelter after moving locations.", + district = "Beşiktaş", + phone = 5552223344L + ), + preserveExistingCoordinates = false + ) + + val edited = RequestHelpRepository.getLocalHelpRequest(created.requestId) + assertNotNull(edited) + requireNotNull(edited) + assertEquals(listOf("shelter"), edited.helpTypesJson.jsonArrayToStringList()) + assertEquals("Need shelter after moving locations.", edited.description) + assertEquals("Beşiktaş", edited.district) + assertEquals("5552223344", edited.contactPhone) + } + + private fun sampleSubmission( + helpTypes: List, + description: String, + district: String, + phone: Long + ): RequestHelpSubmission { + return RequestHelpSubmission( + helpTypes = helpTypes, + otherHelpText = "", + affectedPeopleCount = 2, + description = description, + riskFlags = listOf("Fire"), + vulnerableGroups = emptyList(), + shareProfileHealthInfoWithVolunteer = true, + location = RequestHelpLocationSubmission( + country = "Turkey", + city = "Istanbul", + district = district, + neighborhood = "Bostancı", + extraAddress = "Existing Street 5" + ), + contact = RequestHelpContactSubmission( + fullName = "Alex Helper", + phone = phone + ), + consentGiven = true + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt index 050de41e..fc532de0 100644 --- a/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt +++ b/android/app/src/main/java/com/neph/features/requesthelp/presentation/RequestHelpScreen.kt @@ -124,7 +124,7 @@ private val vulnerableGroupOptions = listOf( private const val RequestHelpGpsCoordinateSource = "gps" private const val RequestHelpMapCoordinateSource = "map_selection" -private data class RequestHelpFormState( +internal data class RequestHelpFormState( val helpTypes: List = emptyList(), val affectedPeopleCount: String = "", val riskFlags: List = emptyList(), @@ -220,19 +220,35 @@ private fun RequestHelpFormState.toMobileOnboardingPracticeHelpRequest(): Mobile ) } -private fun HelpRequestEntity.toFormState(): RequestHelpFormState { - val phoneParts = normalizePhoneParts(contactPhone) +internal fun HelpRequestEntity.toFormState( + locations: LocationData = locationData +): RequestHelpFormState { + val phoneParts = normalizeStoredRequestPhoneParts(contactPhone) + val alternativePhoneParts = normalizeStoredRequestPhoneParts(contactAlternativePhone) + val countryValue = resolveCountryFormValue(country, locations) + val cityValue = resolveCityFormValue(countryValue, city, locations) + val districtValue = resolveDistrictFormValue(countryValue, cityValue, district, locations) + val neighborhoodValue = resolveNeighborhoodFormValue( + countryValue = countryValue, + cityValue = cityValue, + districtValue = districtValue, + neighborhood = neighborhood, + locations = locations + ) return RequestHelpFormState( - helpTypes = helpTypesJson.jsonArrayToStringList().mapNotNull { helpTypeLabelsByApiValue[it] }, + helpTypes = helpTypesJson.jsonArrayToStringList() + .mapNotNull(::persistedHelpTypeToOption), affectedPeopleCount = affectedPeopleCount.toString(), - riskFlags = riskFlagsJson.jsonArrayToStringList(), - vulnerableGroups = vulnerableGroupsJson.jsonArrayToStringList(), + riskFlags = riskFlagsJson.jsonArrayToStringList() + .map { persistedOptionToLabel(it, riskFlagOptions) }, + vulnerableGroups = vulnerableGroupsJson.jsonArrayToStringList() + .map { persistedOptionToLabel(it, vulnerableGroupOptions) }, situationDescription = description, shareProfileHealthInfoWithVolunteer = shareProfileHealthInfoWithVolunteer, - country = country, - city = city, - district = district, - neighborhood = neighborhood, + country = countryValue, + city = cityValue, + district = districtValue, + neighborhood = neighborhoodValue, shortAddress = extraAddress, latitude = latitude, longitude = longitude, @@ -243,11 +259,120 @@ private fun HelpRequestEntity.toFormState(): RequestHelpFormState { fullName = contactFullName, countryCode = phoneParts.countryCode, phoneNumber = phoneParts.phone, - alternativePhone = contactAlternativePhone.orEmpty(), + alternativePhone = alternativePhoneParts.phone, confirmationAccepted = true ) } +private fun persistedHelpTypeToOption(value: String): String? { + val raw = value.trim() + if (raw.isBlank()) return null + helpTypeLabelsByApiValue[raw.lowercase(Locale.ROOT)]?.let { return it } + return persistedOptionToLabel(raw, helpTypeOptions) +} + +private fun persistedOptionToLabel(value: String, options: List): String { + val raw = value.trim() + if (raw.isBlank()) return raw + + options.firstOrNull { it.equals(raw, ignoreCase = true) }?.let { return it } + val normalizedRaw = normalizePersistedOption(raw) + return options.firstOrNull { normalizePersistedOption(it) == normalizedRaw } + ?: raw +} + +private fun normalizePersistedOption(value: String): String { + return value + .trim() + .lowercase(Locale.ROOT) + .replace("&", "") + .replace(Regex("[^a-z0-9]+"), "") +} + +private fun normalizeStoredRequestPhoneParts(phoneNumber: String?): PhoneParts { + val raw = phoneNumber?.trim().orEmpty() + val digits = raw.filter(Char::isDigit) + if (!raw.startsWith("+") && digits.length == 12 && digits.startsWith("90")) { + return PhoneParts(countryCode = "+90", phone = digits.removePrefix("90").trimStart('0')) + } + return normalizePhoneParts(phoneNumber) +} + +private fun resolveCountryFormValue(country: String, locations: LocationData): String { + val raw = country.trim() + if (raw.isBlank()) return "" + + if (locations.containsKey(raw)) return raw + val normalizedKey = raw.lowercase(Locale.ROOT) + if (locations.containsKey(normalizedKey)) return normalizedKey + + return findCountryKeyByLabel(raw, locations).ifBlank { raw } +} + +private fun resolveCityFormValue( + countryValue: String, + city: String, + locations: LocationData +): String { + val raw = city.trim() + if (countryValue.isBlank() || raw.isBlank()) return raw + + val cities = locations[countryValue]?.cities ?: return raw + if (cities.containsKey(raw)) return raw + val normalizedKey = raw.lowercase(Locale.ROOT) + if (cities.containsKey(normalizedKey)) return normalizedKey + + return findCityKeyByLabel(countryValue, raw, locations).ifBlank { raw } +} + +private fun resolveDistrictFormValue( + countryValue: String, + cityValue: String, + district: String, + locations: LocationData +): String { + val raw = district.trim() + if (countryValue.isBlank() || cityValue.isBlank() || raw.isBlank()) return raw + + val districts = locations[countryValue]?.cities?.get(cityValue)?.districts ?: return raw + if (districts.containsKey(raw)) return raw + val normalizedKey = raw.lowercase(Locale.ROOT) + if (districts.containsKey(normalizedKey)) return normalizedKey + + return findDistrictKeyByLabel(countryValue, cityValue, raw, locations).ifBlank { raw } +} + +private fun resolveNeighborhoodFormValue( + countryValue: String, + cityValue: String, + districtValue: String, + neighborhood: String, + locations: LocationData +): String { + val raw = neighborhood.trim() + if (countryValue.isBlank() || cityValue.isBlank() || districtValue.isBlank() || raw.isBlank()) { + return raw + } + + val neighborhoods = locations[countryValue] + ?.cities + ?.get(cityValue) + ?.districts + ?.get(districtValue) + ?.neighborhoods + ?: return raw + + neighborhoods.firstOrNull { it.value.equals(raw, ignoreCase = true) }?.let { return it.value } + + return findNeighborhoodValueByLabel( + countryKey = countryValue, + cityKey = cityValue, + districtKey = districtValue, + label = raw, + locations = locations + ).ifBlank { raw } +} + private fun toggleSelection(current: List, option: String): List { return if (option in current) { current - option @@ -843,7 +968,7 @@ fun RequestHelpScreen( } } - LaunchedEffect(sessionToken, activeDraftLocalId) { + LaunchedEffect(sessionToken, activeDraftLocalId, locationLoading) { val existingDraft = activeDraftLocalId.takeIf { it.isNotBlank() }?.let { localId -> RequestHelpRepository.getLocalHelpRequest(localId) } @@ -852,7 +977,10 @@ fun RequestHelpScreen( onNavigateToLogin() return@LaunchedEffect } - formState = existingDraft.toFormState() + if (locationLoading) { + return@LaunchedEffect + } + formState = existingDraft.toFormState(availableLocationData) infoMessage = "" checkingActiveRequest = false return@LaunchedEffect diff --git a/android/app/src/test/java/com/neph/features/requesthelp/presentation/RequestHelpFormStateMappingTest.kt b/android/app/src/test/java/com/neph/features/requesthelp/presentation/RequestHelpFormStateMappingTest.kt new file mode 100644 index 00000000..c08b8223 --- /dev/null +++ b/android/app/src/test/java/com/neph/features/requesthelp/presentation/RequestHelpFormStateMappingTest.kt @@ -0,0 +1,98 @@ +package com.neph.features.requesthelp.presentation + +import com.neph.core.database.HelpRequestEntity +import com.neph.core.sync.LocalOwnerType +import com.neph.core.sync.SyncStatus +import com.neph.features.profile.data.locationData +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Test + +class RequestHelpFormStateMappingTest { + @Test + fun editFormHydratesPersistedLabelsIntoSelectableFormValues() { + val formState = helpRequestEntity( + helpTypes = listOf("search_rescue", "Food & Water"), + riskFlags = listOf("gas_leak", "Blocked Access / Debris"), + vulnerableGroups = listOf("elderly", "Chronic Condition"), + country = "Turkey", + city = "Istanbul", + district = "Kadıköy", + neighborhood = "Bostancı", + contactPhone = "905551112233", + contactAlternativePhone = "+905551112244" + ).toFormState(locationData) + + assertEquals(listOf("Search & Rescue", "Food & Water"), formState.helpTypes) + assertEquals(listOf("Gas Leak", "Blocked Access / Debris"), formState.riskFlags) + assertEquals(listOf("Elderly", "Chronic Condition"), formState.vulnerableGroups) + assertEquals("tr", formState.country) + assertEquals("istanbul", formState.city) + assertEquals("kadikoy", formState.district) + assertEquals("bostanci", formState.neighborhood) + assertEquals("+90", formState.countryCode) + assertEquals("5551112233", formState.phoneNumber) + assertEquals("5551112244", formState.alternativePhone) + assertEquals(true, formState.confirmationAccepted) + } + + @Test + fun editFormPreservesUnknownRemoteLocationValuesInsteadOfBlankingThem() { + val formState = helpRequestEntity( + country = "Atlantis", + city = "Poseidon", + district = "Coral", + neighborhood = "Reef" + ).toFormState(locationData) + + assertEquals("Atlantis", formState.country) + assertEquals("Poseidon", formState.city) + assertEquals("Coral", formState.district) + assertEquals("Reef", formState.neighborhood) + } + + private fun helpRequestEntity( + helpTypes: List = listOf("first_aid"), + riskFlags: List = emptyList(), + vulnerableGroups: List = emptyList(), + country: String = "Turkey", + city: String = "Istanbul", + district: String = "Kadıköy", + neighborhood: String = "Bostancı", + contactPhone: String = "5551112233", + contactAlternativePhone: String? = null + ): HelpRequestEntity { + return HelpRequestEntity( + localId = "local-edit-test", + remoteId = "remote-edit-test", + ownerType = LocalOwnerType.AUTHENTICATED, + guestAccessToken = null, + helpTypesJson = JSONArray(helpTypes).toString(), + otherHelpText = "", + affectedPeopleCount = 2, + riskFlagsJson = JSONArray(riskFlags).toString(), + vulnerableGroupsJson = JSONArray(vulnerableGroups).toString(), + description = "Need help after structural damage.", + bloodType = "", + shareProfileHealthInfoWithVolunteer = true, + country = country, + city = city, + district = district, + neighborhood = neighborhood, + extraAddress = "Existing Street 5", + contactFullName = "Alex Helper", + contactPhone = contactPhone, + contactAlternativePhone = contactAlternativePhone, + status = "PENDING_SYNC", + helperFirstName = null, + helperLastName = null, + helperPhone = null, + helperProfession = null, + helperExpertise = null, + helpersJson = JSONArray().toString(), + syncStatus = SyncStatus.PENDING_UPDATE, + createdAtEpochMillis = 1_700_000_000_000L, + updatedAtEpochMillis = 1_700_000_000_000L + ) + } +}