feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,652 @@
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
}
}