Files
simplex-chat/tests/Bots/BadgeService/WaitersTests.hs
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

147 lines
5.8 KiB
Haskell

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Bots.BadgeService.WaitersTests (badgeWaitersTests) where
import BadgeService.Waiters
import Control.Concurrent.Async (wait, withAsync)
import Control.Concurrent.STM
import Control.Exception (Exception, SomeException, throwIO, try)
import Data.IORef (newIORef, readIORef)
import Data.Text (Text)
import Simplex.Chat.PaymentService.Types (InvoiceId (..), InvoiceStatus (..))
import System.Timeout (timeout)
import Test.Hspec
badgeWaitersTests :: Spec
badgeWaitersTests = describe "badge service waiters" $ do
it "wakes on a status published between subscribe and the database read" testSubscribeBeforeRead
it "answers a payment whose publish landed before this request subscribed" testPaymentPublishedBeforeSubscribe
it "is not woken by a publish for a different invoice" testDifferentInvoiceNoWake
it "returns the current status, not an error, when the wait times out" testTimeoutReturnsCurrentStatus
it "empties the map when the waiting action throws" testMapEmptiesOnException
it "empties the map after a normal return" testMapEmptiesOnNormalReturn
it "keeps one map entry for two waiters on the same invoice" testRefcountSharedInvoice
it "counts one entry per invoice, not per waiter" testWaitingCountPerInvoice
iid, iid2 :: InvoiceId
iid = InvoiceId "inv-1"
iid2 = InvoiceId "inv-2"
-- | An invoice nobody has paid into carries no figure and no verdict from the provider.
unpaid :: (Text, Bool)
unpaid = ("", False)
openUnpaid :: Seen
openUnpaid = (ISOpen, unpaid)
data Boom = Boom deriving (Show)
instance Exception Boom
-- | This suite has hung rather than failed before. `us` must exceed every awaitStatus
-- timeout the test uses.
bounded :: String -> Int -> IO a -> IO a
bounded what us act =
timeout us act >>= \case
Just a -> pure a
Nothing -> do
expectationFailure (what <> " did not finish within " <> show us <> "us")
error "unreachable: expectationFailure always throws"
testSubscribeBeforeRead :: IO ()
testSubscribeBeforeRead = bounded "the ordering wait" 2_000_000 $ do
w <- newWaiters
ref <- newIORef ISOpen
let readStatus = do
atomically $ publish w iid ISPaid
(\s -> (s, unpaid)) <$> readIORef ref
s <- awaitStatus w iid readStatus openUnpaid 200_000
s `shouldBe` ISPaid
testDifferentInvoiceNoWake :: IO ()
testDifferentInvoiceNoWake = bounded "the isolation wait" 2_000_000 $ do
w <- newWaiters
otherSubscribed <- newEmptyTMVarIO
let otherRead = atomically (putTMVar otherSubscribed ()) >> pure openUnpaid
withAsync (awaitStatus w iid2 otherRead openUnpaid 200_000) $ \other ->
withAsync (awaitStatus w iid (pure openUnpaid) openUnpaid 200_000) $ \watched -> do
atomically $ takeTMVar otherSubscribed
atomically $ publish w iid2 ISPaid
otherResult <- wait other
otherResult `shouldBe` ISPaid
watchedResult <- wait watched
watchedResult `shouldBe` ISOpen
testTimeoutReturnsCurrentStatus :: IO ()
testTimeoutReturnsCurrentStatus = bounded "the timeout wait" 1_000_000 $ do
w <- newWaiters
s <- awaitStatus w iid (pure openUnpaid) openUnpaid 50_000
s `shouldBe` ISOpen
testMapEmptiesOnException :: IO ()
testMapEmptiesOnException = bounded "the exception-cleanup wait" 1_000_000 $ do
w <- newWaiters
_ <- try @SomeException $ awaitStatus w iid (throwIO Boom) openUnpaid 1_000_000
waitingCount w >>= (`shouldBe` 0)
testMapEmptiesOnNormalReturn :: IO ()
testMapEmptiesOnNormalReturn = bounded "the normal-return cleanup wait" 1_000_000 $ do
w <- newWaiters
_ <- awaitStatus w iid (pure openUnpaid) openUnpaid 50_000
waitingCount w >>= (`shouldBe` 0)
testRefcountSharedInvoice :: IO ()
testRefcountSharedInvoice = bounded "the shared-invoice refcount wait" 2_000_000 $ do
w <- newWaiters
entered <- newTVarIO (0 :: Int)
go <- newTVarIO False
let readStatus = do
atomically $ modifyTVar' entered (+ 1)
atomically $ readTVar go >>= check
pure openUnpaid
withAsync (awaitStatus w iid readStatus openUnpaid 500_000) $ \a1 ->
withAsync (awaitStatus w iid readStatus openUnpaid 500_000) $ \a2 -> do
atomically $ readTVar entered >>= check . (== 2)
waitingCount w >>= (`shouldBe` 1)
atomically $ writeTVar go True
atomically $ publish w iid ISPaid
r1 <- wait a1
r2 <- wait a2
r1 `shouldBe` ISPaid
r2 `shouldBe` ISPaid
waitingCount w >>= (`shouldBe` 0)
testWaitingCountPerInvoice :: IO ()
testWaitingCountPerInvoice = bounded "the per-invoice waiting-count wait" 2_000_000 $ do
w <- newWaiters
entered <- newTVarIO (0 :: Int)
go <- newTVarIO False
let readStatus = do
atomically $ modifyTVar' entered (+ 1)
atomically $ readTVar go >>= check
pure openUnpaid
withAsync (awaitStatus w iid readStatus openUnpaid 500_000) $ \a1 ->
withAsync (awaitStatus w iid2 readStatus openUnpaid 500_000) $ \a2 -> do
atomically $ readTVar entered >>= check . (== 2)
waitingCount w >>= (`shouldBe` 2)
atomically $ writeTVar go True
atomically $ publish w iid ISPaid
atomically $ publish w iid2 ISPaid
r1 <- wait a1
r2 <- wait a2
r1 `shouldBe` ISPaid
r2 `shouldBe` ISPaid
waitingCount w >>= (`shouldBe` 0)
-- | The payment committed before this request subscribed, so its publish found no watch and
-- the counter cannot report it. The read after subscribing is the only thing that can, and a
-- request that waits for the counter alone parks for its whole timeout with the money already in.
testPaymentPublishedBeforeSubscribe :: IO ()
testPaymentPublishedBeforeSubscribe = do
w <- newWaiters
s <- bounded "the lost-publish wait" 1_000_000 $ awaitStatus w iid (pure (ISOpen, ("0.005", True))) openUnpaid 30_000_000
s `shouldBe` ISOpen