{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TupleSections #-} module Bots.StoreService.WebTests ( badgeWebTests, withServiceStore, withServiceStores, columnsOf, runMigrations, digestFixture, capturingStderr, StubState (..), StubCall (..), newStubState, stubProvider, stubCalls, setSignals, sampleProviderInvoice, prepareStaticDir, testServiceConfig, testBTCPayConfig, WebClient, webRequestBody, webGet, statusOf, jsonObject, fieldOf, rcv, bounded, ) where import StoreService.Catalog (catalogCurrency, defaultCatalog) import StoreService.Config (BTCPayConfig (..), ListenerConfig (..), PollConfig (..), ServiceConfig (..), SpeedPolicy (..), StripeConfig (..)) import StoreService.Log (logInfo) import StoreService.Names.Abi (hex) import StoreService.Orders (cancelOpenInvoice, codeLifetime, expireOverdue, settleOrder) import StoreService.Poller import StoreService.Providers import StoreService.Providers.BTCPay (btcpayProvider, listPageSize, maxListPages) import StoreService.Providers.Stripe (stripeProvider) import StoreService.Store (CreditedPurchase (..), FundingCredit (..), KeyPurchase (..), KeyRedemption (..), ManagedGroup (..), NewCodePurchase (..), NewStorePurchase (..), PaymentRebind (..), PurchaseSubscription (..), RevokeResult (..), appendLedgerPlan, createCodePurchase, createStorePurchase, getCodePurchaseForKey, getLedgerEntries, getLedgerTip, getManagedGroup, getMovedFromExpiry, getPurchaseByKey, getPurchaseSubscription, getStorePaymentCredit, insertBadgeCode, insertManagedGroup, insertSubscriptionCharge, markOwnerBootstrapped, rebindStorePayment, revokeCode) import StoreService.StoreReceipts (StoreCharge (..)) import StoreService.Store.Invoices import StoreService.Waiters (awaitStatus, newWaiters, publish, waitingCount) import StoreService.Web.Http import StoreService.Web.Server import Bots.StoreService.BotTests (codeCounts, newPurchaseKeys) import Bots.StoreService.CatalogTests (WebOffer (..), WebPrice (..), parseCatalogSource) import Bots.StoreService.FakeBTCPay import Bots.StoreService.FakeStripe (FakeStripe (..), fakeIntentStatus, setIntentState, stripeEvent, stripeSigHeader, withFakeStripe) import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.Async (async, wait) import qualified Control.Concurrent.Async as Async import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, putMVar, takeMVar, withMVar) import Control.Concurrent.STM (atomically, modifyTVar', readTVarIO) import qualified Control.Exception as E import Control.Monad (forM, forM_, join, replicateM, replicateM_, void, when) import Data.Aeson ((.=)) import qualified Data.Aeson as J import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM import Data.ByteString (ByteString) import qualified Data.ByteString as BS import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Char8 as BC import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (toLower) import Data.Either (isLeft) import Data.Functor ((<&>)) import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef) import Data.Int (Int64) import Data.List (isInfixOf, sort, sortOn) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes, isJust, isNothing, fromMaybe, mapMaybe) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import qualified Data.Text.IO as T import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, picosecondsToDiffTime, secondsToDiffTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.Word (Word32, Word8) import qualified GHC.IO.Device as IODevice import qualified GHC.IO.FD as FD import GHC.IO.Handle.FD (handleToFd) import Network.HTTP.Client (Manager, Request (..), RequestBody (..), Response, defaultManagerSettings, httpLbs, newManager, parseRequest, responseBody, responseHeaders, responseStatus, responseTimeoutMicro) import Network.HTTP.Types (Header, HeaderName, hCacheControl, hContentType) import Network.HTTP.Types.Status (statusCode) import qualified Network.Wai.Handler.Warp as Warp import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey (..), BadgeType (..)) import Simplex.Chat.Badges.Ledger (addMonths, debitEntry, emptyEntry, grantEntry, issueEntry) import Simplex.Chat.StoreService (BadgeOffer (..), BadgePrice (..), StatementCreditType (..), StatementDebitType (..), StatementEntry (..), SubscriptionState (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..), BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) import Simplex.Chat.PaymentService.Types (CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..), ServicePaymentMethod (..)) import Simplex.Messaging.Agent.Store.Common (DBStore (..), withConnection, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSSignature (..)) import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import System.Directory (createDirectoryIfMissing, createFileLink, doesFileExist, listDirectory) import System.FilePath ((>)) import System.IO (BufferMode (..), IOMode (..), hFlush, hGetBuffering, hSetBuffering, stderr, withFile) import System.IO.Unsafe (unsafePerformIO) import System.Timeout (timeout) import Test.Hspec import Text.Read (readMaybe) import UnliftIO.Temporary (withTempDirectory) #if defined(dbPostgres) import StoreService.Store.Postgres.Migrations (schemaMigrations, storeServiceSchemaMigrations) import ChatClient (testDBConnstr) import Database.PostgreSQL.Simple (Only (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations #else import StoreService.Store.SQLite.Migrations (schemaMigrations, storeServiceSchemaMigrations) import Data.String (fromString) import Database.SQLite.Simple (Only (..)) import qualified Database.SQLite.Simple as SQL import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations #endif withServiceStore :: (DBStore -> IO a) -> IO a withServiceStore action = withServiceStores (>>= action) -- | Each store the action opens is a new one over the same test database, as a restarted service opens; all are closed at the end. withServiceStores :: (IO DBStore -> IO a) -> IO a withServiceStores action = withServiceDBOpts $ \opts -> do opened <- newIORef [] let open = do Right st <- createDBStore opts storeServiceSchemaMigrations (MigrationConfig MCError Nothing) st <$ modifyIORef' opened (st :) action open `E.finally` (readIORef opened >>= mapM_ closeDBStore) withServiceDBOpts :: (DBOpts -> IO a) -> IO a #if defined(dbPostgres) withServiceDBOpts action = do n <- atomicModifyIORef' serviceSchemaCounter (\i -> (i + 1, i)) action DBOpts { connstr = BC.pack testDBConnstr, schema = "sx_badge_service_web_test_" <> BC.pack (show n), poolSize = 4, createSchema = True } serviceSchemaCounter :: IORef Int serviceSchemaCounter = unsafePerformIO $ newIORef 0 {-# NOINLINE serviceSchemaCounter #-} #else withServiceDBOpts action = do createDirectoryIfMissing True "tests/tmp" withTempDirectory "tests/tmp" "badge-web" $ \dir -> action (DBOpts (dir > "badge_service_test.db") [] "" False True TQOff) #endif columnsOf :: DBStore -> Text -> IO [Text] columnsOf st table = withConnection st $ \db -> #if defined(dbPostgres) map fromOnly <$> DB.query db "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?" (Only table) #else map fromOnly <$> DB.query_ db (fromString ("SELECT name FROM pragma_table_info('" ++ T.unpack table ++ "')")) #endif badgeWebTests :: Spec badgeWebTests = do describe "store service schema" $ do it "carries the service-only columns, and none of the subscription columns it never wrote" testServiceColumns it "refuses a duplicate provider_ref" testProviderRefUnique it "M20260918 adds the multi-use columns and the group table" testGroupOpsColumns it "M20260918 defaults a fresh code to one use with none spent" testGroupOpsRedeemCounts it "declares its migrations in name order, master's badge migrations before the names ones" testMigrationOrder it "migrates all the way down and up again" testSchemaDownUpCycle it "rolls back only the group migration and re-applies it, keeping a code redeemed twice spent" testGroupOpsDownUp it "rolls back only the subscription migration, restoring the columns it dropped, and re-applies it" testSubscriptionsDownUp describe "store service store" $ do it "writes the invoice, its code and their link atomically" testCreationIsAtomic it "newInvoiceId is 128 CSPRNG bits, base64url, and two calls differ" testNewInvoiceIdRandom it "codeHashExists is true for a hash already written" testCodeHashExists it "expireOverdue moves only open, past-expiry rows, and names them" testExpireOverdueMovesOnlyQualifying it "expireOverdue moves only the overdue invoices it is given" testExpireOverdueMovesOnlyTheNamed it "expireOverdue never sweeps an invoice that has been paid into" testExpireOverdueSparesAFundedInvoice it "expireOverdue spares an invoice funded by dust, or by the verdict alone" testExpireOverdueSparesAZeroAmount it "readCatalogRows drops every disabled row" testReadCatalogRowsDropsDisabled it "a revoked code cannot be redeemed, and a redeemed code cannot be revoked" testRevokeAndRedeemExcludeEachOther it "a multi-use code can be revoked while it has uses left, and not once they are gone" testRevokeMultiUseCode it "every timestamp round-trips to the second" testTimestampRoundTrip it "stores a charge credit with its charge, and reads it back at the tip and in the statement" testChargeCreditRoundTrip it "stores transfers and an upgrade with the purchase each names, and reads them back at the tip and in the statement" testTransferEntriesRoundTrip it "refuses a ledger row naming a purchase key with no purchase, writing nothing" testUnknownPurchaseKeyRefused it "moves a store payment to a new key, ending the old purchase's share with a transferOut naming it" testPaymentRebind it "moves nothing when the old purchase's ledger or the payment's binding moved since they were read" testPaymentRebindLostRace it "reads the latest credential expiry of the purchase a payment last moved from" testMovedFromExpiry describe "managed group" $ do it "round-trips and bootstraps once" testManagedGroupRoundTripsAndBootstrapsOnce it "refuses a second row and burns the flag per group" testManagedGroupIsSingleRow describe "multi-use" $ do it "finds a key's own purchase with its newest credential, and none for another key" testKeyPurchaseLookup it "gives concurrent claims exactly the code's uses" testMultiUseConcurrentClaimsUpToLimit it "refuses a second claim by the same key without using up a use" testSameKeyClaimsOnce describe "store service catalog seed" $ do it "writes the compiled-in catalog into an empty database" testSeedWritesTheCatalog it "leaves exactly one row per id when the service starts twice" testSeedIsIdempotent it "leaves a price and an offer an operator withdrew withdrawn" testSeedNeverResurrectsAWithdrawnRow it "seeds exactly what web/src/catalog.ts compiles into the page" testSeedMatchesWebCatalog it "prices a btc checkout, and answers card provider_unavailable" testSeededCatalogSellsSomething describe "StoreService.Providers stub" $ do it "constructs a Provider record and records every call, in order" testStubProviderRecordsCalls it "makes pCreateInvoice fail on demand, so provider_unavailable is exercisable" testStubProviderCreateFailsOnDemand it "records nothing when the caller never reaches the provider" testStubProviderNotCalledWhenSkipped describe "store service log" $ it "keeps each record whole on its own line while many threads log at once, in every buffering mode" testLogRecordsWhole describe "store service web" $ do it "serves the shell at / and a hashed asset under /assets" testServesTheBuild it "fills the shell's publishable key from the ini at boot" testShellCarriesPublishableKey it "caches by where the file is, not by how the request spelled it" testCachingFollowsTheResolvedPath it "serves the built web app, exactly as it is shipped" testServesBuiltWebApp it "serves no static routes when serve_webapp is off, and still answers the API" testServeWebappOff it "exports the injected webapp to a folder for a proxy to serve" testExportWebapp it "refuses every traversal spelling, by canonicalisation" testTraversalRefused it "answers the read fields, with cryptoCurrency lowercase" testInvoiceView it "names the confirmations settlement needs, from the speed policy" testViewNamesTheConfirmationsSettlementNeeds it "omits the confirmations where no provider is configured" testViewOmitsConfirmationsWithoutBTCPay it "carries the provider's paid-in-full verdict, which the amounts cannot give" testViewCarriesTheProvidersPaidVerdict it "never withdraws a paid-in-full verdict on a later read" testPaidVerdictIsNotWithdrawnByALaterRead it "carries a closed invoice's paid-in-full verdict, the status staying expired" testViewCarriesAClosedPaidVerdict it "answers an unknown id with 404 not_found and nothing else" testUnknownInvoiceIsOpaque it "releases a held wait when a payment lands without settling" testHeldWaitWakesOnAPaymentThatDoesNotSettle it "leaves a held wait alone when a pass writes nothing" testHeldWaitIsNotWokenByAPassThatWroteNothing it "is not woken by the same figures arriving again" testARepeatedSignalDoesNotWakeAHold it "carries Cache-Control: no-store on every API response" testApiIsNeverCached it "answers a known path reached with the wrong method with 405" testWrongMethodIs405 it "answers a path this task does not implement with 404" testUnroutedPathIs404 it "answers ?wait= at once when it is terminal, stale or unparseable" testWaitAnswersAtOnce it "answers at once when it holds a payment the page has not seen" testHoldAnswersAPaymentThePageHasNotSeen it "answers a verdict that arrived with no figure to go with it" testHoldAnswersAVerdictWithNoFigure it "holds ?wait=open, and is woken by publish rather than polled" testHoldIsWokenNotPolled it "refuses over sixty reads a minute from one IP" testReadRateLimit it "takes the client from X-Forwarded-For only when it is trusted" testForwardedForOnlyWhenTrusted it "uses a forwarded client only when it parses as an IP address" testForwardedForMustBeAnAddress it "holds the bucket map under its cap through a flood of distinct clients" testBucketsStayBounded it "answers a hold from the row it read, not from the status it woke on" testHoldReportsTheRowNotTheWake it "answers a hold that timed out with a change nothing published" testHoldTimeoutReportsAnUnpublishedChange it "answers a handler exception with 500 internal, and keeps serving" testHandlerExceptionIsContained it "logs a handler exception as one service log record, never a bare line" testHandlerExceptionIsLogged describe "StoreService names: sites" $ do it "exports both sites, each with the publishable key" testExportsBothSites it "serves each site on its own port only, whatever the Host, and the API on both" testEachPortServesItsSite it "listens on the badges port alone without the names site" testOneListenerWithoutTheNamesSite it "serves a names asset with its type" testServesANamesAsset it "keeps every traversal spelling on the names port inside the names build" testNamesTraversalRefused it "refuses a build folder that is not there, only where it is served or exported" testSiteDirsMustExist describe "store service settlement" $ do it "settles an open invoice: paid, a settled payment row, and the code" testSettlesAnOpenInvoice it "settles an expired invoice, because late settlement is routine" testLateSettlementIsLegal it "records what arrived without moving an unsettled invoice" testFundedRecordsWithoutMoving it "expires an open invoice whose window closed, recording what arrived" testClosedExpiresAnOpenInvoice it "writes no payment for a window that closed on nothing" testClosedWithNothingWritesNoPayment it "rewrites the same values when a closed window is redelivered" testClosedReplayIsIdempotent it "changes nothing for any signal against a paid invoice" testPaidRefusesEverySignal it "takes the larger of the stored and the reported amount" testAmountIsMonotonic it "measures the code deadline from the first settlement" testDeadlineIsFromTheFirstSettlement it "leaves an invoice another transaction moved first alone" testStatusGuardRefusesAStaleObservation it "publishes what it found when it loses the status guard" testLosingTheStatusGuardStillWakesTheHold it "never downgrades a settled payment row" testSettledPaymentIsNotDowngraded it "fills in a crypto amount that only arrived later" testCryptoAmountFillsInFromNull it "answers Left for an invoice this service does not hold" testUnknownInvoiceSettlesNothing it "publishes after the commit, so a woken reader finds the row" testPublishIsAfterCommit it "wakes a request the listener is holding, from settleOrder itself" testSettlementWakesAHeldRequest it "reports settledAt from the payment row, not from the invoice" testSettledAtIsThePaymentRow it "refuses a settlement instant the provider cannot have meant" testAbsurdSettledInstantIsRefused it "refuses a settlement instant in the future, as a millisecond clock gives" testFutureSettledInstantIsRefused describe "store service poller" $ do it "settles a payment with no webhook delivered anywhere" testSettlesWithNoWebhookAtAll it "reads only the invoices it is waiting on, and lists on the stray cadence" testPassReadsWhatItAwaits it "asks the provider nothing when it is waiting on nothing" testIdlePassAsksNothing it "lists instead of reading once too many invoices are open" testManyOpenInvoicesList it "sweeps nothing when no provider can account for the rows" testNoProviderAccountsForNothing it "holds the sweep for one row a configured provider does not own" testMixedProvidersHoldTheSweep it "sends one list for a bulk pass, and none for the next one that reads" testBulkListIsTheOnlyList it "hands every signal the pass returned to settleOrder, unless the row answers it" testEverySignalSettles it "passes over a listed invoice this service does not hold" testForeignRefIsPassedOver it "changes nothing when the provider fails, and settles on the next pass" testProviderFailureLosesNothing it "reads the cadence fresh from the waiters each pass" testCadenceFollowsTheWaiters it "cuts an idle sleep short when a browser arrives during it" testWaiterCutsTheIdleSleepShort it "serves a hint batch without postponing a pass that falls due" testHintsDoNotPostponeThePass it "floors the cadence, so a zero in the ini is not a busy loop" testCadenceHasAFloor it "wakes a request the listener is holding, from a poller pass" testPollerWakesAHeldRequest it "expires an open invoice past the ten-minute grace, and reads before it does" testSweepExpiresPastTheGrace it "writes status alone, so a later SigClosed still records what arrived" testSweepWritesStatusAlone it "wakes a request held on an invoice it expires" testSweepWakesAHeldRequest it "cancels at a provider that keeps its invoice payable, then expires it" testSweepCancelsBeforeExpiring it "keeps an invoice open while its provider refuses the cancel" testSweepKeepsOpenWhenCancelFails it "settles an invoice past the settle window whose cancel is refused" testSweepSettlesWhatItCannotCancel it "expires an invoice whose cancel answer was lost, from the read that follows" testSweepExpiresWhatAReadShowsCancelled it "expires an invoice past the settle window once its cancel has failed for an hour, and reports it" testSweepGivesUpPastTheWindow it "cancels overdue invoices oldest first, keeping the failures of those left for later" testSweepKeepsFailuresBeyondTheCap it "expires an old invoice whose provider is gone, without a call" testSweepExpiresWithoutItsProvider it "warns once per order while a cancel keeps failing" testSweepWarnsOncePerOrder it "cancels at most a pass-sized batch, leaving the rest for the next pass" testSweepCapsCancelsPerPass it "reports a skipped invoice once, and again only after the interval" testSkipWarningsAreRateLimited it "warns once for a provider that stays down, not once a pass" testOutageWarnsOnceNotEveryPass it "holds the skip log under its cap when every reason is fresh" testSkipReasonsStayBounded it "warns once for all the invoices it did not sell, not once per invoice" testStrangerSkipsShareOneWarning it "raises a skip naming an invoice this service sold" testSkipNamingOurInvoiceIsRaised it "holds the sweep back until a pass has accounted for every invoice" testSweepWaitsForAPassThatSawEverything it "settles and sweeps the rest of the pass around an invoice that throws" testOneBadInvoiceDoesNotStopThePass it "pages past a hundred open invoices, a request per hundred" testListPaginates it "stops paging at its ceiling rather than looping on a server that ignores take" testPagingStopsAtTheCeiling describe "store service webhook" $ do it "verifies a real BTCPay signature over the bytes as received, and queues" testWebhookVerifiesARealSignature it "hands the adapter the raw body, byte for byte" testWebhookPassesTheRawBytes it "queues a read and answers 200 empty, reaching no provider" testWebhookQueuesARead it "settles by the poller's own path, so there is one settlement lane" testWebhookSettlesByThePollerPath it "answers at once even when a read at the provider takes a second" testWebhookDoesNotWaitOnTheProvider it "answers 400 empty, with no detail, for a signature that does not verify" testWebhookRefusesASignature it "answers 200 for an unhandled type, an unknown ref and the other lane's ref" testWebhookIgnoresWhatItCannotActOn it "refuses a body over the 64 KB cap before parsing or verifying it" testWebhookRefusesAnOversizedBody it "queues a read of an order once, however often it is hinted before being served" testReadHintQueuedOnce it "drops a hint rather than blocking when the queue is full" testWebhookDropsAHintWhenFull it "answers 200 rather than any 5xx when the adapter or the store throws" testWebhookNeverAnswers5xx describe "store service checkout" $ do it "answers the checkout fields and writes the three rows" testCreateInvoice it "carries no code and no code hash, over the raw bytes" testCreateCarriesNoCode it "tells the provider xmr rather than the chain another request used" testXmrReachesTheProviderAsXmr it "derives badgeType, months and amount from the catalog, never from the body" testCreateDerivesFromCatalog it "refuses every catalog_changed condition before the provider" testCatalogRefusalCostsNothing it "refuses a code_hash already sold before the provider" testCodeConflictCostsNothing it "maps a code_hash unique violation that raced the check to 409, not 500" testRacingCodeHashIsConflict it "answers 500 and names the abandoned invoice when the store is lost" testStoreLostAfterTheProviderCreated it "refuses a malformed body, an unknown method and a malformed codeHash" testBadRequestCostsNothing it "refuses a body over the size cap before reading it all" testOversizedBodyCostsNothing it "answers method card with provider_unavailable when no Stripe provider is configured" testCardUnconfigured503 it "answers a provider that failed to create with provider_unavailable, writing nothing" testProviderFailureWritesNothing it "refuses the sixth create in a minute, without reaching the provider" testCreateRateLimit describe "store service card" $ do it "creates a Stripe PaymentIntent, carrying its clientSecret and a stripe invoice row" testCardCreatesSession it "cancels an overdue card order's intent at Stripe before expiring it" testCardSweepCancelsTheIntent it "derives the shown expiry from stripe.session_minutes, not the btcpay window" testCardExpiryFollowsSessionMinutes it "settles a card invoice from a signed payment_intent.succeeded and a pass" testCardWebhookSettles describe "store service cancel" $ do it "expires the invoice here and invalidates it at the provider" testCancelClosesTheInvoiceAtBothEnds it "wakes a hold another tab is sitting on" testCancelWakesAHeldRequest it "refuses to cancel an invoice that is no longer open" testCancelIsRefusedOnceItIsNotOpen it "refuses to cancel an invoice awaiting confirmation" testCancelIsRefusedOnceItIsFunded it "leaves the invoice open when the provider refuses" testCancelLeavesTheInvoiceOpenWhenTheProviderFails it "expires an invoice a payment landed on while the provider was being told" testCancelExpiresAFundedInvoice it "answers an unknown id with the same 404 the read does" testCancelIsOpaqueForAnUnknownInvoice it "answers GET with 405 and Allow: POST" testCancelRefusesOtherMethods describe "store service scenarios" $ do it "buys a code: create, pay at BTCPay, one pass, paid" scenarioPaidPurchase it "wakes a wait held from before the settlement, within a second of it" scenarioHeldWaitWakes it "reports a partial payment and leaves the invoice open" scenarioPartPaymentIsReported it "reports what an expiry received, and sells no code for it" scenarioExpiryReportsWhatArrived it "settles an expired invoice late, because confirmation after expiry is routine" scenarioLateSettlement it "changes nothing when the same settlement is seen a second time" scenarioReplayChangesNothing it "closes an invalid invoice as expired, on the same screen" scenarioInvalidClosesAsExpired it "refuses a second invoice for one code hash, creating none at BTCPay" scenarioCodeConflictCreatesNothing it "settles with no webhook delivered, by reading the invoice it is waiting on" scenarioNoWebhookAnywhere testServiceColumns :: IO () testServiceColumns = withServiceStore $ \st -> do columnsOf st "sx_badge_service_badge_code_invoices" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["badge_code_id", "provider_ref"]) columnsOf st "sx_badge_service_payments" >>= (`shouldSatisfy` elem "crypto_paid") columnsOf st "sx_badge_service_badge_codes" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["expires_at", "revoked_at"]) assertSubscriptionColumns st assertSubscriptionColumns :: HasCallStack => DBStore -> IO () assertSubscriptionColumns st = do columnsOf st "sx_badge_service_subscription_charges" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["charge_id", "payment_id", "provider_charge_ref", "plan_id", "period_start", "period_end", "amount", "currency", "created_at"]) columnsOf st "sx_badge_service_badge_ledger" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["charge_id", "from_purchase_id", "to_purchase_id"]) payments <- columnsOf st "sx_badge_service_payments" payments `shouldSatisfy` \cs -> all (`elem` cs) ["subscription_state", "subscription_checked_at", "provider_token", "rebound_at"] payments `shouldSatisfy` \cs -> not (any (`elem` cs) ["subscription_renews_at", "grace_until", "cancelled"]) testGroupOpsColumns :: IO () testGroupOpsColumns = withServiceStore assertGroupOpsColumns assertGroupOpsColumns :: HasCallStack => DBStore -> IO () assertGroupOpsColumns st = do columnsOf st "sx_badge_service_badge_codes" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["redeem_limit", "redeem_count", "group_item_id", "group_item_sent_at"]) columnsOf st "sx_badge_service_group" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["group_id", "group_link", "owner_bootstrapped", "created_at"]) runMigrations :: DBStore -> MigrationsToRun -> IO () #if defined(dbPostgres) runMigrations st = Migrations.run st Nothing #else runMigrations st = Migrations.run st Nothing True #endif -- | Applied migrations must be a prefix of this list by name, so a migration of ours dated -- before one of master's breaks upgrading a badge-service database. testMigrationOrder :: IO () testMigrationOrder = do let declared = map (\(n, _, _) -> n) schemaMigrations [(a, b) | (a, b) <- zip declared (drop 1 declared), a >= b] `shouldBe` [] let (badges, ours) = span (`elem` badgeMigrations) (map name storeServiceSchemaMigrations) badges `shouldBe` badgeMigrations ours `shouldBe` ["20261013_names", "20261022_drop_taken_codes", "20261022_name_balances", "20261022_name_late_wait", "20261023_name_terms", "20261027_name_invoiced_units"] where badgeMigrations = ["20260915_badge_service_schema", "20260918_badge_group_ops", "20260925_badge_store_receipts", "20261012_badge_subscriptions"] testSchemaDownUpCycle :: IO () testSchemaDownUpCycle = withServiceStore $ \st -> do let downMigrations = mapMaybe toDownMigration storeServiceSchemaMigrations length downMigrations `shouldBe` length storeServiceSchemaMigrations runMigrations st $ MTRDown downMigrations columnsOf st "sx_badge_service_badge_codes" `shouldReturn` [] columnsOf st "sx_badge_service_group" `shouldReturn` [] columnsOf st "sx_badge_service_subscription_charges" `shouldReturn` [] runMigrations st $ MTRUp storeServiceSchemaMigrations assertGroupOpsColumns st assertSubscriptionColumns st testSubscriptionsDownUp :: IO () testSubscriptionsDownUp = withServiceStore $ \st -> do let subscriptions = [m | m@Migration {name = "20261012_badge_subscriptions"} <- storeServiceSchemaMigrations] length subscriptions `shouldBe` 1 runMigrations st $ MTRDown (mapMaybe toDownMigration subscriptions) columnsOf st "sx_badge_service_subscription_charges" `shouldReturn` [] columnsOf st "sx_badge_service_badge_ledger" >>= (`shouldNotSatisfy` \cs -> any (`elem` cs) ["charge_id", "from_purchase_id", "to_purchase_id"]) payments <- columnsOf st "sx_badge_service_payments" payments `shouldSatisfy` \cs -> all (`elem` cs) ["subscription_renews_at", "grace_until", "cancelled"] payments `shouldSatisfy` \cs -> not (any (`elem` cs) ["subscription_state", "subscription_checked_at", "provider_token", "rebound_at"]) runMigrations st $ MTRUp subscriptions assertSubscriptionColumns st testGroupOpsDownUp :: IO () testGroupOpsDownUp = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime let redeemedHash = digestFixture 44 unredeemedHash = digestFixture 45 groupOps = [m | m@Migration {name = "20260918_badge_group_ops"} <- storeServiceSchemaMigrations] redeemed <- insertCode st redeemedHash CPSPaid 2 now void $ insertCode st unredeemedHash CPSPaid 1 now -- Two purchases of one code would break a down migration that restored the unique index. replicateM_ 2 $ newPurchaseKeys >>= claimUse st redeemed now >>= (`shouldSatisfy` isJust) runMigrations st $ MTRDown (mapMaybe toDownMigration groupOps) columnsOf st "sx_badge_service_badge_codes" >>= (`shouldNotSatisfy` elem "redeem_count") runMigrations st $ MTRUp groupOps codeCounts st redeemedHash `shouldReturn` Just (1, 1) codeCounts st unredeemedHash `shouldReturn` Just (1, 0) testGroupOpsRedeemCounts :: IO () testGroupOpsRedeemCounts = withServiceStore $ \st -> do let codeHash = digestFixture 43 withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)" (DB.Binary codeHash, "supporter" :: Text, 1 :: Int, "free" :: Text, someCreated) codeCounts st codeHash `shouldReturn` Just (1, 0) testProviderRefUnique :: IO () testProviderRefUnique = withServiceStore $ \st -> do seedBadgePrice st "price1" seedInvoice st "inv1" seedInvoice st "inv2" insertBadgeCodeInvoice st "inv1" "price1" "same-provider-ref" insertBadgeCodeInvoice st "inv2" "price1" "same-provider-ref" `shouldThrow` anyException seedInvoice :: DBStore -> Text -> IO () seedInvoice st invoiceId = withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_invoices (invoice_id, provider, price, amount, currency, expires_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (invoiceId, "btcpay" :: Text, 500 :: Int, 500 :: Int, "usd" :: Text, "2030-01-01T00:00:00Z" :: Text, "open" :: Text, "2026-08-31T00:00:00Z" :: Text, "2026-08-31T00:00:00Z" :: Text) seedBadgePrice :: DBStore -> Text -> IO () seedBadgePrice st priceId = withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_badge_prices (price_id, badge_type, month_price, currency, status, created_at) VALUES (?,?,?,?,?,?)" (priceId, "supporter" :: Text, 500 :: Int, "usd" :: Text, "active" :: Text, "2026-08-31T00:00:00Z" :: Text) insertBadgeCodeInvoice :: DBStore -> Text -> Text -> Text -> IO () insertBadgeCodeInvoice st invoiceId priceId providerRef = withConnection st $ \db -> do let codeHash = DB.Binary (digestFixture (fromIntegral (sum (map fromEnum (T.unpack invoiceId))))) DB.execute db "INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)" (codeHash, "supporter" :: Text, 1 :: Int, "unpaid" :: Text, "2026-08-31T00:00:00Z" :: Text) DB.execute db "INSERT INTO sx_badge_service_badge_code_invoices (invoice_id, badge_code_id, price_id, created_at, provider_ref) SELECT ?, badge_code_id, ?, ?, ? FROM sx_badge_service_badge_codes WHERE code_hash = ?" (invoiceId, priceId, "2026-08-31T00:00:00Z" :: Text, providerRef, codeHash) someExpiry :: UTCTime someExpiry = UTCTime (fromGregorian 2030 1 1) 0 someCreated :: UTCTime someCreated = UTCTime (fromGregorian 2026 8 31) 0 -- | These 32 bytes are all above 0x7f, so the value is not valid UTF-8 and catches the -- postgresql-simple bug where a plain ByteString is written as escaped text. digestFixture :: Word8 -> ByteString digestFixture n = BS.pack [0x80 + ((n * 7 + i) `mod` 0x80) | i <- [0 .. 31]] sampleInvoice :: NewInvoice sampleInvoice = NewInvoice { niInvoiceId = InvoiceId "inv1", niProviderRef = "provider-ref-1", niCodeHash = digestFixture 1, niPriceId = BadgePriceId "price1", niOfferId = Nothing, niBadgeType = BTSupporter, niMonths = 1, niPrice = CurrencyAmount 500, niAmount = CurrencyAmount 500, niCurrency = "usd", niProvider = PPCrypto, niDestination = SPDCrypto CCBtc "bc1qexampleaddress" "0.00050000", niExpiresAt = someExpiry, niCreatedAt = someCreated } codeInvoiceRow :: DBStore -> InvoiceId -> IO (Maybe Text) codeInvoiceRow st (InvoiceId invId) = withConnection st $ \db -> do rows <- DB.query db "SELECT invoice_id FROM sx_badge_service_badge_code_invoices WHERE invoice_id = ?" (Only invId) :: IO [Only Text] pure $ case rows of [] -> Nothing (Only r : _) -> Just r markPaid :: DBStore -> InvoiceId -> IO () markPaid st (InvoiceId invId) = withConnection st $ \db -> DB.execute db "UPDATE sx_badge_service_invoices SET status = 'paid' WHERE invoice_id = ?" (Only invId) insertPrice :: DBStore -> Text -> Text -> Int -> Text -> IO () insertPrice st priceId badgeType monthPrice status = withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_badge_prices (price_id, badge_type, month_price, currency, status, created_at) VALUES (?,?,?,?,?,?)" (priceId, badgeType, monthPrice, "usd" :: Text, status, "2026-08-31T00:00:00Z" :: Text) insertOffer :: DBStore -> Text -> Maybe Text -> Int -> Int -> Text -> IO () insertOffer st offerId priceId months discount status = withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_badge_offers (offer_id, price_id, months, discount, status, created_at) VALUES (?,?,?,?,?,?)" (offerId, priceId, months, discount, status, "2026-08-31T00:00:00Z" :: Text) testCreationIsAtomic :: IO () testCreationIsAtomic = withServiceStore $ \st -> do seedBadgePrice st "price1" let duplicateHash = digestFixture 9 planted = sampleInvoice {niInvoiceId = InvoiceId "other", niProviderRef = "p-other", niCodeHash = duplicateHash} ni = sampleInvoice {niCodeHash = duplicateHash} planted' <- createInvoiceRows st planted planted' `shouldBe` Right () r <- createInvoiceRows st ni r `shouldSatisfy` isLeft r `shouldBe` Left CECodeConflict getInvoice st (niInvoiceId ni) `shouldReturn` Nothing codeInvoiceRow st (niInvoiceId ni) `shouldReturn` Nothing testNewInvoiceIdRandom :: IO () testNewInvoiceIdRandom = do InvoiceId a <- newInvoiceId InvoiceId b <- newInvoiceId a `shouldNotBe` b T.length a `shouldBe` 22 T.any (== '=') a `shouldBe` False testCodeHashExists :: IO () testCodeHashExists = withServiceStore $ \st -> do seedBadgePrice st "price1" createInvoiceRows st sampleInvoice `shouldReturn` Right () codeHashExists st (niCodeHash sampleInvoice) `shouldReturn` True codeHashExists st "no-such-hash" `shouldReturn` False testExpireOverdueMovesOnlyQualifying :: IO () testExpireOverdueMovesOnlyQualifying = withServiceStore $ \st -> do seedBadgePrice st "price1" now <- getCurrentTime let pastExpiry = addUTCTime (-3600) now futureExpiry = addUTCTime 3600 now overdue = sampleInvoice {niExpiresAt = pastExpiry} notYet = sampleInvoice {niInvoiceId = InvoiceId "inv-notyet", niProviderRef = "p-notyet", niCodeHash = digestFixture 13, niExpiresAt = futureExpiry} alreadyPaid = sampleInvoice {niInvoiceId = InvoiceId "inv-paid", niProviderRef = "p-paid", niCodeHash = digestFixture 14, niExpiresAt = pastExpiry} createInvoiceRows st overdue `shouldReturn` Right () createInvoiceRows st notYet `shouldReturn` Right () createInvoiceRows st alreadyPaid `shouldReturn` Right () markPaid st (niInvoiceId alreadyPaid) map (\OverdueInvoice {oiInvoiceId, oiProviderRef} -> (oiInvoiceId, oiProviderRef)) <$> overdueInvoices st now `shouldReturn` [(niInvoiceId overdue, niProviderRef overdue)] moved <- expireAllOverdue st now moved `shouldBe` [niInvoiceId overdue] Just overdueRow <- getInvoice st (niInvoiceId overdue) irStatus overdueRow `shouldBe` ISExpired Just notYetRow <- getInvoice st (niInvoiceId notYet) irStatus notYetRow `shouldBe` ISOpen Just paidRow <- getInvoice st (niInvoiceId alreadyPaid) irStatus paidRow `shouldBe` ISPaid testExpireOverdueMovesOnlyTheNamed :: IO () testExpireOverdueMovesOnlyTheNamed = withServiceStore $ \st -> do seedBadgePrice st "price1" now <- getCurrentTime let pastExpiry = addUTCTime (-3600) now named = sampleInvoice {niExpiresAt = pastExpiry} unnamed = sampleInvoice {niInvoiceId = InvoiceId "inv-unnamed", niProviderRef = "p-unnamed", niCodeHash = digestFixture 13, niExpiresAt = pastExpiry} createInvoiceRows st named `shouldReturn` Right () createInvoiceRows st unnamed `shouldReturn` Right () expireNamed st now [niInvoiceId named] `shouldReturn` [niInvoiceId named] invoiceStatus st (niInvoiceId named) `shouldReturn` ISExpired invoiceStatus st (niInvoiceId unnamed) `shouldReturn` ISOpen expireNamed st now [niInvoiceId named] `shouldReturn` [] expireAllOverdue :: DBStore -> UTCTime -> IO [InvoiceId] expireAllOverdue st now = overdueInvoices st now >>= expireNamed st now . map oiInvoiceId expireNamed :: DBStore -> UTCTime -> [InvoiceId] -> IO [InvoiceId] expireNamed st now invIds = newWaiters >>= \waiters -> expireOverdue st waiters Nothing now now invIds testRevokeAndRedeemExcludeEachOther :: IO () testRevokeAndRedeemExcludeEachOther = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime keys <- newPurchaseKeys let newCode codeHash = insertCode st codeHash CPSPaid 1 now redeem badgeCodeId = claimUse st badgeCodeId now keys revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now revokedFirst <- newCode "revoked-first" revoke "revoked-first" `shouldReturn` Revoked revokedFirst redeem revokedFirst `shouldReturn` Nothing codeCounts st "revoked-first" `shouldReturn` Just (1, 0) redeemedFirst <- newCode "redeemed-first" isJust <$> redeem redeemedFirst `shouldReturn` True revoke "redeemed-first" `shouldReturn` AlreadyRedeemed redeem redeemedFirst `shouldReturn` Nothing testRevokeMultiUseCode :: IO () testRevokeMultiUseCode = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime let newCode codeHash = insertCode st codeHash CPSPaid 2 now -- A purchase key redeems once, so every redemption here brings its own. redeem badgeCodeId = newPurchaseKeys >>= claimUse st badgeCodeId now revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now partlyUsed <- newCode "partly-used" isJust <$> redeem partlyUsed `shouldReturn` True revoke "partly-used" `shouldReturn` Revoked partlyUsed redeem partlyUsed `shouldReturn` Nothing usedUp <- newCode "used-up" isJust <$> redeem usedUp `shouldReturn` True isJust <$> redeem usedUp `shouldReturn` True revoke "used-up" `shouldReturn` AlreadyRedeemed testReadCatalogRowsDropsDisabled :: IO () testReadCatalogRowsDropsDisabled = withServiceStore $ \st -> do insertPrice st "price-active" "supporter" 500 "active" insertPrice st "price-disabled" "supporter" 500 "disabled" insertOffer st "offer-active" (Just "price-active") 3 10 "active" insertOffer st "offer-disabled" (Just "price-active") 3 10 "disabled" (prices, offers) <- readCatalogRows st map (\BadgePrice {priceId} -> priceId) prices `shouldBe` [BadgePriceId "price-active"] map (\BadgeOffer {offerId} -> offerId) offers `shouldBe` [BadgeOfferId "offer-active"] seedDefaultCatalog :: DBStore -> UTCTime -> IO (Int, Int) seedDefaultCatalog st at = uncurry (seedCatalog st) (defaultCatalog at) priceFacts :: BadgePrice -> (Text, Text, Word32, Text, BadgeItemStatus) priceFacts BadgePrice {priceId = BadgePriceId pId, badgeType = bType, monthPrice = CurrencyAmount mPrice, currency = cur, status = pStatus} = (pId, textEncode bType, mPrice, cur, pStatus) offerFacts :: BadgeOffer -> (Text, Maybe Text, Word8, OfferDiscount, BadgeItemStatus) offerFacts BadgeOffer {offerId = BadgeOfferId oId, priceId = oPriceId, months = oMonths, discount = oDiscount, status = oStatus} = (oId, (\(BadgePriceId p) -> p) <$> oPriceId, oMonths, oDiscount, oStatus) allPriceRows :: DBStore -> IO [(Text, Text, Word32, Text, Text, UTCTime)] allPriceRows st = withConnection st $ \db -> DB.query_ db "SELECT price_id, badge_type, month_price, currency, status, created_at FROM sx_badge_service_badge_prices ORDER BY price_id" allOfferRows :: DBStore -> IO [(Text, Maybe Text, Word32, Maybe Word32, Maybe Word32, Text, UTCTime)] allOfferRows st = withConnection st $ \db -> DB.query_ db "SELECT offer_id, price_id, months, free_months, discount, status, created_at FROM sx_badge_service_badge_offers ORDER BY offer_id" testSeedWritesTheCatalog :: IO () testSeedWritesTheCatalog = withServiceStore $ \st -> do readCatalogRows st `shouldReturn'` (0, 0) seedDefaultCatalog st someCreated `shouldReturn` (2, 4) (prices, offers) <- readCatalogRows st sortOn (\(i, _, _, _, _) -> i) (map priceFacts prices) `shouldBe` [ ("price_legend", "legend", 7000, "usd", BISActive), ("price_supporter", "supporter", 700, "usd", BISActive) ] sortOn (\(i, _, _, _, _) -> i) (map offerFacts offers) `shouldBe` [ ("offer_12m", Just "price_legend", 12, ODDiscount 50, BISActive), ("offer_12m_s", Just "price_supporter", 12, ODDiscount 50, BISActive), ("offer_3m", Just "price_legend", 3, ODFreeMonths 1, BISActive), ("offer_3m_s", Just "price_supporter", 3, ODFreeMonths 1, BISActive) ] where shouldReturn' action expected = do (ps, os) <- action (length ps, length os) `shouldBe` expected testSeedIsIdempotent :: IO () testSeedIsIdempotent = withServiceStore $ \st -> do seedDefaultCatalog st someCreated `shouldReturn` (2, 4) let restartedAt = addUTCTime 86400 someCreated seedDefaultCatalog st restartedAt `shouldReturn` (0, 0) priceRows <- allPriceRows st offerRows <- allOfferRows st map (\(i, _, _, _, _, _) -> i) priceRows `shouldBe` ["price_legend", "price_supporter"] map (\(i, _, _, _, _, _, _) -> i) offerRows `shouldBe` ["offer_12m", "offer_12m_s", "offer_3m", "offer_3m_s"] map (\(_, _, _, _, _, at) -> at) priceRows `shouldBe` replicate 2 someCreated map (\(_, _, _, _, _, _, at) -> at) offerRows `shouldBe` replicate 4 someCreated testSeedNeverResurrectsAWithdrawnRow :: IO () testSeedNeverResurrectsAWithdrawnRow = withServiceStore $ \st -> do insertPrice st "price_supporter" "supporter" 900 "deprecated" insertPrice st "price_legend" "legend" 9000 "disabled" insertOffer st "offer_12m" (Just "price_legend") 12 25 "disabled" seedDefaultCatalog st someCreated `shouldReturn` (0, 3) priceRows <- allPriceRows st map (\(i, _, mp, _, s, _) -> (i, mp, s)) priceRows `shouldBe` [("price_legend", 9000, "disabled"), ("price_supporter", 900, "deprecated")] offerRows <- allOfferRows st map (\(i, _, m, _, d, s, _) -> (i, m, d, s)) offerRows `shouldBe` [ ("offer_12m", 12, Just 25, "disabled"), ("offer_12m_s", 12, Just 50, "active"), ("offer_3m", 3, Nothing, "active"), ("offer_3m_s", 3, Nothing, "active") ] (prices, offers) <- readCatalogRows st sortOn (\(i, _, _, _, _) -> i) (map priceFacts prices) `shouldBe` [("price_supporter", "supporter", 900, "usd", BISDeprecated)] map (\(i, _, _, _, _) -> i) (sortOn (\(i, _, _, _, _) -> i) (map offerFacts offers)) `shouldBe` ["offer_12m_s", "offer_3m", "offer_3m_s"] testSeedMatchesWebCatalog :: IO () testSeedMatchesWebCatalog = withServiceStore $ \st -> do src <- T.readFile "apps/simplex-store-service/web/src/catalog.ts" (webPrices, webOffers) <- case parseCatalogSource src of Nothing -> failWith "could not parse CATALOG out of web/src/catalog.ts -- its shape has changed" Just parsed -> pure parsed webPrices `shouldNotBe` [] webOffers `shouldNotBe` [] seedDefaultCatalog st someCreated `shouldReturn` (length webPrices, length webOffers) (prices, offers) <- readCatalogRows st sortOn (\(i, _, _, _) -> i) (map (\(i, t, mp, cur, _) -> (i, t, mp, cur)) (map priceFacts prices)) `shouldBe` sortOn (\(i, _, _, _) -> i) (map (\WebPrice {wpPriceId, wpBadgeType, wpMonthPrice, wpCurrency} -> (wpPriceId, wpBadgeType, wpMonthPrice, wpCurrency)) webPrices) sortOn (\(i, _, _, _) -> i) (map (\(i, p, m, d, _) -> (i, p, m, d)) (map offerFacts offers)) `shouldBe` sortOn (\(i, _, _, _) -> i) (map (\WebOffer {woOfferId, woPriceId, woMonths, woDiscount} -> (woOfferId, Just woPriceId, woMonths, woDiscount)) webOffers) testSeededCatalogSellsSomething :: IO () testSeededCatalogSellsSomething = bounded "seeded catalog sells" $ withSeededCheckout $ \client -> do card <- postCreateAs client 1 (createBody "price_legend" (Just "offer_12m") "card" (codeHashText sampleCode)) statusOf card `shouldBe` 503 responseBody card `shouldBe` errorBody "provider_unavailable" priced <- postCreateAs client 2 (createBody "price_legend" (Just "offer_12m") "btc" (codeHashText sampleCode)) statusOf priced `shouldBe` 200 o <- jsonObject priced fieldOf o "badgeType" `shouldBe` Just (J.String "legend") fieldOf o "months" `shouldBe` Just (J.Number 12) fieldOf o "amount" `shouldBe` Just (J.Number 42000) fieldOf o "currency" `shouldBe` Just (J.String catalogCurrency) withSeededCheckout :: (WebClient -> IO a) -> IO a withSeededCheckout action = withServiceStore $ \st -> do createDirectoryIfMissing True "tests/tmp" withTempDirectory "tests/tmp" "badge-seeded" $ \root -> do staticDir <- prepareStaticDir root _ <- seedDefaultCatalog st someCreated ref <- newIORef (newStubState (Right sampleProviderInvoice)) let cfg = (testServiceConfig staticDir True) {btcpay = Just testBTCPayConfig} withListener [stubProvider ref] True holdMicros st cfg (const action) testTimestampRoundTrip :: IO () testTimestampRoundTrip = withServiceStore $ \st -> do seedBadgePrice st "price1" let subSecond = UTCTime (fromGregorian 2026 8 31) (picosecondsToDiffTime 12500000000000) truncated = UTCTime (fromGregorian 2026 8 31) (picosecondsToDiffTime 12000000000000) ni = sampleInvoice {niExpiresAt = subSecond, niCreatedAt = subSecond} createInvoiceRows st ni `shouldReturn` Right () Just row <- getInvoice st (niInvoiceId ni) irExpiresAt row `shouldBe` truncated irCreatedAt row `shouldBe` truncated testChargeCreditRoundTrip :: IO () testChargeCreditRoundTrip = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime (purchaseKey, masterKey) <- newPurchaseKeys Just purchaseId <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now withTransaction st $ \db -> insertSubscriptionCharge db "pay1" "ch1" StoreCharge {chargeRef = "2000000812345672", planId = "SUBSCR_BADGE_SUPPORTER_MONTH_01", periodStart = now, periodEnd = addMonths 1 now, chargePaid = Nothing, revoked = False, offer = False} now let charge = grantEntry now "e1" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter) withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [charge] Nothing withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just [J.toJSON charge]) . fmap (map J.toJSON) withTransaction st (`getLedgerTip` purchaseId) >>= (`shouldBe` Just (J.toJSON charge)) . fmap J.toJSON testTransferEntriesRoundTrip :: IO () testTransferEntriesRoundTrip = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime (purchaseKey, masterKey) <- newPurchaseKeys (otherKey, otherMasterKey) <- newPurchaseKeys Just purchaseId <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now Just _ <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay2", provider = PPApple, providerRef = "2000000812345673", paid = Nothing, purchaseKey = otherKey, masterKey = otherMasterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now let transferIn = grantEntry now "e1" BTSupporter 2 (SCTransferIn otherKey) (emptyEntry now BTSupporter) upgrade = debitEntry now "e2" (SDUpgrade purchaseKey) transferIn transferOut = debitEntry now "e3" (SDTransferOut otherKey) upgrade rows = [transferIn, upgrade, transferOut] withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing rows Nothing withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just (map J.toJSON rows)) . fmap (map J.toJSON) withTransaction st (`getLedgerTip` purchaseId) >>= (`shouldBe` Just (J.toJSON transferOut)) . fmap J.toJSON testUnknownPurchaseKeyRefused :: IO () testUnknownPurchaseKeyRefused = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime (purchaseKey, masterKey) <- newPurchaseKeys (unknownKey, _) <- newPurchaseKeys Just purchaseId <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Nothing, providerToken = Nothing} now let granted = grantEntry now "e1" BTSupporter 2 SCCode (emptyEntry now BTSupporter) withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [granted] Nothing let upgraded = debitEntry now "e2" (SDUpgrade purchaseKey) granted refused <- E.try $ withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [upgraded, debitEntry now "e3" (SDTransferOut unknownKey) upgraded] Nothing (refused :: Either E.SomeException ()) `shouldSatisfy` isLeft withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just [J.toJSON granted]) . fmap (map J.toJSON) testPaymentRebind :: IO () testPaymentRebind = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime (k2, mk2) <- newPurchaseKeys (p1, k1Rows@[_, issued1]) <- chargedStorePurchase st now reboundAt st "pay1" `shouldReturn` [Nothing] let transferOut = debitEntry now "e3" (SDTransferOut k2) issued1 charge2 = grantEntry now "e4" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter) Just issued2 <- pure $ issueEntry now "e5" charge2 Just p2 <- withTransaction st $ \db -> do p2_ <- rebindStorePayment db PaymentRebind {paymentId = "pay1", fromPurchaseId = p1, fromTipId = "e2", fromRows = [transferOut], purchaseKey = k2, masterKey = mk2, badgeType = BTSupporter} now forM_ p2_ $ \p2 -> appendLedgerPlan db p2 Nothing [charge2] (Just (charge2, issued2, seededCredential mk2 (balanceStartTs issued2))) pure p2_ withTransaction st (`getLedgerTip` p1) >>= (`shouldBe` Just (J.toJSON transferOut)) . fmap J.toJSON withTransaction st (\db -> getLedgerEntries db p1 0) >>= (`shouldBe` Just (map J.toJSON (k1Rows <> [transferOut]))) . fmap (map J.toJSON) withTransaction st (\db -> getLedgerEntries db p2 0) >>= (`shouldBe` Just (map J.toJSON [charge2, issued2])) . fmap (map J.toJSON) subscriptionPayment st p1 `shouldReturn` Nothing subscriptionPayment st p2 `shouldReturn` Just "pay1" creditedPurchaseId st `shouldReturn` Just p2 reboundAt st "pay1" `shouldReturn` [Just now] testPaymentRebindLostRace :: IO () testPaymentRebindLostRace = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime (k2, mk2) <- newPurchaseKeys (k3, mk3) <- newPurchaseKeys (p1, k1Rows@[charge1, issued1]) <- chargedStorePurchase st now let rebindTo (k, mk) fromTipId tip = PaymentRebind {paymentId = "pay1", fromPurchaseId = p1, fromTipId, fromRows = [debitEntry now "x" (SDTransferOut k) tip], purchaseKey = k, masterKey = mk, badgeType = BTSupporter} k1Entries = withTransaction st (\db -> getLedgerEntries db p1 0) <&> fmap (map J.toJSON) withTransaction st (\db -> rebindStorePayment db (rebindTo (k3, mk3) "e1" charge1) now) `shouldReturn` Nothing k1Entries `shouldReturn` Just (map J.toJSON k1Rows) creditedPurchaseId st `shouldReturn` Just p1 reboundAt st "pay1" `shouldReturn` [Nothing] let transferOut = debitEntry now "x" (SDTransferOut k2) issued1 Just _ <- withTransaction st $ \db -> rebindStorePayment db (rebindTo (k2, mk2) "e2" issued1) now withTransaction st (\db -> rebindStorePayment db (rebindTo (k3, mk3) "x" transferOut) (addUTCTime 60 now)) `shouldReturn` Nothing k1Entries `shouldReturn` Just (map J.toJSON (k1Rows <> [transferOut])) reboundAt st "pay1" `shouldReturn` [Just now] isNothing <$> withTransaction st (`getPurchaseByKey` k3) `shouldReturn` True testMovedFromExpiry :: IO () testMovedFromExpiry = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime [(k0, mk0), (k1, mk1), (k2, mk2)] <- replicateM 3 newPurchaseKeys let day n = addUTCTime (n * 86400) now granted uuid = grantEntry now uuid BTSupporter 1 SCCode (emptyEntry now BTSupporter) movedFromExpiry = withTransaction st (`getMovedFromExpiry` "pay1") move fromPurchaseId fromTipId (k, mk) tipId = withTransaction st $ \db -> do Just purchaseId <- rebindStorePayment db PaymentRebind {paymentId = "pay1", fromPurchaseId, fromTipId, fromRows = [debitEntry now (fromTipId <> "-out") (SDTransferOut k) (granted fromTipId)], purchaseKey = k, masterKey = mk, badgeType = BTSupporter} now appendLedgerPlan db purchaseId Nothing [granted tipId] Nothing pure purchaseId Just p0 <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey = k0, masterKey = mk0, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now withTransaction st $ \db -> appendLedgerPlan db p0 Nothing [granted "g0"] Nothing seedIssuance st p0 "i0a" mk0 now (day 10) seedIssuance st p0 "i0b" mk0 now (day 20) movedFromExpiry `shouldReturn` Nothing p1 <- move p0 "g0" (k1, mk1) "g1" seedIssuance st p1 "i1b" mk1 now (day 40) seedIssuance st p1 "i1a" mk1 now (day 30) movedFromExpiry `shouldReturn` Just (day 20) p2 <- move p1 "g1" (k2, mk2) "g2" seedIssuance st p2 "i2a" mk2 now (day 50) seedIssuance st p2 "i2b" mk2 now (day 60) movedFromExpiry `shouldReturn` Just (day 40) -- A purchase holding payment pay1 (provider ref 2000000812345671), funded by charge ch1, with its month issued as e2. chargedStorePurchase :: DBStore -> UTCTime -> IO (Int64, [StatementEntry]) chargedStorePurchase st now = do (purchaseKey, masterKey) <- newPurchaseKeys Just purchaseId <- withTransaction st $ \db -> createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now withTransaction st $ \db -> insertSubscriptionCharge db "pay1" "ch1" StoreCharge {chargeRef = "2000000812345672", planId = "SUBSCR_BADGE_SUPPORTER_MONTH_01", periodStart = now, periodEnd = addMonths 1 now, chargePaid = Nothing, revoked = False, offer = False} now let charge = grantEntry now "e1" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter) Just issued <- pure $ issueEntry now "e2" charge withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [charge] (Just (charge, issued, seededCredential masterKey (balanceStartTs issued))) pure (purchaseId, [charge, issued]) reboundAt :: DBStore -> Text -> IO [Maybe UTCTime] reboundAt st paymentId = withConnection st $ \db -> map fromOnly <$> DB.query db "SELECT rebound_at FROM sx_badge_service_payments WHERE payment_id = ?" (Only paymentId) subscriptionPayment :: DBStore -> Int64 -> IO (Maybe Text) subscriptionPayment st purchaseId = fmap (\PurchaseSubscription {paymentId} -> paymentId) <$> withTransaction st (`getPurchaseSubscription` purchaseId) creditedPurchaseId :: DBStore -> IO (Maybe Int64) creditedPurchaseId st = withTransaction st (\db -> getStorePaymentCredit db PPApple "2000000812345671") <&> \case Credited CreditedPurchase {badgePurchaseId} -> Just badgePurchaseId _ -> Nothing testManagedGroupRoundTripsAndBootstrapsOnce :: IO () testManagedGroupRoundTripsAndBootstrapsOnce = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime beforeInsert <- withTransaction st getManagedGroup beforeInsert `shouldBe` Nothing withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now afterInsert <- withTransaction st getManagedGroup afterInsert `shouldBe` Just ManagedGroup {mgGroupId = 42, mgGroupLink = "https://link", mgOwnerBootstrapped = False} show afterInsert `shouldNotSatisfy` ("https://link" `isInfixOf`) firstMark <- withTransaction st (`markOwnerBootstrapped` 42) secondMark <- withTransaction st (`markOwnerBootstrapped` 42) (firstMark, secondMark) `shouldBe` (True, False) testManagedGroupIsSingleRow :: IO () testManagedGroupIsSingleRow = withServiceStore $ \st -> do now <- truncateToSecond <$> getCurrentTime withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now withTransaction st $ \db -> insertManagedGroup db 43 "https://other" now withTransaction st $ \db -> insertManagedGroup db 42 "https://again" now managedGroupRows st `shouldReturn` [(42, "https://link")] withTransaction st (`markOwnerBootstrapped` 43) `shouldReturn` False (fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just False withTransaction st (`markOwnerBootstrapped` 42) `shouldReturn` True (fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just True managedGroupRows :: DBStore -> IO [(Int64, Text)] managedGroupRows st = withConnection st $ \db -> DB.query_ db "SELECT group_id, group_link FROM sx_badge_service_group ORDER BY group_id" insertCode :: DBStore -> ByteString -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64 insertCode st codeHash paymentStatus redeemLimit now = withTransaction st $ \db -> insertBadgeCode db codeHash BTSupporter 1 paymentStatus redeemLimit now claimUse :: DBStore -> Int64 -> UTCTime -> (C.PublicKeyEd25519, BadgeMasterKey) -> IO (Maybe Int64) claimUse st badgeCodeId now (purchaseKey, masterKey) = withTransaction st $ \db -> createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now -- The signature is dummy bytes, since only the JSON round-trip is under test; 80 is the length its decoder accepts. seededCredential :: BadgeMasterKey -> UTCTime -> BadgeCredential seededCredential masterKey expiry = BadgeCredential {badgeKeyIdx = 1, masterKey, signature = BBSSignature (BS.replicate 80 7), badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}} seedIssuance :: DBStore -> Int64 -> Text -> BadgeMasterKey -> UTCTime -> UTCTime -> IO () seedIssuance st purchaseId issuanceId masterKey at periodEnd = withConnection st $ \db -> DB.execute db "INSERT INTO sx_badge_service_badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) VALUES (?,?,?,?,?,?,?,?)" (issuanceId, purchaseId, "supporter" :: Text, at, periodEnd, periodEnd, DB.Binary (LB.toStrict (J.encode (seededCredential masterKey periodEnd))), at) testKeyPurchaseLookup :: IO () testKeyPurchaseLookup = withServiceStore $ \st -> do now <- getCurrentTime let codeHash = digestFixture 41 badgeCodeId <- insertCode st codeHash CPSFree 2 now keys@(k1, mk1) <- newPurchaseKeys Just purchaseId <- claimUse st badgeCodeId now keys withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) >>= \case KeyRedeemedUnreadable -> pure () _ -> expectationFailure "expected a purchase with no issuance to be unreadable" let firstPeriodEnd = someExpiry renewedPeriodEnd = addUTCTime 86400 someExpiry seedIssuance st purchaseId "iss-first" mk1 now firstPeriodEnd seedIssuance st purchaseId "iss-renewed" mk1 now renewedPeriodEnd (unusedKey, _) <- newPurchaseKeys withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId unusedKey) >>= \case KeyUnredeemed -> pure () _ -> expectationFailure "expected no purchase for a key that never redeemed" replayed <- withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) case replayed of KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> do badgePurchaseId `shouldBe` purchaseId credential `shouldBe` seededCredential mk1 renewedPeriodEnd _ -> expectationFailure "expected the redeemed key's purchase" testSameKeyClaimsOnce :: IO () testSameKeyClaimsOnce = withServiceStore $ \st -> do now <- getCurrentTime let codeHash = digestFixture 46 badgeCodeId <- insertCode st codeHash CPSFree 3 now keys <- newPurchaseKeys claimUse st badgeCodeId now keys >>= (`shouldSatisfy` isJust) -- The key is unique across purchases, so the second insert fails and its transaction returns the use. claimUse st badgeCodeId now keys `shouldThrow` anyException codeCounts st codeHash `shouldReturn` Just (3, 1) testMultiUseConcurrentClaimsUpToLimit :: IO () testMultiUseConcurrentClaimsUpToLimit = withServiceStore $ \st -> do now <- getCurrentTime let codeHash = digestFixture 42 badgeCodeId <- insertCode st codeHash CPSFree 3 now contenders <- replicateM 6 newPurchaseKeys results <- Async.mapConcurrently (claimUse st badgeCodeId now) contenders length (catMaybes results) `shouldBe` 3 codeCounts st codeHash `shouldReturn` Just (3, 3) data StubCall = StubCreate ServicePaymentMethod OrderDraft | StubRead Text | StubCancel Text | StubListOpen | StubRate CryptoCurrency deriving (Eq, Show) data StubState = StubState { ssCalls :: [StubCall], ssCreateResult :: Either ProviderError ProviderInvoice, ssInvoices :: Map.Map Text PaymentSignal, ssCancelError :: Maybe ProviderError, -- The cancel reaches the provider, but its answer is lost. ssCancelLost :: Bool, ssListError :: Maybe ProviderError, ssReadError :: Maybe ProviderError, ssSkipped :: [(Maybe Text, Text)], ssVerifyResult :: Either WebhookError (Maybe Text), ssVerifyThrows :: Bool, ssWebhooks :: [([Header], ByteString)], ssReadDelay :: Int, ssRate :: Either ProviderError Rate } newStubState :: Either ProviderError ProviderInvoice -> StubState newStubState ssCreateResult = StubState { ssCalls = [], ssCreateResult, ssInvoices = Map.empty, ssCancelError = Nothing, ssCancelLost = False, ssListError = Nothing, ssReadError = Nothing, ssSkipped = [], ssVerifyResult = Right Nothing, ssVerifyThrows = False, ssWebhooks = [], ssReadDelay = 0, ssRate = Right stubRate } -- | Below the stored invoices' rate of 62500. stubRate :: Rate stubRate = Rate {rateText = "60000.5", rateValue = 60000.5} record :: IORef StubState -> StubCall -> IO () record ref call = atomicModifyIORef' ref $ \s -> (s {ssCalls = ssCalls s ++ [call]}, ()) stubProvider :: IORef StubState -> Provider stubProvider ref = Provider { pProvider = PPCrypto, pCreateInvoice = \method draft -> do record ref (StubCreate method draft) ssCreateResult <$> readIORef ref, pReadInvoice = \providerRef -> do record ref (StubRead providerRef) stub <- readIORef ref threadDelay (ssReadDelay stub) pure $ maybe (Right (Map.lookup providerRef (ssInvoices stub))) Left (ssReadError stub), pCancelInvoice = \providerRef -> do record ref (StubCancel providerRef) lost <- ssCancelLost <$> readIORef ref if lost then do atomicModifyIORef' ref $ \s -> (s {ssInvoices = Map.insert providerRef (SigClosed (rcv 0 Nothing) PaidInPart) (ssInvoices s)}, ()) pure (Left (ProviderError "the response was lost")) else maybe (Right ()) Left . ssCancelError <$> readIORef ref, pListOpen = do record ref StubListOpen stub <- readIORef ref pure $ case ssListError stub of Just e -> Left e Nothing -> Right ListPass {lpMoved = Map.toList (ssInvoices stub), lpSkipped = ssSkipped stub}, pCurrentRate = Just $ \cc -> do record ref (StubRate cc) ssRate <$> readIORef ref, pVerifyWebhook = const (verifyRecording ref) } -- | 'pVerifyWebhook' is pure by design, so recording its arguments needs 'unsafePerformIO', -- and the NOINLINE effect is safe because every assertion checks an exact list of calls, so a -- lost or repeated recording fails the test rather than passing it. verifyRecording :: IORef StubState -> [Header] -> ByteString -> Either WebhookError (Maybe Text) verifyRecording ref hdrs body = unsafePerformIO $ do atomicModifyIORef' ref $ \s -> (s {ssWebhooks = ssWebhooks s ++ [(hdrs, body)]}, ()) stub <- readIORef ref if ssVerifyThrows stub then E.throwIO (userError "the adapter threw while verifying") else pure (ssVerifyResult stub) {-# NOINLINE verifyRecording #-} stubCalls :: IORef StubState -> IO [StubCall] stubCalls ref = ssCalls <$> readIORef ref stubWebhooks :: IORef StubState -> IO [([Header], ByteString)] stubWebhooks ref = ssWebhooks <$> readIORef ref setVerifyResult :: IORef StubState -> Either WebhookError (Maybe Text) -> IO () setVerifyResult ref r = atomicModifyIORef' ref $ \s -> (s {ssVerifyResult = r}, ()) setReadDelay :: IORef StubState -> Int -> IO () setReadDelay ref micros = atomicModifyIORef' ref $ \s -> (s {ssReadDelay = micros}, ()) setVerifyThrows :: IORef StubState -> Bool -> IO () setVerifyThrows ref throws = atomicModifyIORef' ref $ \s -> (s {ssVerifyThrows = throws}, ()) sampleDraft :: OrderDraft sampleDraft = OrderDraft {odAmount = CurrencyAmount 500, odCurrency = "usd", odReference = "badge:0x01"} sampleProviderInvoice :: ProviderInvoice sampleProviderInvoice = ProviderInvoice {piProviderRef = "pref-1", piDestination = SPDCrypto CCBtc "bc1qexampleaddress" "0.00050000"} testStubProviderRecordsCalls :: IO () testStubProviderRecordsCalls = do ref <- newIORef (newStubState (Right sampleProviderInvoice)) let provider = stubProvider ref created <- pCreateInvoice provider (SPMCrypto CCBtc) sampleDraft created `shouldBe` Right sampleProviderInvoice let pref = piProviderRef sampleProviderInvoice pReadInvoice provider pref `shouldReturn` Right Nothing let signal = SigFunded (rcv 500 (Just "0.00050000")) PaidInPart atomicModifyIORef' ref $ \s -> (s {ssInvoices = Map.insert pref signal (ssInvoices s)}, ()) pReadInvoice provider pref `shouldReturn` Right (Just signal) pListOpen provider `shouldReturn` Right ListPass {lpMoved = [(pref, signal)], lpSkipped = []} calls <- stubCalls ref calls `shouldBe` [StubCreate (SPMCrypto CCBtc) sampleDraft, StubRead pref, StubRead pref, StubListOpen] testStubProviderCreateFailsOnDemand :: IO () testStubProviderCreateFailsOnDemand = do let err = ProviderError "connection refused" ref <- newIORef (newStubState (Left err)) let provider = stubProvider ref pCreateInvoice provider (SPMCrypto CCBtc) sampleDraft `shouldReturn` Left err stubCalls ref `shouldReturn` [StubCreate (SPMCrypto CCBtc) sampleDraft] testStubProviderNotCalledWhenSkipped :: IO () testStubProviderNotCalledWhenSkipped = do ref <- newIORef (newStubState (Right sampleProviderInvoice)) stubCalls ref `shouldReturn` [] -- | This ceiling is shorter than the 30s hold, so a test that waits when it should not fails -- here instead of hanging. exampleCeiling :: Int exampleCeiling = 20 * 1000000 bounded :: HasCallStack => String -> IO a -> IO a bounded label action = timeout exampleCeiling action >>= maybe (failWith (label <> ": did not finish within 20s")) pure clientTimeout :: Int clientTimeout = 60 * 1000000 failWith :: HasCallStack => String -> IO a failWith msg = expectationFailure msg >> error msg outsideMarker :: LB.ByteString outsideMarker = "SECRET-OUTSIDE-STATIC-DIR" shellHtml :: LB.ByteString shellHtml = "