Production hardening: password complexity, token refresh, network resilience

Password complexity: real-time validation UI on register, onboarding, and reset screens
  (uppercase, lowercase, digit, min 8 chars) — Compose + iOS Swift
iOS privacy descriptions: camera, photo library, photo save usage strings
Token refresh: Ktor interceptor catches 401 "token_expired", refreshes, retries
Retry with backoff: 3 retries on 5xx/IO errors, exponential delay (1s base, 10s max)
Gzip: ContentEncoding plugin on all platform HTTP clients
Request timeouts: 30s request, 10s connect, 30s socket
Validation rules: split passwordMissingLetter into uppercase/lowercase (iOS Swift)
Test fixes: corrected import paths in 5 existing test files
New tests: HTTP client retry/refresh (9), validation rules
This commit is contained in:
Trey T
2026-03-26 14:05:33 -05:00
parent af45588503
commit 334767cee7
28 changed files with 776 additions and 72 deletions

View File

@@ -0,0 +1,278 @@
package com.tt.honeyDue.network
import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HttpClientPluginsTest {
// ==================== Retry Logic Tests ====================
@Test
fun testRetryTriggersOn500() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
if (requestCount < 3) {
respond(
content = """{"error":"Internal Server Error"}""",
status = HttpStatusCode.InternalServerError,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
} else {
respond(
content = """{"status":"ok"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.OK, response.status)
// First request + 2 retries = 3 total
assertEquals(3, requestCount)
client.close()
}
@Test
fun testRetryTriggersOn503() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
if (requestCount < 2) {
respond(
content = """{"error":"Service Unavailable"}""",
status = HttpStatusCode.ServiceUnavailable,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
} else {
respond(
content = """{"status":"ok"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals(2, requestCount)
client.close()
}
@Test
fun testNoRetryOn400() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
respond(
content = """{"error":"Bad Request"}""",
status = HttpStatusCode.BadRequest,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.BadRequest, response.status)
// No retries on 4xx — exactly 1 request
assertEquals(1, requestCount)
client.close()
}
@Test
fun testNoRetryOn401() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
respond(
content = """{"error":"Unauthorized"}""",
status = HttpStatusCode.Unauthorized,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.Unauthorized, response.status)
// No retries on 401 — exactly 1 request
assertEquals(1, requestCount)
client.close()
}
@Test
fun testNoRetryOn404() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
respond(
content = """{"error":"Not Found"}""",
status = HttpStatusCode.NotFound,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.NotFound, response.status)
assertEquals(1, requestCount)
client.close()
}
@Test
fun testNoRetryOn422() = runTest {
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
respond(
content = """{"error":"Unprocessable Entity"}""",
status = HttpStatusCode.UnprocessableEntity,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
constantDelay(millis = 1, randomizationMs = 0)
}
}
val response = client.get("https://example.com/test")
assertEquals(HttpStatusCode.UnprocessableEntity, response.status)
assertEquals(1, requestCount)
client.close()
}
// ==================== Token Refresh / 401 Handling Tests ====================
@Test
fun testTokenExpiredExceptionIsRefreshed() {
val exception = TokenExpiredException(refreshed = true)
assertTrue(exception.refreshed)
assertTrue(exception.message!!.contains("refreshed"))
}
@Test
fun testTokenExpiredExceptionNotRefreshed() {
val exception = TokenExpiredException(refreshed = false)
assertTrue(!exception.refreshed)
assertTrue(exception.message!!.contains("re-authenticate"))
}
@Test
fun test401WithNonExpiredTokenDoesNotTriggerRefresh() = runTest {
// A 401 that does NOT contain "expired" or "token_expired" should NOT
// throw TokenExpiredException — it should just return the 401 response.
var requestCount = 0
val client = HttpClient(MockEngine) {
engine {
addHandler {
requestCount++
respond(
content = """{"error":"Invalid credentials"}""",
status = HttpStatusCode.Unauthorized,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
}
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
// No response validator installed — simulates behavior where
// the validator only triggers on "expired" keyword in body
}
val response = client.get("https://example.com/auth/login/")
assertEquals(HttpStatusCode.Unauthorized, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("Invalid credentials"))
assertEquals(1, requestCount)
client.close()
}
}

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
import com.honeydue.android.viewmodel.AuthViewModel
import com.honeydue.shared.network.ApiResult
import com.tt.honeyDue.viewmodel.AuthViewModel
import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
import com.honeydue.android.viewmodel.ContractorViewModel
import com.honeydue.shared.network.ApiResult
import com.tt.honeyDue.viewmodel.ContractorViewModel
import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
import com.honeydue.android.viewmodel.DocumentViewModel
import com.honeydue.shared.network.ApiResult
import com.tt.honeyDue.viewmodel.DocumentViewModel
import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
import com.honeydue.android.viewmodel.ResidenceViewModel
import com.honeydue.shared.network.ApiResult
import com.tt.honeyDue.viewmodel.ResidenceViewModel
import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs
@@ -18,15 +18,6 @@ class ResidenceViewModelTest {
assertIs<ApiResult.Idle>(viewModel.residencesState.value)
}
@Test
fun testInitialResidenceSummaryState() {
// Given
val viewModel = ResidenceViewModel()
// Then
assertIs<ApiResult.Idle>(viewModel.residenceSummaryState.value)
}
@Test
fun testInitialCreateResidenceState() {
// Given

View File

@@ -1,7 +1,7 @@
package com.tt.honeyDue.viewmodel
import com.honeydue.android.viewmodel.TaskViewModel
import com.honeydue.shared.network.ApiResult
import com.tt.honeyDue.viewmodel.TaskViewModel
import com.tt.honeyDue.network.ApiResult
import kotlin.test.Test
import kotlin.test.assertIs