diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 08f1bb12ab..6d47eb81a5 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2481,6 +2481,12 @@ func loadBadgeStateAsync(_ userId: Int64) async { } } +func loadCurrentBadgeState() async { + if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) { + await loadBadgeStateAsync(userId) + } +} + private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index e9de68f82a..398cfb6594 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -70,13 +70,15 @@ struct BadgeStoreReceipt { let signatureVerified: Bool let transaction: Transaction - // core mints the id in lower case, and UUID formats it in upper case - var echoedInvoiceId: String? { invoiceId?.uuidString.lowercased() } + var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) } } +// core mints the id in lower case, and UUID formats it in upper case +func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() } + enum BadgePurchaseOutcome { case purchased(BadgeStoreReceipt) - case pending + case pending(invoiceId: String?) case cancelled } @@ -107,10 +109,12 @@ final class BadgeStore: ObservableObject { // invoices whose purchase this session is still waiting on the store for @Published private var buying: Set = [] // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing - @Published private var waitingForApproval = false + // by invoice id, so a pending purchase shows only under the profile whose record it names + @Published private var waitingForApproval: Set = [] // until the store has been asked once, a purchase made while the app was not running is unknown @Published private var reconciledOnce = false - private var presenting: Set = [] + // whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it + private var presenting: [UInt64: Bool] = [:] private var transactionUpdates: Task? = nil private init() {} @@ -119,7 +123,7 @@ final class BadgeStore: ObservableObject { let purchases = openStorePurchases(userId) let held = Set(unfinished.values.compactMap { $0.echoedInvoiceId }) if purchases.contains(where: { $0.invoiceId.map(held.contains) == true }) { return .issuing } - if waitingForApproval { return .waitingForApproval } + if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } if purchases.contains(where: { $0.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking } return nil } @@ -222,18 +226,20 @@ final class BadgeStore: ObservableObject { switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { case let .success(verification): return .purchased(storeReceipt(verification)) case .pending: - await MainActor.run { waitingForApproval = true } - return .pending + let pending = coreInvoiceId(invoiceId) + await MainActor.run { _ = waitingForApproval.insert(pending) } + return .pending(invoiceId: pending) case .userCancelled: return .cancelled @unknown default: throw BadgeStoreError.unknownPurchaseResult } } // only the purchase the user started may alert: an answer can reveal a profile other than the one on screen - func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async { + private func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async { guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }), - await claim(receipt) + await claim(receipt, interactive: interactive) else { return } + var alertText: String? = nil do { switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) { case let .redeemed(user, badgeState): @@ -253,18 +259,14 @@ final class BadgeStore: ObservableObject { logger.error("BadgeStore.presentPurchase: \(responseError(error))") let refused = badgeReceiptRefused(error) if refused { await finish(receipt) } - if interactive { - let text = redeemErrorText(error) - await MainActor.run { - showAlert( - NSLocalizedString("Purchase error", comment: "alert title"), - message: refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message") - ) - } - } + let text = redeemErrorText(error) + alertText = refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message") + } + await loadCurrentBadgeState() + let userWaiting = await MainActor.run { presenting.removeValue(forKey: receipt.transactionId) == true } + if userWaiting, let alertText { + await MainActor.run { showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: alertText) } } - await loadBadgeStateAsync(userId) - await MainActor.run { _ = presenting.remove(receipt.transactionId) } } // at launch and on return to the foreground, never on a timer @@ -287,7 +289,7 @@ final class BadgeStore: ObservableObject { let abandoned = await MainActor.run { openStorePurchases(userId) .compactMap { $0.transactionRef == nil ? $0.invoiceId : nil } - .filter { !held.contains($0) && !buying.contains($0) } + .filter { !held.contains($0) && !buying.contains($0) && !waitingForApproval.contains($0) } } for invoiceId in abandoned { await closeInvoice(userId, invoiceId) @@ -300,7 +302,7 @@ final class BadgeStore: ObservableObject { } catch let error { logger.error("BadgeStore.closeInvoice: \(responseError(error))") } - await loadBadgeStateAsync(userId) + await loadCurrentBadgeState() } // transactions the store settles outside a purchase call, such as an approved Ask to Buy @@ -330,11 +332,15 @@ final class BadgeStore: ObservableObject { // one request per transaction: the purchase itself, launch, foreground and the store can each present it @MainActor - private func claim(_ receipt: BadgeStoreReceipt) -> Bool { - guard presenting.insert(receipt.transactionId).inserted else { return false } + private func claim(_ receipt: BadgeStoreReceipt, interactive: Bool) -> Bool { + if let waiting = presenting[receipt.transactionId] { + presenting[receipt.transactionId] = waiting || interactive + return false + } + presenting[receipt.transactionId] = interactive unfinished[receipt.transactionId] = receipt // an approved Ask to Buy arrives as a transaction, which ends the wait - waitingForApproval = false + if let invoiceId = receipt.echoedInvoiceId { waitingForApproval.remove(invoiceId) } return true } diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 859958c38a..6d34c37168 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -138,7 +138,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p } private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { - Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending + Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId) Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = purchase.purchaseToken, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index de05c87b64..bd23034ca5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -113,7 +113,7 @@ private val testBadgeProducts: List = listOf( sealed class BadgePurchaseOutcome { class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() - object Pending: BadgePurchaseOutcome() + class Pending(val invoiceId: String?): BadgePurchaseOutcome() object Cancelled: BadgePurchaseOutcome() } @@ -150,11 +150,13 @@ object BadgeStore { private val storePurchases = mutableStateOf>?>(null) // invoices whose purchase this session is still waiting on the store for private val buying = mutableStateOf>(emptySet()) - private val waitingForApproval = mutableStateOf(false) + // by invoice id, so a pending purchase shows only under the profile whose record it names + private val waitingForApproval = mutableStateOf>(emptySet()) // until the store has been asked once, a purchase made while the app was not running is unknown private val reconciledOnce = mutableStateOf(false) + // whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it; // read and written on the main thread only - private val presenting = mutableSetOf() + private val presenting = mutableMapOf() fun purchaseState(userId: Long?): BadgePurchaseState? { if (!platform.androidHasPlatformStore) return null @@ -162,7 +164,7 @@ object BadgeStore { val held = unfinished.value.values.mapNotNull { it.invoiceId }.toSet() return when { purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing - waitingForApproval.value -> BadgePurchaseState.WaitingForApproval + purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval purchases.any { it.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking else -> null } @@ -267,15 +269,17 @@ object BadgeStore { } else { platform.androidPurchaseBadge(id, invoiceId) } - if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true } + if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } return outcome } // only the purchase the user started may alert: an answer can reveal a profile other than the one on screen - suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) { + private suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) { val rhId = chatModel.remoteHostId() val userId = chatModel.currentUser.value?.userId ?: return - if (!claim(receipt)) return + if (!claim(receipt, interactive)) return + var alertText: String? = null + var userWaiting = false try { when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) { is BadgePurchaseResult.Redeemed -> { @@ -293,19 +297,17 @@ object BadgeStore { Log.e(TAG, "BadgeStore.presentPurchase: ${r.err?.string}") val refused = badgeReceiptRefused(r.err) if (refused) finish(receipt) - if (interactive) { - val text = chatModel.controller.redeemErrorText(r.err) - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry) - ) - } + val text = chatModel.controller.redeemErrorText(r.err) + alertText = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry) } null -> {} } } finally { - chatModel.controller.loadBadgeState(rhId) - withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) } + withContext(NonCancellable) { chatModel.controller.loadBadgeState(chatModel.remoteHostId()) } + userWaiting = withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) == true } + } + if (userWaiting && alertText != null) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = alertText) } } @@ -320,7 +322,7 @@ object BadgeStore { return } // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here - withContext(Dispatchers.Main) { waitingForApproval.value = purchases.any { it is BadgePurchaseOutcome.Pending } } + withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet() purchases.forEach { reconcile(it) } closeAbandoned(held) @@ -337,7 +339,7 @@ object BadgeStore { val abandoned = withContext(Dispatchers.Main) { openStorePurchases(userId) .mapNotNull { if (it.transactionRef == null) it.invoiceId else null } - .filter { it !in held && it !in buying.value } + .filter { it !in held && it !in buying.value && it !in waitingForApproval.value } } abandoned.forEach { closeInvoice(userId, it) } } @@ -357,17 +359,23 @@ object BadgeStore { is BadgePurchaseOutcome.Purchased -> if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) else presentPurchase(outcome.receipt, interactive = false) - is BadgePurchaseOutcome.Pending -> withContext(Dispatchers.Main) { waitingForApproval.value = true } + is BadgePurchaseOutcome.Pending -> + if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } is BadgePurchaseOutcome.Cancelled -> {} } } // one request per purchase: the purchase itself, launch, foreground and the store can each present it - private suspend fun claim(receipt: BadgeStoreReceipt): Boolean = withContext(Dispatchers.Main) { - if (!presenting.add(receipt.token)) return@withContext false + private suspend fun claim(receipt: BadgeStoreReceipt, interactive: Boolean): Boolean = withContext(Dispatchers.Main) { + val waiting = presenting[receipt.token] + if (waiting != null) { + presenting[receipt.token] = waiting || interactive + return@withContext false + } + presenting[receipt.token] = interactive unfinished.value += receipt.token to receipt // a slow payment that completes arrives as a purchase, which ends the wait - waitingForApproval.value = false + if (receipt.invoiceId != null) waitingForApproval.value -= receipt.invoiceId true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt index dee2e6270f..d02d854200 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -22,7 +22,7 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M val purchasing = remember { mutableStateOf(false) } LaunchedEffect(Unit) { BadgeStore.load() } - CloseWhenPurchaseInFlight(modalManager) + CloseWhenSupportGivesWay(modalManager) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index 54ba29fbe1..51fc6e05e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -58,7 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } - CloseWhenPurchaseInFlight(modalManager) + CloseWhenSupportGivesWay(modalManager) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 9926e01569..fee8b41b7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -69,7 +69,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) { var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) } LaunchedEffect(Unit) { BadgeStore.load() } - CloseWhenPurchaseInFlight(modalManager) + CloseWhenSupportGivesWay(modalManager) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index a086615846..1254671f59 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -99,7 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } - CloseWhenPurchaseInFlight(modalManager) + CloseWhenSupportGivesWay(modalManager) // when the text is unchanged, the field's own value is kept: it carries the cursor position and the // keyboard's composition state, which BasicTextField loses unless they are passed back to it @@ -118,7 +118,8 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { withContext(Dispatchers.Main) { submitting.value = false when (outcome) { - is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal() + // a shown badge closes this screen itself, and a second close would take the screen beneath + is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal() is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message) is BadgeRedeemOutcome.Cancelled -> {} } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index 8ed6f481c0..df081b5f03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -12,11 +12,7 @@ import chat.simplex.common.views.helpers.ModalView @OptIn(ExperimentalAnimationApi::class) @Composable fun BadgesView(modalManager: ModalManager, close: () -> Unit) { - val shownBadge: BadgeState? = run { - if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null - val badgeState = BadgeModel.badgeState.value - if (badgeState != null && badgeState.shown) badgeState else null - } + val shownBadge = currentShownBadge() // the card look is a modal setting, so the modal is composed here to follow the screen shown ModalView(close, cardScreen = shownBadge != null) { @@ -37,13 +33,19 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { } } -// each purchase screen closes itself when it recomposes with a purchase in flight, as iOS pops them -// when Support SimpleX gives way to it +fun currentShownBadge(): BadgeState? { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null + val badgeState = BadgeModel.badgeState.value + return if (badgeState != null && badgeState.shown) badgeState else null +} + +// each purchase screen closes itself when it recomposes with a purchase in flight or a badge shown, as iOS +// pops them when Support SimpleX gives way to either @Composable -fun CloseWhenPurchaseInFlight(modalManager: ModalManager) { - val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null - LaunchedEffect(inFlight) { - if (inFlight) modalManager.closeModal() +fun CloseWhenSupportGivesWay(modalManager: ModalManager) { + val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null + LaunchedEffect(gaveWay) { + if (gaveWay) modalManager.closeModal() } } diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 4bbc9ad1a8..66c55b4f31 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -237,7 +237,7 @@ data BadgeServiceErrorCode | BSEPaymentNotEntitled | BSEPaymentPending | BSEProviderUnavailable - | BSEProviderNotConfigured + | BSEProviderNotConfigured -- no verifier deployed: a dev service, not the store failing to answer | BSERateLimited | BSECodeInvalid | BSECodeUsed @@ -283,8 +283,9 @@ data BadgeState = BadgeState } deriving (Show) --- | A store purchase this profile started or presented, neither credited nor closed. Neither --- reference is a secret: Apple's is the transaction id, Google's is a hash of the token. +-- | One of a profile's open store purchases, neither credited nor closed. The app matches invoiceId +-- against the transactions its store still holds; transactionRef is set once a receipt arrived. +-- Neither reference is a secret: Apple's is the transaction id, Google's is a hash of the token. data BadgeStorePurchase = BadgeStorePurchase { invoiceId :: Maybe Text, transactionRef :: Maybe Text diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index ee0dc9fbe7..b47850946a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -985,12 +985,14 @@ CREATE TABLE badge_code_redemptions( CREATE TABLE badge_store_receipts( badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - provider TEXT NOT NULL, - transaction_ref TEXT NOT NULL, + invoice_id TEXT UNIQUE, + provider TEXT, + transaction_ref TEXT, purchase_key BLOB NOT NULL, purchase_priv_key BLOB NOT NULL, master_key BLOB NOT NULL, created_at TEXT NOT NULL, + closed_at TEXT, UNIQUE(provider, transaction_ref) ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 9f7a4f4733..6f1faba503 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -140,7 +140,7 @@ badgeServiceTests = do it "should answer a receipt presented under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile it "should deliver a purchase first presented under another profile to that profile" testPurchaseStrandedUnderOtherProfile it "should deliver a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile - it "should credit a receipt to the profile that created its invoice, and answer the presenter as delivered" testInvoiceOtherProfile + it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile it "should resolve the same receipt to the same record, and replay its credential" testInvoiceSameReceiptTwice it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown it "should reopen a closed record for a late receipt, and credit it" testInvoiceReopened @@ -1763,7 +1763,9 @@ testInvoiceOtherProfile ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - alice <## "badge purchase delivered to another profile" + alice <## "[user: alice] badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " (alice "/user alice"