param( [string]$SupabaseExe = 'C:\Users\encep\bin\supabase.exe', [string]$ProjectUrl = 'http://127.0.0.1:55321' ) $ErrorActionPreference = 'Stop' $supabaseProjectDirectory = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $createdUserIds = [System.Collections.Generic.List[string]]::new() $createdAdRewardTransactionIds = [System.Collections.Generic.List[string]]::new() $passCount = 0 function Assert-Equal { param( [string]$Name, [object]$Actual, [object]$Expected ) if ($Actual -ne $Expected) { throw "FAIL $Name expected=[$Expected] actual=[$Actual]" } $script:passCount += 1 Write-Host "PASS $Name" } function Assert-True { param( [string]$Name, [bool]$Condition ) if (-not $Condition) { throw "FAIL $Name" } $script:passCount += 1 Write-Host "PASS $Name" } if (-not (Test-Path -LiteralPath $SupabaseExe)) { throw "Supabase CLI was not found at $SupabaseExe" } $statusEnv = & $SupabaseExe status --workdir $supabaseProjectDirectory -o env 2>$null if ($LASTEXITCODE -ne 0) { throw 'D3RO local Supabase stack is not running.' } function Get-StatusValue { param([string]$Name) $line = $statusEnv | Where-Object { $_ -like ($Name + '=*') } if (-not $line) { throw "Supabase status did not provide $Name" } return (($line -split '=', 2)[1]).Trim('"') } $anonKey = Get-StatusValue 'ANON_KEY' $serviceRoleKey = Get-StatusValue 'SERVICE_ROLE_KEY' $testPassword = 'D3ro-Mobile-Platform-E2E-2026!' function Invoke-JsonRequest { param( [ValidateSet('Get', 'Post', 'Patch', 'Delete')] [string]$Method, [string]$Path, [string]$ApiKey, [string]$Bearer, [object]$Body, [hashtable]$AdditionalHeaders = @{} ) $headers = @{ apikey = $ApiKey Authorization = "Bearer $Bearer" } foreach ($header in $AdditionalHeaders.GetEnumerator()) { $headers[$header.Key] = $header.Value } $parameters = @{ Uri = $ProjectUrl + $Path Method = $Method Headers = $headers TimeoutSec = 30 SkipHttpErrorCheck = $true } if ($null -ne $Body) { $parameters.ContentType = 'application/json' $parameters.Body = $Body | ConvertTo-Json -Depth 8 } return Invoke-WebRequest @parameters } function Convert-ResponseJson { param([Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]$Response) if ([string]::IsNullOrWhiteSpace($Response.Content)) { return $null } return $Response.Content | ConvertFrom-Json } function Get-Sha256Hex { param([string]$Value) $algorithm = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) return [Convert]::ToHexString($algorithm.ComputeHash($bytes)).ToLowerInvariant() } finally { $algorithm.Dispose() } } function New-TestUser { param([string]$Label) $email = "mobile-platform-$Label-$([guid]::NewGuid().ToString('N'))@example.test" $response = Invoke-JsonRequest ` -Method Post ` -Path '/auth/v1/signup' ` -ApiKey $anonKey ` -Bearer $anonKey ` -Body @{ email = $email; password = $testPassword; data = @{ name = "Mobile $Label" } } Assert-Equal "signup-$Label-status" ([int]$response.StatusCode) 200 $body = Convert-ResponseJson $response Assert-True "signup-$Label-session" (-not [string]::IsNullOrWhiteSpace($body.access_token)) $createdUserIds.Add([string]$body.user.id) return $body } function Remove-TestUser { param([string]$UserId) $response = Invoke-JsonRequest ` -Method Delete ` -Path "/auth/v1/admin/users/$UserId" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null if ([int]$response.StatusCode -notin @(200, 404)) { Write-Warning "Fixture cleanup failed for $UserId with status $($response.StatusCode)" } } function Remove-TestAdRewardReceiptByTransaction { param([string]$TransactionId) $response = Invoke-JsonRequest ` -Method Delete ` -Path "/rest/v1/ad_reward_receipts?transaction_id=eq.$TransactionId" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null if ([int]$response.StatusCode -notin @(200, 204)) { Write-Warning "Ad reward receipt fixture cleanup failed for transaction $TransactionId with status $($response.StatusCode)" } } try { $userOne = New-TestUser 'owner' $userTwo = New-TestUser 'other' $unsignedAdCallback = Invoke-JsonRequest ` -Method Get ` -Path '/functions/v1/admob-ssv?reward_amount=50' ` -ApiKey $anonKey ` -Bearer $anonKey ` -Body $null # Invalid signed/unsigned callbacks are acknowledged with 200 so AdMob does # not retry fraudulent input; no reward RPC is reached on this path. Assert-Equal 'admob-unsigned-callback-acknowledged-without-grant' ([int]$unsignedAdCallback.StatusCode) 200 Assert-True 'admob-unsigned-callback-error' $unsignedAdCallback.Content.Contains('missing_signature') $iapRequestBody = @{ platform = 'google_play' productId = 'd3ro_voice_pro_monthly' purchaseToken = 'unverified-fixture-purchase-token' } $iapUnauthenticated = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/iap-verify' ` -ApiKey $anonKey ` -Bearer $anonKey ` -Body $iapRequestBody Assert-Equal 'iap-edge-unauthenticated-denied' ([int]$iapUnauthenticated.StatusCode) 401 $unknownIapBody = $iapRequestBody.Clone() $unknownIapBody.productId = 'unknown_product' $iapUnknownProduct = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/iap-verify' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $unknownIapBody Assert-Equal 'iap-edge-unknown-product-denied' ([int]$iapUnknownProduct.StatusCode) 400 $iapUnconfigured = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/iap-verify' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $iapRequestBody Assert-Equal 'iap-edge-unconfigured-fails-closed' ([int]$iapUnconfigured.StatusCode) 503 Assert-True 'iap-edge-unconfigured-error' $iapUnconfigured.Content.Contains('google_play_not_configured') $rtdnUnconfigured = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/google-play-rtdn' ` -ApiKey $anonKey ` -Bearer $anonKey ` -Body @{ message = @{ messageId = 'fixture'; data = 'e30=' } } Assert-Equal 'rtdn-edge-unconfigured-fails-closed' ([int]$rtdnUnconfigured.StatusCode) 503 Assert-True 'rtdn-edge-unconfigured-error' $rtdnUnconfigured.Content.Contains('google_pubsub_not_configured') $settingsInsert = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/user_settings' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body @{ user_id = $userOne.user.id theme_mode = 'dark' locale = 'ko' onboarding_version = 1 } ` -AdditionalHeaders @{ Prefer = 'return=minimal' } Assert-Equal 'settings-owner-insert' ([int]$settingsInsert.StatusCode) 201 $crossSettings = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/user_settings?select=user_id&user_id=eq.$($userOne.user.id)" ` -ApiKey $anonKey ` -Bearer $userTwo.access_token ` -Body $null Assert-Equal 'settings-cross-user-read-status' ([int]$crossSettings.StatusCode) 200 Assert-Equal 'settings-cross-user-read-empty' @((Convert-ResponseJson $crossSettings)).Count 0 $forgedSettings = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/user_settings' ` -ApiKey $anonKey ` -Bearer $userTwo.access_token ` -Body @{ user_id = $userOne.user.id; theme_mode = 'light'; locale = 'en' } Assert-Equal 'settings-cross-user-insert-denied' ([int]$forgedSettings.StatusCode) 403 $dictionaryInsert = Invoke-JsonRequest -Method Post -Path '/rest/v1/dictionary?select=id,word,category' -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ user_id = $userOne.user.id; word = ' D3RO Voice '; pronunciation = '디쓰리로 보이스'; category = 'technical' } -AdditionalHeaders @{ Prefer = 'return=representation' } Assert-Equal 'dictionary-normalized-insert-status' ([int]$dictionaryInsert.StatusCode) 201 $dictionaryRow = @((Convert-ResponseJson $dictionaryInsert))[0] Assert-Equal 'dictionary-normalized-word' $dictionaryRow.word 'D3RO Voice' $dictionaryDuplicate = Invoke-JsonRequest -Method Post -Path '/rest/v1/dictionary' -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ user_id = $userOne.user.id; word = 'd3ro voice'; category = 'technical' } Assert-Equal 'dictionary-case-duplicate-denied' ([int]$dictionaryDuplicate.StatusCode) 409 $bootstrapCommands = Invoke-JsonRequest -Method Post -Path '/rest/v1/rpc/bootstrap_custom_instructions' -ApiKey $anonKey -Bearer $userOne.access_token -Body @{} Assert-Equal 'commands-bootstrap-owner-status' ([int]$bootstrapCommands.StatusCode) 200 $ownerBuiltins = @((Convert-ResponseJson $bootstrapCommands)) Assert-Equal 'commands-bootstrap-owner-count' $ownerBuiltins.Count 4 $bootstrapOtherCommands = Invoke-JsonRequest -Method Post -Path '/rest/v1/rpc/bootstrap_custom_instructions' -ApiKey $anonKey -Bearer $userTwo.access_token -Body @{} Assert-Equal 'commands-bootstrap-other-status' ([int]$bootstrapOtherCommands.StatusCode) 200 Assert-Equal 'commands-bootstrap-other-count' @((Convert-ResponseJson $bootstrapOtherCommands)).Count 4 $crossCommands = Invoke-JsonRequest -Method Get -Path "/rest/v1/custom_instructions?select=id&user_id=eq.$($userOne.user.id)" -ApiKey $anonKey -Bearer $userTwo.access_token -Body $null Assert-Equal 'commands-cross-user-read-empty' @((Convert-ResponseJson $crossCommands)).Count 0 $customCommandInsert = Invoke-JsonRequest -Method Post -Path '/rest/v1/custom_instructions?select=id,name,revision' -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ user_id = $userOne.user.id; name = 'Release summary'; description = 'Summarize a release'; prompt = 'Summarize this release without inventing facts.'; sort_order = 100 } -AdditionalHeaders @{ Prefer = 'return=representation' } Assert-Equal 'commands-custom-insert-status' ([int]$customCommandInsert.StatusCode) 201 $customCommand = @((Convert-ResponseJson $customCommandInsert))[0] $customCommandUpdate = Invoke-JsonRequest -Method Patch -Path "/rest/v1/custom_instructions?id=eq.$($customCommand.id)&revision=eq.1&select=id,revision" -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ name = 'Release notes summary' } -AdditionalHeaders @{ Prefer = 'return=representation' } Assert-Equal 'commands-custom-update-revision' @((Convert-ResponseJson $customCommandUpdate))[0].revision 2 $staleCommandUpdate = Invoke-JsonRequest -Method Patch -Path "/rest/v1/custom_instructions?id=eq.$($customCommand.id)&revision=eq.1&select=id" -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ description = 'stale write' } -AdditionalHeaders @{ Prefer = 'return=representation' } Assert-Equal 'commands-custom-stale-empty' @((Convert-ResponseJson $staleCommandUpdate)).Count 0 $activateCommand = Invoke-JsonRequest -Method Post -Path '/rest/v1/rpc/set_active_custom_instruction' -ApiKey $anonKey -Bearer $userOne.access_token -Body @{ instruction_id = $customCommand.id } Assert-Equal 'commands-activate-id' (Convert-ResponseJson $activateCommand).active_instruction_id $customCommand.id $crossActivateCommand = Invoke-JsonRequest -Method Post -Path '/rest/v1/rpc/set_active_custom_instruction' -ApiKey $anonKey -Bearer $userTwo.access_token -Body @{ instruction_id = $customCommand.id } Assert-True 'commands-cross-user-activate-denied' ([int]$crossActivateCommand.StatusCode -ge 400) $forgedJob = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/processing_jobs' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body @{ user_id = $userOne.user.id kind = 'transcription' idempotency_key = 'forged-client-job' } Assert-Equal 'processing-job-client-write-denied' ([int]$forgedJob.StatusCode) 403 $transactionId = 'txn-' + [guid]::NewGuid().ToString('N') $createdAdRewardTransactionIds.Add($transactionId) $rewardBody = @{ p_user_id = $userOne.user.id p_network = 'admob' p_placement = 'rewarded_video_quota' p_ad_unit_id = 'google-demo-rewarded' p_transaction_id = $transactionId p_reward_tokens = 50 } $unknownRewardBody = $rewardBody.Clone() $unknownRewardBody.p_user_id = [guid]::NewGuid().ToString() $unknownRewardBody.p_transaction_id = 'txn-' + [guid]::NewGuid().ToString('N') $createdAdRewardTransactionIds.Add([string]$unknownRewardBody.p_transaction_id) $unknownReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $unknownRewardBody Assert-Equal 'reward-unknown-user-status' ([int]$unknownReward.StatusCode) 200 $unknownRewardResponse = Convert-ResponseJson $unknownReward Assert-Equal 'reward-unknown-user-not-granted' $unknownRewardResponse.granted $false Assert-Equal 'reward-unknown-user-reason' $unknownRewardResponse.reason 'unknown_user' $unknownRewardReplay = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $unknownRewardBody $unknownRewardReplayResponse = Convert-ResponseJson $unknownRewardReplay Assert-Equal 'reward-unknown-user-replay-not-granted' $unknownRewardReplayResponse.granted $false Assert-Equal 'reward-unknown-user-replay-duplicate' $unknownRewardReplayResponse.reason 'duplicate' $unknownRewardReceipt = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/ad_reward_receipts?select=user_id,disposition&transaction_id=eq.$($unknownRewardBody.p_transaction_id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null $unknownRewardReceiptRows = @((Convert-ResponseJson $unknownRewardReceipt)) Assert-Equal 'reward-unknown-user-receipt-count' $unknownRewardReceiptRows.Count 1 Assert-Equal 'reward-unknown-user-receipt-unlinked' $unknownRewardReceiptRows[0].user_id $null Assert-Equal 'reward-unknown-user-receipt-disposition' $unknownRewardReceiptRows[0].disposition 'unknown_user' $clientReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $rewardBody Assert-Equal 'reward-client-rpc-denied' ([int]$clientReward.StatusCode) 403 $serviceReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $rewardBody Assert-Equal 'reward-service-status' ([int]$serviceReward.StatusCode) 200 $serviceRewardBody = Convert-ResponseJson $serviceReward Assert-Equal 'reward-service-granted' $serviceRewardBody.granted $true Assert-Equal 'reward-service-tokens' $serviceRewardBody.tokens_added 50 $duplicateReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $rewardBody $duplicateBody = Convert-ResponseJson $duplicateReward Assert-Equal 'reward-duplicate-not-granted' $duplicateBody.granted $false Assert-Equal 'reward-duplicate-reason' $duplicateBody.reason 'duplicate' $cooldownBody = $rewardBody.Clone() $cooldownBody.p_transaction_id = 'txn-' + [guid]::NewGuid().ToString('N') $createdAdRewardTransactionIds.Add([string]$cooldownBody.p_transaction_id) $cooldownReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $cooldownBody $cooldownResponse = Convert-ResponseJson $cooldownReward Assert-Equal 'reward-cooldown-not-granted' $cooldownResponse.granted $false Assert-Equal 'reward-cooldown-reason' $cooldownResponse.reason 'cooldown' $ownerClaims = Invoke-JsonRequest ` -Method Get ` -Path '/rest/v1/ad_reward_claims?select=transaction_id,reward_tokens' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null Assert-Equal 'reward-owner-claim-count' @((Convert-ResponseJson $ownerClaims)).Count 1 $otherClaims = Invoke-JsonRequest ` -Method Get ` -Path '/rest/v1/ad_reward_claims?select=transaction_id,reward_tokens' ` -ApiKey $anonKey ` -Bearer $userTwo.access_token ` -Body $null Assert-Equal 'reward-cross-user-claim-count' @((Convert-ResponseJson $otherClaims)).Count 0 $purchaseToken = 'fixture-google-play-token-' + [guid]::NewGuid().ToString('N') $purchaseBody = @{ p_user_id = $userOne.user.id p_platform = 'google_play' p_product_id = 'd3ro_voice_pro_monthly' p_store_transaction_id = 'GPA.fixture.' + [guid]::NewGuid().ToString('N') p_token_hash = Get-Sha256Hex $purchaseToken p_linked_token_hash = $null p_purchase_token = $purchaseToken p_purchase_state = 'purchased' p_purchase_at = '2026-08-21T00:00:00Z' p_expires_at = '2030-08-21T00:00:00Z' p_auto_renewing = $true p_acknowledged = $false p_tier = 'pro' p_entitled = $true p_verification = @{ subscriptionState = 'SUBSCRIPTION_STATE_ACTIVE' } } $clientPurchase = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/apply_verified_google_play_purchase' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $purchaseBody Assert-Equal 'iap-client-rpc-denied' ([int]$clientPurchase.StatusCode) 403 $servicePurchase = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/apply_verified_google_play_purchase' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $purchaseBody Assert-Equal 'iap-service-rpc-status' ([int]$servicePurchase.StatusCode) 200 $servicePurchaseBody = Convert-ResponseJson $servicePurchase Assert-Equal 'iap-service-entitled' $servicePurchaseBody.entitled $true Assert-Equal 'iap-service-tier' $servicePurchaseBody.tier 'pro' $ownerPurchases = Invoke-JsonRequest ` -Method Get ` -Path '/rest/v1/iap_purchases?select=product_id,purchase_state' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null Assert-Equal 'iap-owner-summary-count' @((Convert-ResponseJson $ownerPurchases)).Count 1 $otherPurchases = Invoke-JsonRequest ` -Method Get ` -Path '/rest/v1/iap_purchases?select=product_id,purchase_state' ` -ApiKey $anonKey ` -Bearer $userTwo.access_token ` -Body $null Assert-Equal 'iap-cross-user-summary-count' @((Convert-ResponseJson $otherPurchases)).Count 0 $subscriptionProbe = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/subscriptions?select=tier,provider,payment_provider&user_id=eq.$($userOne.user.id)" ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null $subscriptionRows = @((Convert-ResponseJson $subscriptionProbe)) Assert-Equal 'iap-subscription-tier' $subscriptionRows[0].tier 'pro' Assert-Equal 'iap-subscription-provider' $subscriptionRows[0].provider 'google_play' $paidRewardBody = $rewardBody.Clone() $paidRewardBody.p_transaction_id = 'txn-' + [guid]::NewGuid().ToString('N') $createdAdRewardTransactionIds.Add([string]$paidRewardBody.p_transaction_id) $paidReward = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $paidRewardBody $paidRewardResponse = Convert-ResponseJson $paidReward Assert-Equal 'reward-paid-tier-not-granted' $paidRewardResponse.granted $false Assert-Equal 'reward-paid-tier-reason' $paidRewardResponse.reason 'ineligible_tier' $stolenPurchaseBody = $purchaseBody.Clone() $stolenPurchaseBody.p_user_id = $userTwo.user.id $stolenPurchase = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/apply_verified_google_play_purchase' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $stolenPurchaseBody Assert-Equal 'iap-cross-account-token-denied' ([int]$stolenPurchase.StatusCode) 400 Assert-True 'iap-cross-account-token-error' $stolenPurchase.Content.Contains('purchase_owned_by_other_user') $replacementToken = 'fixture-google-play-replacement-' + [guid]::NewGuid().ToString('N') $replacementBody = $purchaseBody.Clone() $replacementBody.p_product_id = 'd3ro_voice_pro_plus_monthly' $replacementBody.p_store_transaction_id = 'GPA.fixture.replacement.' + [guid]::NewGuid().ToString('N') $replacementBody.p_token_hash = Get-Sha256Hex $replacementToken $replacementBody.p_linked_token_hash = $purchaseBody.p_token_hash $replacementBody.p_purchase_token = $replacementToken $replacementBody.p_tier = 'pro_plus' $replacementPurchase = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/apply_verified_google_play_purchase' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $replacementBody Assert-Equal 'iap-linked-replacement-status' ([int]$replacementPurchase.StatusCode) 200 $replacementResponse = Convert-ResponseJson $replacementPurchase Assert-Equal 'iap-linked-old-token-revoked' $replacementResponse.linked_purchase_revoked $true Assert-Equal 'iap-linked-new-tier' $replacementResponse.tier 'pro_plus' $linkedRowsProbe = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/iap_purchases?select=token_hash,purchase_state&user_id=eq.$($userOne.user.id)&order=created_at.asc" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null $linkedRows = @((Convert-ResponseJson $linkedRowsProbe)) Assert-Equal 'iap-linked-purchase-count' $linkedRows.Count 2 Assert-Equal 'iap-linked-old-state' $linkedRows[0].purchase_state 'expired' $expiredPurchaseBody = $replacementBody.Clone() $expiredPurchaseBody.p_linked_token_hash = $null $expiredPurchaseBody.p_purchase_state = 'expired' $expiredPurchaseBody.p_purchase_at = '2026-07-20T00:00:00Z' $expiredPurchaseBody.p_expires_at = '2026-08-20T00:00:00Z' $expiredPurchaseBody.p_auto_renewing = $false $expiredPurchaseBody.p_acknowledged = $true $expiredPurchaseBody.p_entitled = $false $expiredPurchase = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/apply_verified_google_play_purchase' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $expiredPurchaseBody Assert-Equal 'iap-expiry-rpc-status' ([int]$expiredPurchase.StatusCode) 200 $expiredSubscription = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/subscriptions?select=tier,provider,overage_credits&user_id=eq.$($userOne.user.id)" ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null $expiredSubscriptionRows = @((Convert-ResponseJson $expiredSubscription)) Assert-Equal 'iap-expiry-tier-downgrade' $expiredSubscriptionRows[0].tier 'free' Assert-Equal 'iap-expiry-provider-clear' $expiredSubscriptionRows[0].provider 'none' # The paid-tier callback above was valid and verified, but ineligible. Once # the account becomes free again, replaying that exact transaction must stay # unpaid forever instead of becoming newly grantable in another isolate. $ineligibleReplay = Invoke-JsonRequest ` -Method Post ` -Path '/rest/v1/rpc/grant_verified_ad_reward' ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $paidRewardBody Assert-Equal 'reward-ineligible-replay-status' ([int]$ineligibleReplay.StatusCode) 200 $ineligibleReplayResponse = Convert-ResponseJson $ineligibleReplay Assert-Equal 'reward-ineligible-replay-not-granted' $ineligibleReplayResponse.granted $false Assert-Equal 'reward-ineligible-replay-duplicate' $ineligibleReplayResponse.reason 'duplicate' $ineligibleReplayClaims = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/ad_reward_claims?select=id&transaction_id=eq.$($paidRewardBody.p_transaction_id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null Assert-Equal 'reward-ineligible-replay-claim-count' @((Convert-ResponseJson $ineligibleReplayClaims)).Count 0 $ineligibleReceipt = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/ad_reward_receipts?select=disposition&transaction_id=eq.$($paidRewardBody.p_transaction_id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null $ineligibleReceiptRows = @((Convert-ResponseJson $ineligibleReceipt)) Assert-Equal 'reward-ineligible-receipt-count' $ineligibleReceiptRows.Count 1 Assert-Equal 'reward-ineligible-receipt-disposition' $ineligibleReceiptRows[0].disposition 'ineligible_tier' $postReplaySubscription = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/subscriptions?select=overage_credits&user_id=eq.$($userOne.user.id)" ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null $postReplaySubscriptionRows = @((Convert-ResponseJson $postReplaySubscription)) Assert-Equal 'reward-ineligible-replay-balance-unchanged' ` $postReplaySubscriptionRows[0].overage_credits ` $expiredSubscriptionRows[0].overage_credits $receiptProbe = Invoke-JsonRequest ` -Method Get ` -Path '/rest/v1/iap_purchase_receipts?select=purchase_id' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null Assert-Equal 'iap-receipt-owner-hidden-status' ([int]$receiptProbe.StatusCode) 200 Assert-Equal 'iap-receipt-owner-hidden-rows' @((Convert-ResponseJson $receiptProbe)).Count 0 $wrongDelete = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/account-delete' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body @{ confirmation = 'WRONG' } Assert-Equal 'account-delete-confirmation-denied' ([int]$wrongDelete.StatusCode) 400 $accountDelete = Invoke-JsonRequest ` -Method Post ` -Path '/functions/v1/account-delete' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body @{ confirmation = 'DELETE_MY_ACCOUNT' } Assert-Equal 'account-delete-success' ([int]$accountDelete.StatusCode) 200 $deletedToken = Invoke-JsonRequest ` -Method Get ` -Path '/auth/v1/user' ` -ApiKey $anonKey ` -Bearer $userOne.access_token ` -Body $null Assert-Equal 'account-delete-token-invalidated' ([int]$deletedToken.StatusCode) 403 $deletedProfile = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/profiles?select=id&id=eq.$($userOne.user.id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null Assert-Equal 'account-delete-profile-cascade' @((Convert-ResponseJson $deletedProfile)).Count 0 $deletedSubscription = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/subscriptions?select=id&user_id=eq.$($userOne.user.id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null Assert-Equal 'account-delete-subscription-cascade' @((Convert-ResponseJson $deletedSubscription)).Count 0 $deletedAccountRewardReceipt = Invoke-JsonRequest ` -Method Get ` -Path "/rest/v1/ad_reward_receipts?select=user_id,disposition&transaction_id=eq.$($paidRewardBody.p_transaction_id)" ` -ApiKey $serviceRoleKey ` -Bearer $serviceRoleKey ` -Body $null $deletedAccountRewardReceiptRows = @((Convert-ResponseJson $deletedAccountRewardReceipt)) Assert-Equal 'account-delete-reward-receipt-retained' $deletedAccountRewardReceiptRows.Count 1 Assert-Equal 'account-delete-reward-receipt-unlinked' $deletedAccountRewardReceiptRows[0].user_id $null Assert-Equal 'account-delete-reward-receipt-disposition' ` $deletedAccountRewardReceiptRows[0].disposition ` 'ineligible_tier' Write-Host "PASS mobile platform integration: $passCount assertions" } finally { foreach ($transactionId in $createdAdRewardTransactionIds) { Remove-TestAdRewardReceiptByTransaction $transactionId } foreach ($userId in $createdUserIds) { Remove-TestUser $userId } }