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

@@ -41,5 +41,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
installCommonPlugins()
}
}

View File

@@ -28,6 +28,13 @@
<string name="auth_register_button">Create Account</string>
<string name="auth_have_account">Already have an account? Sign In</string>
<string name="auth_passwords_dont_match">Passwords don\'t match</string>
<string name="auth_password_requirement_length">At least 8 characters</string>
<string name="auth_password_requirement_uppercase">Contains an uppercase letter</string>
<string name="auth_password_requirement_lowercase">Contains a lowercase letter</string>
<string name="auth_password_requirement_digit">Contains a number</string>
<string name="auth_password_requirement_match">Passwords match</string>
<string name="auth_password_requirements_title">Password Requirements</string>
<string name="auth_password_complexity_error">Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit</string>
<!-- Auth - Verify Email -->
<string name="auth_verify_title">Verify Email</string>

View File

@@ -84,6 +84,14 @@ data class AuthResponse(
val user: User
)
/**
* Token refresh response - returned by POST /api/auth/refresh/
*/
@Serializable
data class TokenRefreshResponse(
val token: String
)
/**
* Auth response for registration - matching Go API RegisterResponse
*/

View File

@@ -1253,6 +1253,26 @@ object APILayer {
return result
}
/**
* Refresh the current auth token.
* Calls POST /api/auth/refresh/ with the current token.
* On success, saves the new token to DataManager and TokenStorage.
* On failure, returns an error (caller decides whether to trigger logout).
*/
suspend fun refreshToken(): ApiResult<String> {
val currentToken = getToken() ?: return ApiResult.Error("No token", 401)
val result = authApi.refreshToken(currentToken)
if (result is ApiResult.Success) {
DataManager.setAuthToken(result.data.token)
com.tt.honeyDue.storage.TokenStorage.saveToken(result.data.token)
}
return when (result) {
is ApiResult.Success -> ApiResult.Success(result.data.token)
is ApiResult.Error -> ApiResult.Error(result.message, result.code)
else -> ApiResult.Error("Unexpected state")
}
}
suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult<User> {
// Check DataManager first
if (!forceRefresh) {

View File

@@ -1,9 +1,20 @@
package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.TokenRefreshResponse
import com.tt.honeyDue.storage.TokenStorage
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.compression.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
expect fun getLocalhostAddress(): String
@@ -23,6 +34,142 @@ expect fun getDeviceLanguage(): String
*/
expect fun getDeviceTimezone(): String
/**
* Mutex to prevent multiple concurrent token refresh attempts.
* When one request triggers a 401, only one refresh call is made;
* other requests wait for it to complete.
*/
private val tokenRefreshMutex = Mutex()
/**
* Install common plugins shared across all platform HttpClient instances.
* This includes retry with exponential backoff, request timeouts,
* gzip content encoding, and 401 token refresh handling.
*/
fun HttpClientConfig<*>.installCommonPlugins() {
// Task 1: Gzip support — tells the server we accept compressed responses
install(ContentEncoding) {
gzip()
}
// Task 2: Retry with exponential backoff for server errors and IO exceptions
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
response.status.value in 500..599
}
retryOnExceptionIf { _, cause ->
// Retry on network-level IO errors (connection resets, timeouts, etc.)
// but not on application-level exceptions like serialization errors
cause is io.ktor.client.network.sockets.ConnectTimeoutException ||
cause is io.ktor.client.plugins.HttpRequestTimeoutException ||
cause.cause is kotlinx.io.IOException ||
cause is kotlinx.io.IOException
}
exponentialDelay(
base = 1000.0, // 1 second base
maxDelayMs = 10000 // 10 second max
)
}
// Task 4: Request timeout configuration
install(HttpTimeout) {
requestTimeoutMillis = 30_000 // 30 seconds
connectTimeoutMillis = 10_000 // 10 seconds
socketTimeoutMillis = 30_000 // 30 seconds
}
// Task 3: Token refresh on 401 responses
HttpResponseValidator {
validateResponse { response ->
if (response.status.value == 401) {
// Check if this is a token_expired error (not invalid credentials)
val bodyText = response.bodyAsText()
val isTokenExpired = bodyText.contains("token_expired") ||
bodyText.contains("Token has expired") ||
bodyText.contains("expired")
if (isTokenExpired) {
val currentToken = DataManager.authToken.value
if (currentToken != null) {
// Use mutex to prevent concurrent refresh attempts
val refreshed = tokenRefreshMutex.withLock {
// Double-check: another coroutine may have already refreshed
val tokenAfterLock = DataManager.authToken.value
if (tokenAfterLock != currentToken) {
// Token was already refreshed by another coroutine
true
} else {
attemptTokenRefresh(currentToken)
}
}
if (!refreshed) {
// Refresh failed — clear auth and trigger logout
DataManager.clear()
}
// Throw so the caller can retry (or handle the logout)
throw TokenExpiredException(refreshed)
}
}
}
}
}
}
/**
* Attempt to refresh the auth token by calling POST /api/auth/refresh/.
* Returns true if refresh succeeded and the new token was saved.
*/
private suspend fun attemptTokenRefresh(currentToken: String): Boolean {
return try {
// Use a minimal client to avoid recursive interceptor triggers
val refreshClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
}
val baseUrl = ApiConfig.getBaseUrl()
val response = refreshClient.post("$baseUrl/auth/refresh/") {
header("Authorization", "Token $currentToken")
contentType(ContentType.Application.Json)
}
refreshClient.close()
if (response.status.isSuccess()) {
val tokenResponse = response.body<TokenRefreshResponse>()
// Save the new token to both DataManager and persistent storage
DataManager.setAuthToken(tokenResponse.token)
TokenStorage.saveToken(tokenResponse.token)
println("[ApiClient] Token refreshed successfully")
true
} else {
println("[ApiClient] Token refresh failed: ${response.status.value}")
false
}
} catch (e: Exception) {
println("[ApiClient] Token refresh error: ${e.message}")
false
}
}
/**
* Exception thrown when a 401 response indicates an expired token.
* [refreshed] indicates whether the token was successfully refreshed.
* Callers can catch this and retry the request if refreshed is true.
*/
class TokenExpiredException(val refreshed: Boolean) : Exception(
if (refreshed) "Token was expired but has been refreshed — retry the request"
else "Token expired and refresh failed — user must re-authenticate"
)
object ApiClient {
val httpClient = createHttpClient()
@@ -41,9 +188,9 @@ object ApiClient {
* Print current environment configuration
*/
init {
println("🌐 API Client initialized")
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
println("🔗 Base URL: ${getBaseUrl()}")
println("📁 Media URL: ${getMediaBaseUrl()}")
println("API Client initialized")
println("Environment: ${ApiConfig.getEnvironmentName()}")
println("Base URL: ${getBaseUrl()}")
println("Media URL: ${getMediaBaseUrl()}")
}
}

View File

@@ -248,6 +248,25 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
// Token Refresh
suspend fun refreshToken(token: String): ApiResult<TokenRefreshResponse> {
return try {
val response = client.post("$baseUrl/auth/refresh/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Google Sign In
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try {

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tt.honeyDue.ui.components.HandleErrors
import com.tt.honeyDue.ui.components.auth.AuthHeader
import com.tt.honeyDue.ui.components.auth.RequirementItem
import com.tt.honeyDue.ui.components.common.ErrorCard
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.AuthViewModel
@@ -162,16 +163,58 @@ fun RegisterScreen(
}
}
// Password Requirements
if (password.isNotEmpty()) {
val hasMinLength = password.length >= 8
val hasUppercase = password.any { it.isUpperCase() }
val hasLowercase = password.any { it.isLowerCase() }
val hasDigit = password.any { it.isDigit() }
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(OrganicSpacing.md),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Text(
stringResource(Res.string.auth_password_requirements_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
}
}
}
ErrorCard(message = errorMessage)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
val passwordComplexityMessage = stringResource(Res.string.auth_password_complexity_error)
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
val hasMinLength = password.length >= 8
val hasUppercase = password.any { it.isUpperCase() }
val hasLowercase = password.any { it.isLowerCase() }
val hasDigit = password.any { it.isDigit() }
val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
OrganicPrimaryButton(
text = stringResource(Res.string.auth_register_button),
onClick = {
when {
password != confirmPassword -> {
!isPasswordComplex -> {
errorMessage = passwordComplexityMessage
}
!passwordsMatch -> {
errorMessage = passwordsDontMatchMessage
}
else -> {
@@ -183,7 +226,7 @@ fun RegisterScreen(
},
modifier = Modifier.fillMaxWidth(),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading,
password.isNotEmpty() && isPasswordComplex && passwordsMatch && !isLoading,
isLoading = isLoading
)

View File

@@ -54,10 +54,12 @@ fun ResetPasswordScreen(
val isSuccess = currentStep == com.tt.honeyDue.viewmodel.PasswordResetStep.SUCCESS
// Password validation
val hasLetter = newPassword.any { it.isLetter() }
val hasMinLength = newPassword.length >= 8
val hasUppercase = newPassword.any { it.isUpperCase() }
val hasLowercase = newPassword.any { it.isLowerCase() }
val hasNumber = newPassword.any { it.isDigit() }
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
val isFormValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch
Scaffold(
topBar = {
@@ -192,19 +194,23 @@ fun ResetPasswordScreen(
)
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
stringResource(Res.string.auth_password_requirement_length),
hasMinLength
)
RequirementItem(
"Contains letters",
hasLetter
stringResource(Res.string.auth_password_requirement_uppercase),
hasUppercase
)
RequirementItem(
"Contains numbers",
stringResource(Res.string.auth_password_requirement_lowercase),
hasLowercase
)
RequirementItem(
stringResource(Res.string.auth_password_requirement_digit),
hasNumber
)
RequirementItem(
"Passwords match",
stringResource(Res.string.auth_password_requirement_match),
passwordsMatch
)
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.ui.components.auth.RequirementItem
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import honeydue.composeapp.generated.resources.*
@@ -55,10 +56,16 @@ fun OnboardingCreateAccountContent(
}
val isLoading = registerState is ApiResult.Loading
val hasMinLength = password.length >= 8
val hasUppercase = password.any { it.isUpperCase() }
val hasLowercase = password.any { it.isLowerCase() }
val hasDigit = password.any { it.isDigit() }
val isPasswordComplex = hasMinLength && hasUppercase && hasLowercase && hasDigit
val passwordsMatch = password.isNotEmpty() && password == confirmPassword
val isFormValid = username.isNotBlank() &&
email.isNotBlank() &&
password.isNotBlank() &&
password == confirmPassword
isPasswordComplex &&
passwordsMatch
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
@@ -209,6 +216,25 @@ fun OnboardingCreateAccountContent(
} else null
)
// Password Requirements
if (password.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Text(
stringResource(Res.string.auth_password_requirements_title),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
RequirementItem(stringResource(Res.string.auth_password_requirement_length), hasMinLength)
RequirementItem(stringResource(Res.string.auth_password_requirement_uppercase), hasUppercase)
RequirementItem(stringResource(Res.string.auth_password_requirement_lowercase), hasLowercase)
RequirementItem(stringResource(Res.string.auth_password_requirement_digit), hasDigit)
RequirementItem(stringResource(Res.string.auth_password_requirement_match), passwordsMatch)
}
}
// Error message
if (localErrorMessage != null) {
OrganicCard(

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

View File

@@ -52,5 +52,8 @@ actual fun createHttpClient(): HttpClient {
setAllowsCellularAccess(true)
}
}
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
installCommonPlugins()
}
}

View File

@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
installCommonPlugins()
}
}

View File

@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
installCommonPlugins()
}
}

View File

@@ -39,5 +39,8 @@ actual fun createHttpClient(): HttpClient {
headers.append("Accept-Language", getDeviceLanguage())
headers.append("X-Timezone", getDeviceTimezone())
}
// Shared plugins: gzip, retry with backoff, timeouts, token refresh
installCommonPlugins()
}
}