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:
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user