diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 814c34bf5a..c6862fa36d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5479,7 +5479,18 @@ sendBadgeRequest nm user signKey req = pure $ either (badgeRequestFailed . responseFailed) id $ J.eitherDecodeStrict' respData -- a sentence first, then the error itself: the apps render an 'internal' code by showing this -- message (G2), and a bare 'Show' of a 'ChatError' is not something to put in front of a user - requestFailed e = "The badge service could not be reached, and the request was not delivered. Details: " <> tshow e + -- + -- the agent's request timeout gets its own sentence, because it is the ONE outcome here that + -- may have been DELIVERED: the service can have received the request and consumed the code, + -- with only the reply lost. Saying "not delivered" would steer the user into re-entering the + -- same code, and 'redeemBadgeCode' mints a FRESH purchase key per call, so the retry reaches + -- the service as a different signer and is answered @code_used@ (plan §9) — burning a code + -- they paid for. Everything else this collapses is a genuine non-delivery: an unresolvable + -- target, a transport failure before the send, or an agent error other than the timeout. + requestFailed = \case + ChatErrorAgent {agentError = AGENT (A_SERVICE ASETimeout)} -> + "The badge service did not answer in time. The request may still have been delivered, so a code presented with it may already have been used, and entering it again will not help. Please contact support." + e -> "The badge service could not be reached, and the request was not delivered. Details: " <> tshow e responseFailed e = "The badge service answered with something this app version cannot read. Details: " <> T.pack e badgeRequestFailed e = BSPError {code = BSEInternal, message = Just e, retryAfter = Nothing} diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 17842f7f43..344e385a0f 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -2862,6 +2862,12 @@ testC5StorePaymentsUnsupported ps = -- The agent's 'serviceRequestTimeout' is shortened to two seconds for this example only: its -- default is thirty, which is longer than any terminal assertion waits, and what is being pinned -- is what happens AFTER the timeout, not how long the default is. +-- +-- The message asserted here is the TIMEOUT one, and that is the whole point of asserting it in +-- full: nothing is listening, so the request expires as @A_SERVICE ASETimeout@, which is the one +-- outcome of 'sendBadgeRequest' that may have been delivered. It must not tell the user the +-- request was not delivered, and it must not invite a retry of the same code, which would arrive +-- as a different signer and be answered @code_used@ (plan §9). testC5ServiceStoppedIsInternal :: HasCallStack => TestParams -> IO () testC5ServiceStoppedIsInternal ps = -- the two startup phases publish the service's address and then it stays down: 'withService' is @@ -2871,11 +2877,19 @@ testC5ServiceStoppedIsInternal ps = let cfg = (badgeClientCfg sLink pk) {agentConfig = testAgentCfg {serviceRequestTimeout = 2}} withNewTestChatCfg ps cfg badgeClientDbPrefix aliceProfile $ \alice -> do alice ##> "/_badge catalog 1" - alice <##. "badge service error: internal, The badge service could not be reached, and the request was not delivered." + alice <## badgeTimeoutError alice ##> purchaseCodeCmd "C5-SERVICE-DOWN" - alice <##. "badge service error: internal, The badge service could not be reached, and the request was not delivered." + alice <## badgeTimeoutError noClientBadgeRows "with the service stopped" alice +-- | 'sendBadgeRequest''s timeout sentence, spelled out rather than derived from the function under +-- test: it carries no @Details:@ tail, so the whole line is compared. +badgeTimeoutError :: String +badgeTimeoutError = + "badge service error: internal, The badge service did not answer in time." + <> " The request may still have been delivered, so a code presented with it may already have been used," + <> " and entering it again will not help. Please contact support." + -- Supersession and the slot ----------------------------------------------------- -- | A second code redeemed on the same profile takes the slot: the first purchase becomes