Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite

Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation:

KMP Architecture:
- Fix subscription purchase/restore response contract (VerificationResponse aligned)
- Add feature benefits auth token + APILayer init flow
- Remove ResidenceFormScreen direct API bypass (use APILayer)
- Wire paywall purchase/restore to real SubscriptionApi calls

iOS Platform:
- Add iOS Keychain token storage via Swift KeychainHelper
- Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager)
- DocumentViewModelWrapper observes DataManager for auto-updates
- Add missing accessibility identifiers (document, task columns, Google Sign-In)

XCUITest Rewrite:
- Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups
- Create AuthCriticalPathTests and NavigationCriticalPathTests
- Delete 14 legacy brittle test files (Suite0-10, templates)
- Fix CaseraTests module import (@testable import Casera)

All platforms build clean. TEST BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 18:50:13 -06:00
parent 7444f73b46
commit 5e3596db77
47 changed files with 982 additions and 6075 deletions

View File

@@ -44,7 +44,6 @@ import com.example.casera.ui.screens.MainScreen
import com.example.casera.ui.screens.ManageUsersScreen
import com.example.casera.ui.screens.NotificationPreferencesScreen
import com.example.casera.ui.screens.ProfileScreen
import com.example.casera.ui.subscription.UpgradeScreen
import com.example.casera.ui.theme.MyCribTheme
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.navigation.*
@@ -59,6 +58,7 @@ import com.example.casera.network.AuthApi
import com.example.casera.data.DataManager
import com.example.casera.network.APILayer
import com.example.casera.platform.ContractorImportHandler
import com.example.casera.platform.PlatformUpgradeScreen
import com.example.casera.platform.ResidenceImportHandler
import casera.composeapp.generated.resources.Res
@@ -613,16 +613,12 @@ fun App(
}
composable<UpgradeRoute> {
UpgradeScreen(
PlatformUpgradeScreen(
onNavigateBack = {
navController.popBackStack()
},
onPurchase = { planId ->
// Handle purchase - integrate with billing system
navController.popBackStack()
},
onRestorePurchases = {
// Handle restore - integrate with billing system
onSubscriptionChanged = {
// Subscription state updated via DataManager
}
)
}

View File

@@ -63,9 +63,33 @@ data class PurchaseVerificationRequest(
@SerialName("product_id") val productId: String
)
/**
* Nested subscription info returned by backend purchase/restore endpoints.
*/
@Serializable
data class VerificationSubscriptionInfo(
val tier: String = "",
@SerialName("subscribed_at") val subscribedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("auto_renew") val autoRenew: Boolean = true,
@SerialName("cancelled_at") val cancelledAt: String? = null,
val platform: String = "",
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("is_pro") val isPro: Boolean = false
)
/**
* Response from backend purchase/restore endpoints.
* Backend returns: { "message": "...", "subscription": { "tier": "pro", ... } }
*/
@Serializable
data class VerificationResponse(
val success: Boolean,
val tier: String? = null,
val error: String? = null
)
val message: String = "",
val subscription: VerificationSubscriptionInfo? = null
) {
/** Backward-compatible: success when subscription is present */
val success: Boolean get() = subscription != null
/** Backward-compatible: tier extracted from nested subscription */
val tier: String? get() = subscription?.tier
}

View File

@@ -153,6 +153,16 @@ object APILayer {
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
}
// Load feature benefits (auth required)
println("🔄 Fetching feature benefits...")
val featureBenefitsResult = subscriptionApi.getFeatureBenefits(token)
if (featureBenefitsResult is ApiResult.Success) {
println("✅ Feature benefits loaded: ${featureBenefitsResult.data.size} features")
DataManager.setFeatureBenefits(featureBenefitsResult.data)
} else if (featureBenefitsResult is ApiResult.Error) {
println("❌ Failed to fetch feature benefits: ${featureBenefitsResult.message}")
}
// Load contractors if cache is empty or stale
if (!DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
println("🔄 Fetching contractors...")
@@ -1391,6 +1401,18 @@ object APILayer {
return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId)
}
/**
* Fetch feature benefits from backend (requires auth).
*/
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = subscriptionApi.getFeatureBenefits(token)
if (result is ApiResult.Success) {
DataManager.setFeatureBenefits(result.data)
}
return result
}
// ==================== Helper Methods ====================
/**

View File

@@ -42,9 +42,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
return try {
val response = client.get("$baseUrl/subscription/features/")
val response = client.get("$baseUrl/subscription/features/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())

View File

@@ -0,0 +1,15 @@
package com.example.casera.platform
import androidx.compose.runtime.Composable
/**
* Platform-specific upgrade screen composable.
* On Android, wires BillingManager for Google Play purchases.
* On iOS, purchase flow is handled in Swift via StoreKitManager.
* On other platforms, shows the common UpgradeScreen with backend-only restore.
*/
@Composable
expect fun PlatformUpgradeScreen(
onNavigateBack: () -> Unit,
onSubscriptionChanged: () -> Unit
)

View File

@@ -15,8 +15,8 @@ import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.platform.PlatformUpgradeScreen
import com.example.casera.storage.TokenStorage
import com.example.casera.ui.subscription.UpgradeScreen
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import kotlinx.serialization.json.Json
@@ -315,16 +315,12 @@ fun MainScreen(
composable<UpgradeRoute> {
Box(modifier = Modifier.fillMaxSize()) {
UpgradeScreen(
PlatformUpgradeScreen(
onNavigateBack = {
navController.popBackStack()
},
onPurchase = { planId ->
// Handle purchase - integrate with billing system
navController.popBackStack()
},
onRestorePurchases = {
// Handle restore - integrate with billing system
onSubscriptionChanged = {
// Subscription state updated via DataManager
}
)
}

View File

@@ -22,8 +22,7 @@ import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceType
import com.example.casera.models.ResidenceUser
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import com.example.casera.network.APILayer
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
@@ -78,25 +77,21 @@ fun ResidenceFormScreen(
var userToRemove by remember { mutableStateOf<ResidenceUser?>(null) }
var showRemoveUserConfirmation by remember { mutableStateOf(false) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
// Load users when in edit mode and user is owner
LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) {
if (isEditMode && isCurrentUserOwner && existingResidence != null) {
isLoadingUsers = true
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.getResidenceUsers(token, existingResidence.id)) {
is ApiResult.Success -> {
// Filter out the owner from the list
users = result.data.filter { it.id != existingResidence.ownerId }
}
is ApiResult.Error -> {
// Silently fail - users list will be empty
}
else -> {}
when (val result = APILayer.getResidenceUsers(existingResidence.id)) {
is ApiResult.Success -> {
// Filter out the owner from the list
users = result.data.filter { it.id != existingResidence.ownerId }
}
is ApiResult.Error -> {
// Silently fail - users list will be empty
}
else -> {}
}
isLoadingUsers = false
}
@@ -460,9 +455,8 @@ fun ResidenceFormScreen(
onClick = {
userToRemove?.let { user ->
scope.launch {
val token = TokenStorage.getToken()
if (token != null && existingResidence != null) {
when (residenceApi.removeUser(token, existingResidence.id, user.id)) {
if (existingResidence != null) {
when (APILayer.removeUser(existingResidence.id, user.id)) {
is ApiResult.Success -> {
users = users.filter { it.id != user.id }
}