mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 12:57:45 +00:00
579 lines
22 KiB
Haskell
579 lines
22 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module Bots.StoreService.FakeBTCPay
|
|
( FakeBTCPay (..),
|
|
FakeRequest (..),
|
|
withFakeBTCPay,
|
|
fakeApiKey,
|
|
fakeExpiryMinutes,
|
|
setInvoiceState,
|
|
setRates,
|
|
failNextCalls,
|
|
failAfterCalls,
|
|
useListFixture,
|
|
usePaymentMethodsFixture,
|
|
ignoreListPaging,
|
|
answerOversize,
|
|
fakeRequests,
|
|
apiRequests,
|
|
fakeInvoiceIds,
|
|
fixtureResponse,
|
|
webhookEvent,
|
|
webhookSigHeader,
|
|
webhookHexSig,
|
|
)
|
|
where
|
|
|
|
import StoreService.Config (BTCPayConfig (..), SpeedPolicy (..))
|
|
import Control.Applicative ((<|>))
|
|
import Control.Concurrent.STM
|
|
import Control.Monad (join)
|
|
import Crypto.Hash (Digest, SHA256)
|
|
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
|
|
import qualified Data.Aeson as J
|
|
import Data.Aeson.Key (Key)
|
|
import qualified Data.Aeson.Key as K
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import Data.Aeson.Types (Pair)
|
|
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
|
import Data.ByteString (ByteString)
|
|
import qualified Data.ByteString.Char8 as B8
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import Data.List (isPrefixOf, nub)
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe)
|
|
import Data.Scientific (Scientific)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.Encoding as TE
|
|
-- http-client is imported qualified because its Request has requestHeaders, requestBody and
|
|
-- queryString just as WAI's does.
|
|
import Network.HTTP.Client (Manager)
|
|
import qualified Network.HTTP.Client as HTTP
|
|
import Network.HTTP.Types
|
|
( Query,
|
|
Status,
|
|
badRequest400,
|
|
hAuthorization,
|
|
hContentType,
|
|
methodGet,
|
|
methodPost,
|
|
mkStatus,
|
|
notFound404,
|
|
ok200,
|
|
statusCode,
|
|
statusMessage,
|
|
unauthorized401,
|
|
)
|
|
import Network.HTTP.Types.Header (Header)
|
|
import Network.Wai (Application, Response, pathInfo, queryString, requestHeaders, requestMethod, responseLBS, strictRequestBody)
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
import Simplex.Messaging.Util (tshow)
|
|
import System.FilePath ((</>))
|
|
import Text.Read (readMaybe)
|
|
|
|
fixtureDir :: FilePath
|
|
fixtureDir = "apps" </> "simplex-store-service" </> "test-fixtures" </> "btcpay"
|
|
|
|
fakeApiKey :: Text
|
|
fakeApiKey = "7f3c1d9a2e5b48c6905ad1e2f3b4c5d6e7f80912"
|
|
|
|
fakeStoreId :: Text
|
|
fakeStoreId = "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT"
|
|
|
|
fakeWebhookSecret :: Text
|
|
fakeWebhookSecret = "3d8f5c6a2b1e4f7089abcdef01234567"
|
|
|
|
fakeExpiryMinutes :: Int
|
|
fakeExpiryMinutes = 60
|
|
|
|
data FakeRequest = FakeRequest
|
|
{ frMethod :: ByteString,
|
|
frPath :: [Text],
|
|
frQuery :: Query,
|
|
frHeaders :: [Header],
|
|
frBody :: LB.ByteString
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | isSpeedPolicy: the checkout's speedPolicy the invoice was created with. isPayments: the payments set by a test, else one of all
|
|
-- that was paid, Settled once the invoice is.
|
|
data InvoiceState = InvoiceState
|
|
{ isMethodId :: Text,
|
|
isStatus :: Text,
|
|
isAdditionalStatus :: Text,
|
|
isPaid :: Maybe Text,
|
|
isDue :: Maybe Text,
|
|
-- | The method's amount, when a test sets one other than the fixture's.
|
|
isAmount :: Maybe Text,
|
|
isSpeedPolicy :: Maybe Text,
|
|
isPayments :: Maybe J.Value
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data FailPlan = FailPlan {fpSkip :: Int, fpCalls :: Int, fpStatus :: Int}
|
|
deriving (Eq, Show)
|
|
|
|
data FakeState = FakeState
|
|
{ fsInvoices :: M.Map Text InvoiceState,
|
|
fsNextId :: Int,
|
|
fsFail :: FailPlan,
|
|
fsList :: Text,
|
|
fsMethods :: Maybe Text,
|
|
fsIgnorePaging :: Bool,
|
|
fsOversize :: Bool,
|
|
-- | The rates answer set by a test, else each pair asked at the fixtures' rate.
|
|
fsRates :: Maybe J.Value,
|
|
fsRequests :: [FakeRequest]
|
|
}
|
|
|
|
initialState :: FakeState
|
|
initialState =
|
|
FakeState
|
|
{ fsInvoices = M.empty,
|
|
fsNextId = 1,
|
|
fsFail = FailPlan {fpSkip = 0, fpCalls = 0, fpStatus = 0},
|
|
fsList = "invoice-list",
|
|
fsMethods = Nothing,
|
|
fsIgnorePaging = False,
|
|
fsOversize = False,
|
|
fsRates = Nothing,
|
|
fsRequests = []
|
|
}
|
|
|
|
data FakeBTCPay = FakeBTCPay
|
|
{ fbConfig :: BTCPayConfig,
|
|
fbBaseUrl :: String,
|
|
fbManager :: Manager,
|
|
fbState :: TVar FakeState
|
|
}
|
|
|
|
-- | Warp chooses the port, because a fixed one could collide with a server left over from an
|
|
-- earlier run and the test would pass against that instead.
|
|
withFakeBTCPay :: (FakeBTCPay -> IO a) -> IO a
|
|
withFakeBTCPay action = do
|
|
fbState <- newTVarIO initialState
|
|
fbManager <- HTTP.newManager HTTP.defaultManagerSettings
|
|
Warp.testWithApplication (pure (fakeApp fbState)) $ \prt -> do
|
|
let fbBaseUrl = "http://127.0.0.1:" <> show prt
|
|
action FakeBTCPay {fbConfig = fakeConfig (T.pack fbBaseUrl), fbBaseUrl, fbManager, fbState}
|
|
|
|
fakeConfig :: Text -> BTCPayConfig
|
|
fakeConfig host =
|
|
BTCPayConfig
|
|
{ bHost = host,
|
|
bApiKey = fakeApiKey,
|
|
bStoreId = fakeStoreId,
|
|
bWebhookSecret = fakeWebhookSecret,
|
|
bExpiryMinutes = fakeExpiryMinutes,
|
|
bSpeedPolicy = MediumSpeed,
|
|
bPaymentTolerance = 0.5
|
|
}
|
|
|
|
-- | A BTCPay webhook delivery shaped as its documentation shows. Both suites sign this exact
|
|
-- envelope, so what a valid signature covers cannot mean two different things.
|
|
webhookEvent :: Text -> Text -> LB.ByteString
|
|
webhookEvent eventType invoiceRef =
|
|
LB.fromStrict . TE.encodeUtf8 . T.unlines $
|
|
[ "{",
|
|
" \"deliveryId\": \"vBYbSCVSMFXFqSuCZ2LEHt\",",
|
|
" \"webhookId\": \"5GtRTUCYPbVAWJmYcgZ8wK\",",
|
|
" \"originalDeliveryId\": \"vBYbSCVSMFXFqSuCZ2LEHt\",",
|
|
" \"isRedelivery\": false,",
|
|
" \"type\": \"" <> eventType <> "\",",
|
|
" \"timestamp\": 1700000000,",
|
|
" \"storeId\": \"" <> fakeStoreId <> "\",",
|
|
" \"invoiceId\": \"" <> invoiceRef <> "\"",
|
|
"}"
|
|
]
|
|
|
|
-- | The header BTCPay sends is an HMAC-SHA256 over the bytes exactly as written above.
|
|
webhookSigHeader :: Text -> LB.ByteString -> [Header]
|
|
webhookSigHeader secret body = [("BTCPay-Sig", "sha256=" <> webhookHexSig secret body)]
|
|
|
|
webhookHexSig :: Text -> LB.ByteString -> ByteString
|
|
webhookHexSig secret body = convertToBase Base16 digest
|
|
where
|
|
digest :: Digest SHA256
|
|
digest = hmacGetDigest (hmac (TE.encodeUtf8 secret) (LB.toStrict body) :: HMAC SHA256)
|
|
|
|
fixtureResponse :: Text -> IO J.Value
|
|
fixtureResponse name = do
|
|
raw <- LB.readFile path
|
|
case J.eitherDecode raw of
|
|
Left e -> fail (path <> ": " <> e)
|
|
Right (J.Object o) -> case (KM.lookup "_fixture" o, KM.lookup "response" o) of
|
|
(Just (J.String _), Just v) -> pure v
|
|
_ -> fail (path <> ": a fixture is {\"_fixture\": <provenance>, \"response\": <body>}")
|
|
Right _ -> fail (path <> ": a fixture is a JSON object")
|
|
where
|
|
path = fixtureDir </> T.unpack name <> ".json"
|
|
|
|
invoiceFixture :: Text -> Text
|
|
invoiceFixture = \case
|
|
"Processing" -> "invoice-processing"
|
|
"Settled" -> "invoice-settled"
|
|
"Expired" -> "invoice-expired"
|
|
"Invalid" -> "invoice-invalid"
|
|
_ -> "invoice-new"
|
|
|
|
methodsFixture :: Text -> Text
|
|
methodsFixture methodId
|
|
| methodId == xmrMethod = "payment-methods-xmr"
|
|
| otherwise = "payment-methods-btc"
|
|
|
|
-- The adapter's own ids are restated rather than imported, so a fake sharing the constant cannot
|
|
-- agree with a wrong change to it.
|
|
btcMethod, xmrMethod :: Text
|
|
btcMethod = "BTC-CHAIN"
|
|
xmrMethod = "XMR-CHAIN"
|
|
|
|
patchInvoice :: Text -> InvoiceState -> J.Value -> J.Value
|
|
patchInvoice invId InvoiceState {isStatus, isAdditionalStatus, isSpeedPolicy} v =
|
|
setField "id" (J.String invId)
|
|
. setField "status" (J.String isStatus)
|
|
. setField "additionalStatus" (J.String isAdditionalStatus)
|
|
. setField "checkout" (setField "speedPolicy" (maybe J.Null J.String isSpeedPolicy) (fromMaybe (J.object []) (objectField "checkout" v)))
|
|
$ v
|
|
|
|
-- | The fixtures' payments were written for a settled invoice, so the payments follow the state instead.
|
|
patchMethods :: InvoiceState -> J.Value -> J.Value
|
|
patchMethods InvoiceState {isStatus, isPaid, isDue, isAmount, isPayments} = \case
|
|
J.Array vs -> J.Array (fmap one vs)
|
|
other -> other
|
|
where
|
|
paidStatus = if isStatus == "Settled" then "Settled" else "Processing" :: Text
|
|
one = \case
|
|
J.Object fixture ->
|
|
let o = maybe fixture (\a -> KM.insert "amount" (J.String a) fixture) isAmount
|
|
paid = fromMaybe (fixturePaid o) isPaid
|
|
-- With nothing received the whole amount is owed. The fixtures were written for a
|
|
-- settled invoice, so without this a freshly created one would serve their `due` of
|
|
-- zero and the fake would report nothing left to send on an unpaid invoice.
|
|
due = if isZero paid then isDue <|> textField "amount" o else isDue
|
|
withPaid = KM.insert "paymentMethodPaid" (J.String paid) o
|
|
withDue = maybe withPaid (\d -> KM.insert "due" (J.String d) withPaid) due
|
|
payments
|
|
| Just ps <- isPayments = ps
|
|
| isZero paid = J.Array mempty
|
|
| otherwise = J.toJSON [J.object ["id" J..= ("faketx-" <> paid), "receivedDate" J..= settledDate, "value" J..= paid, "fee" J..= ("0.00000000" :: Text), "status" J..= paidStatus]]
|
|
in J.Object (KM.insert "payments" payments withDue)
|
|
v -> v
|
|
fixturePaid o = case KM.lookup "paymentMethodPaid" o of
|
|
Just (J.String t) -> t
|
|
_ -> "0"
|
|
|
|
-- | The fixtures' settlement time.
|
|
settledDate :: Int
|
|
settledDate = 1700003600
|
|
|
|
isZero :: Text -> Bool
|
|
isZero t = (J.decodeStrict (TE.encodeUtf8 t) :: Maybe Scientific) == Just 0
|
|
|
|
setField :: Key -> J.Value -> J.Value -> J.Value
|
|
setField k v = \case
|
|
J.Object o -> J.Object (KM.insert k v o)
|
|
other -> other
|
|
|
|
fakeApp :: TVar FakeState -> Application
|
|
fakeApp stv req respond = do
|
|
body <- strictRequestBody req
|
|
case pathInfo req of
|
|
["_state", invId] | isPost -> control body (setState invId)
|
|
["_fail"] | isPost -> control body setFail
|
|
["_fixtures"] | isPost -> control body setFixtures
|
|
["_paging"] | isPost -> control body setPaging
|
|
["_oversize"] | isPost -> control body setOversize
|
|
["_rates"] | isPost -> setRatesAnswer body
|
|
"api" : "v1" : "stores" : storeId : rest -> apiCall body storeId rest
|
|
_ -> refuse notFound404 "no such path on the fake btcpay"
|
|
where
|
|
verb = requestMethod req
|
|
isPost = verb == methodPost
|
|
isGet = verb == methodGet
|
|
refuse st message = respond (errorResponse st message)
|
|
ok = respond (jsonResponse ok200 (J.object ["ok" J..= True]))
|
|
|
|
control body act = case J.decode body of
|
|
Just (J.Object o) ->
|
|
act o >>= \case
|
|
Nothing -> ok
|
|
Just message -> refuse badRequest400 message
|
|
_ -> refuse badRequest400 "a control call takes a JSON object"
|
|
|
|
setState invId o = case unknownKeys stateKeys o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> atomically $ do
|
|
st <- readTVar stv
|
|
case M.lookup invId (fsInvoices st) of
|
|
Nothing -> pure (Just ("no invoice " <> invId <> " was created here"))
|
|
Just inv -> do
|
|
let inv' =
|
|
inv
|
|
{ isStatus = fromMaybe (isStatus inv) (textField "status" o),
|
|
isAdditionalStatus = fromMaybe (isAdditionalStatus inv) (textField "additionalStatus" o),
|
|
isPaid = maybe (isPaid inv) Just (textField "paymentMethodPaid" o),
|
|
isDue = maybe (isDue inv) Just (textField "due" o),
|
|
isAmount = maybe (isAmount inv) Just (textField "amount" o),
|
|
isPayments = maybe (isPayments inv) Just (KM.lookup "payments" o)
|
|
}
|
|
writeTVar stv st {fsInvoices = M.insert invId inv' (fsInvoices st)}
|
|
pure Nothing
|
|
|
|
setFail o = case unknownKeys ["calls", "status", "skip"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case (intField "calls" o, intField "status" o) of
|
|
(Just calls, Just code) -> do
|
|
let plan = FailPlan {fpSkip = fromMaybe 0 (intField "skip" o), fpCalls = calls, fpStatus = code}
|
|
atomically (modifyTVar' stv (\st -> st {fsFail = plan}))
|
|
pure Nothing
|
|
_ -> pure (Just "_fail takes {\"calls\": <n>, \"status\": <http status>, \"skip\": <n>}")
|
|
|
|
setFixtures o = case unknownKeys ["list", "paymentMethods"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case (named "list" "invoice-list" fsListOf, named "paymentMethods" "payment-methods" fsMethodsOf) of
|
|
(Left message, _) -> pure (Just message)
|
|
(_, Left message) -> pure (Just message)
|
|
(Right setL, Right setM)
|
|
| null (KM.keys o) -> pure (Just "_fixtures takes a `list` or a `paymentMethods` fixture name")
|
|
| otherwise -> do
|
|
atomically (modifyTVar' stv (setM . setL))
|
|
pure Nothing
|
|
where
|
|
named k prefix set = case textField k o of
|
|
Nothing -> Right id
|
|
Just name
|
|
| prefix `isPrefixOf` T.unpack name -> Right (set name)
|
|
| otherwise -> Left ("not a " <> K.toText k <> " fixture: " <> name)
|
|
fsListOf name st = st {fsList = name}
|
|
fsMethodsOf name st = st {fsMethods = Just name}
|
|
|
|
setRatesAnswer body = case J.decode body of
|
|
Just v -> atomically (modifyTVar' stv (\st -> st {fsRates = Just v})) >> ok
|
|
Nothing -> refuse badRequest400 "_rates takes the answer to serve, as JSON"
|
|
|
|
setOversize o = case unknownKeys ["on"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case KM.lookup "on" o of
|
|
Just (J.Bool on) -> do
|
|
atomically (modifyTVar' stv (\st -> st {fsOversize = on}))
|
|
pure Nothing
|
|
_ -> pure (Just "_oversize takes {\"on\": <bool>}")
|
|
|
|
setPaging o = case unknownKeys ["ignore"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case KM.lookup "ignore" o of
|
|
Just (J.Bool ignore) -> do
|
|
atomically (modifyTVar' stv (\st -> st {fsIgnorePaging = ignore}))
|
|
pure Nothing
|
|
_ -> pure (Just "_paging takes {\"ignore\": <bool>}")
|
|
|
|
apiCall body storeId rest = do
|
|
atomically $
|
|
modifyTVar' stv $ \st ->
|
|
st {fsRequests = FakeRequest verb (pathInfo req) (queryString req) (requestHeaders req) body : fsRequests st}
|
|
if storeId /= fakeStoreId
|
|
then refuse notFound404 ("no store " <> storeId <> " on the fake btcpay")
|
|
else case lookup hAuthorization (requestHeaders req) of
|
|
Just given | given == expectedAuth -> injectingFailure (greenfield body rest)
|
|
_ -> refuse unauthorized401 "Authorization must be `token <api-key>`"
|
|
|
|
expectedAuth = "token " <> TE.encodeUtf8 fakeApiKey
|
|
|
|
injectingFailure act = do
|
|
failing <- atomically $ do
|
|
st <- readTVar stv
|
|
case fsFail st of
|
|
plan@FailPlan {fpSkip} | fpSkip > 0 -> do
|
|
writeTVar stv st {fsFail = plan {fpSkip = fpSkip - 1}}
|
|
pure Nothing
|
|
plan@FailPlan {fpCalls, fpStatus} | fpCalls > 0 -> do
|
|
writeTVar stv st {fsFail = plan {fpCalls = fpCalls - 1}}
|
|
pure (Just fpStatus)
|
|
_ -> pure Nothing
|
|
case failing of
|
|
Just code -> refuse (mkStatus code "Injected Failure") "the fake was told to fail this call"
|
|
Nothing -> act
|
|
|
|
greenfield body = \case
|
|
["payment-methods"] | isGet -> serve "store-payment-methods" id
|
|
["rates"] | isGet -> rates
|
|
["invoices"] | isPost -> createInvoice body
|
|
["invoices"] | isGet -> listInvoices
|
|
["invoices", invId] | isGet ->
|
|
withInvoice invId $ \inv -> serve (invoiceFixture (isStatus inv)) (patchInvoice invId inv)
|
|
["invoices", invId, "payment-methods"] | isGet ->
|
|
withInvoice invId $ \inv -> do
|
|
override <- fsMethods <$> readTVarIO stv
|
|
serve (fromMaybe (methodsFixture (isMethodId inv)) override) (patchMethods inv)
|
|
_ -> refuse notFound404 "no such greenfield path on the fake btcpay"
|
|
|
|
rates = do
|
|
answer <- fsRates <$> readTVarIO stv
|
|
let asked = [TE.decodeUtf8 pair | ("currencyPair", Just pair) <- queryString req]
|
|
respond . jsonResponse ok200 $ fromMaybe (J.toJSON (map fixtureRate asked)) answer
|
|
fixtureRate pair = J.object ["currencyPair" J..= pair, "errors" J..= ([] :: [Text]), "rate" J..= lookup pair fixtureRates]
|
|
fixtureRates = [("BTC_USD", "108000.12"), ("XMR_USD", "168.25") :: (Text, Text)]
|
|
|
|
listInvoices = do
|
|
st <- readTVarIO stv
|
|
fixture <- fixtureResponse (fsList st)
|
|
case arrayEntries fixture of
|
|
Nothing -> respond (jsonResponse ok200 fixture)
|
|
Just fixed -> do
|
|
let invs = M.toAscList (fsInvoices st)
|
|
cache <- readFixtures (concatMap (fixtureNames (fsMethods st)) invs)
|
|
let entries = fixed <> map (listedInvoice cache (fsMethods st)) invs
|
|
page = if fsIgnorePaging st then entries else paged (queryString req) entries
|
|
respond (jsonResponse ok200 (J.toJSON page))
|
|
|
|
readFixtures names = M.fromList <$> mapM (\n -> (,) n <$> fixtureResponse n) (nub names)
|
|
|
|
fixtureNames override (_, inv) = [invoiceFixture (isStatus inv), methodsName override inv]
|
|
|
|
methodsName override inv = fromMaybe (methodsFixture (isMethodId inv)) override
|
|
|
|
listedInvoice cache override (invId, inv) =
|
|
setField "paymentMethods" (patchMethods inv (cached (methodsName override inv))) $
|
|
patchInvoice invId inv (cached (invoiceFixture (isStatus inv)))
|
|
where
|
|
cached name = fromMaybe J.Null (M.lookup name cache)
|
|
|
|
serve name patch = do
|
|
oversize <- fsOversize <$> readTVarIO stv
|
|
if oversize
|
|
then respond (jsonResponse ok200 (J.String (T.replicate (12 * 1024 * 1024) "x")))
|
|
else do
|
|
v <- fixtureResponse name
|
|
respond (jsonResponse ok200 (patch v))
|
|
|
|
withInvoice invId act = do
|
|
invoices <- fsInvoices <$> readTVarIO stv
|
|
case M.lookup invId invoices of
|
|
Just inv -> act inv
|
|
Nothing -> refuse notFound404 ("no invoice " <> invId <> " on the fake btcpay")
|
|
|
|
createInvoice body = case J.decode body >>= chosenMethod of
|
|
Nothing -> refuse badRequest400 "checkout.paymentMethods must name exactly one of BTC-CHAIN, XMR-CHAIN"
|
|
Just methodId -> do
|
|
(invId, inv) <- atomically $ do
|
|
st <- readTVar stv
|
|
let invId = "FakeInvoiceRef" <> T.justifyRight 4 '0' (tshow (fsNextId st))
|
|
inv =
|
|
InvoiceState
|
|
{ isMethodId = methodId,
|
|
isStatus = "New",
|
|
isAdditionalStatus = "None",
|
|
isPaid = Just "0.00000000",
|
|
isDue = Nothing,
|
|
isAmount = Nothing,
|
|
isSpeedPolicy = J.decode body >>= objectField "checkout" >>= objectField "speedPolicy" >>= asText,
|
|
isPayments = Nothing
|
|
}
|
|
writeTVar stv st {fsNextId = fsNextId st + 1, fsInvoices = M.insert invId inv (fsInvoices st)}
|
|
pure (invId, inv)
|
|
serve (invoiceFixture (isStatus inv)) (patchInvoice invId inv)
|
|
|
|
arrayEntries :: J.Value -> Maybe [J.Value]
|
|
arrayEntries = \case
|
|
J.Array vs -> Just (foldr (:) [] vs)
|
|
_ -> Nothing
|
|
|
|
paged :: Query -> [J.Value] -> [J.Value]
|
|
paged q entries = maybe id take (intQuery "take" q) (drop (fromMaybe 0 (intQuery "skip" q)) entries)
|
|
|
|
intQuery :: ByteString -> Query -> Maybe Int
|
|
intQuery k q = join (lookup k q) >>= readMaybe . B8.unpack
|
|
|
|
stateKeys :: [Key]
|
|
stateKeys = ["status", "additionalStatus", "paymentMethodPaid", "due", "amount", "payments"]
|
|
|
|
unknownKeys :: [Key] -> J.Object -> Maybe Text
|
|
unknownKeys known o = case filter (`notElem` known) (KM.keys o) of
|
|
k : _ -> Just ("this control call does not set " <> K.toText k)
|
|
[] -> Nothing
|
|
|
|
chosenMethod :: J.Value -> Maybe Text
|
|
chosenMethod v = case objectField "checkout" v >>= objectField "paymentMethods" of
|
|
Just (J.Array ms) -> case foldr (:) [] ms of
|
|
[J.String m] | m `elem` [btcMethod, xmrMethod] -> Just m
|
|
_ -> Nothing
|
|
_ -> Nothing
|
|
|
|
asText :: J.Value -> Maybe Text
|
|
asText = \case
|
|
J.String t -> Just t
|
|
_ -> Nothing
|
|
|
|
objectField :: Key -> J.Value -> Maybe J.Value
|
|
objectField k = \case
|
|
J.Object o -> KM.lookup k o
|
|
_ -> Nothing
|
|
|
|
textField :: Key -> J.Object -> Maybe Text
|
|
textField k o = case KM.lookup k o of
|
|
Just (J.String t) -> Just t
|
|
_ -> Nothing
|
|
|
|
intField :: Key -> J.Object -> Maybe Int
|
|
intField k o = case KM.lookup k o of
|
|
Just n -> case J.fromJSON n of
|
|
J.Success i -> Just i
|
|
J.Error _ -> Nothing
|
|
Nothing -> Nothing
|
|
|
|
jsonResponse :: Status -> J.Value -> Response
|
|
jsonResponse st v = responseLBS st [(hContentType, "application/json")] (J.encode v)
|
|
|
|
errorResponse :: Status -> Text -> Response
|
|
errorResponse st message = jsonResponse st (J.object ["code" J..= TE.decodeUtf8 (statusMessage st), "message" J..= message])
|
|
|
|
controlPost :: FakeBTCPay -> String -> J.Value -> IO ()
|
|
controlPost FakeBTCPay {fbBaseUrl, fbManager} path v = do
|
|
req <- HTTP.parseRequest (fbBaseUrl <> path)
|
|
r <- HTTP.httpLbs req {HTTP.method = methodPost, HTTP.requestBody = HTTP.RequestBodyLBS (J.encode v)} fbManager
|
|
case statusCode (HTTP.responseStatus r) of
|
|
200 -> pure ()
|
|
code -> fail ("fake btcpay " <> path <> " answered " <> show code <> ": " <> show (HTTP.responseBody r))
|
|
|
|
setInvoiceState :: FakeBTCPay -> Text -> [Pair] -> IO ()
|
|
setInvoiceState fake invId fields = controlPost fake ("/_state/" <> T.unpack invId) (J.object fields)
|
|
|
|
failNextCalls :: FakeBTCPay -> Int -> Int -> IO ()
|
|
failNextCalls = failAfterCalls 0
|
|
|
|
failAfterCalls :: Int -> FakeBTCPay -> Int -> Int -> IO ()
|
|
failAfterCalls skip fake calls code =
|
|
controlPost fake "/_fail" (J.object ["calls" J..= calls, "status" J..= code, "skip" J..= skip])
|
|
|
|
answerOversize :: FakeBTCPay -> Bool -> IO ()
|
|
answerOversize fake on = controlPost fake "/_oversize" (J.object ["on" J..= on])
|
|
|
|
-- | The rates call answers this from now on.
|
|
setRates :: FakeBTCPay -> J.Value -> IO ()
|
|
setRates fake = controlPost fake "/_rates"
|
|
|
|
useListFixture :: FakeBTCPay -> Text -> IO ()
|
|
useListFixture fake name = controlPost fake "/_fixtures" (J.object ["list" J..= name])
|
|
|
|
usePaymentMethodsFixture :: FakeBTCPay -> Text -> IO ()
|
|
usePaymentMethodsFixture fake name = controlPost fake "/_fixtures" (J.object ["paymentMethods" J..= name])
|
|
|
|
ignoreListPaging :: FakeBTCPay -> IO ()
|
|
ignoreListPaging fake = controlPost fake "/_paging" (J.object ["ignore" J..= True])
|
|
|
|
fakeRequests :: FakeBTCPay -> IO [FakeRequest]
|
|
fakeRequests FakeBTCPay {fbState} = reverse . fsRequests <$> readTVarIO fbState
|
|
|
|
fakeInvoiceIds :: FakeBTCPay -> IO [Text]
|
|
fakeInvoiceIds FakeBTCPay {fbState} = M.keys . fsInvoices <$> readTVarIO fbState
|
|
|
|
apiRequests :: FakeBTCPay -> ByteString -> [Text] -> IO [FakeRequest]
|
|
apiRequests fake verb segments = filter matching <$> fakeRequests fake
|
|
where
|
|
matching FakeRequest {frMethod, frPath} =
|
|
frMethod == verb && frPath == ["api", "v1", "stores", fakeStoreId] <> segments
|