mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 19:27:55 +00:00
309 lines
14 KiB
Haskell
309 lines
14 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Bots.StoreService.CatalogTests where
|
|
|
|
import StoreService.Catalog
|
|
import Control.Monad (when)
|
|
import Data.List (find)
|
|
import qualified Data.Map.Strict as Map
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.IO as T
|
|
import Data.Text.Read (decimal)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
|
|
import Data.Word (Word32, Word8)
|
|
import Simplex.Chat.Badges (BadgeType (..))
|
|
import Simplex.Chat.StoreService (BadgeOffer (..), BadgePrice (..))
|
|
import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
|
|
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..))
|
|
import Test.Hspec
|
|
|
|
badgeCatalogTests :: Spec
|
|
badgeCatalogTests = describe "badge catalog" $ do
|
|
it "prices the six sellable pairs at their literal totals" testLiteralTotals
|
|
it "delivers the free months it does not charge for" testFreeMonths
|
|
it "truncates a percentage discount in the buyer's favour" testDiscountTruncates
|
|
it "refuses a pair it cannot price" testRefusals
|
|
it "refuses a gross that would wrap, even where the charge is sellable" testGrossIsBounded
|
|
it "agrees with the catalog compiled into the page" testNoDriftFromWeb
|
|
it "refuses an unknown priceId" testPriceOfferUnknownPrice
|
|
it "refuses a badge type this service does not sell" testPriceOfferUnsoldBadgeType
|
|
it "refuses a disabled price" testPriceOfferDisabledPrice
|
|
it "refuses an unknown offerId" testPriceOfferUnknownOffer
|
|
it "refuses a disabled offer" testPriceOfferDisabledOffer
|
|
it "refuses an offer that names a different price" testPriceOfferMismatchedPrice
|
|
it "refuses a pair offerTotal itself cannot price" testPriceOfferUnpriced
|
|
it "refuses on the badge type before it checks whether the price is disabled" testPriceOfferOrderUnsoldBeforeDisabled
|
|
it "prices a deprecated price rather than refusing it" testPriceOfferDeprecatedPricePriced
|
|
it "prices a deprecated offer rather than refusing it" testPriceOfferDeprecatedOfferPriced
|
|
it "carries the term's gross, not the per-month rate, for a free-months offer" testPriceOfferGrossFreeMonths
|
|
it "carries the term's gross, not the per-month rate, for a percentage-discount offer" testPriceOfferGrossDiscount
|
|
it "applies an offer with no priceId to any price" testPriceOfferAnyPrice
|
|
|
|
offer :: Word8 -> OfferDiscount -> BadgeOffer
|
|
offer months discount =
|
|
BadgeOffer {offerId = BadgeOfferId "o", priceId = Nothing, months, discount, status = BISActive, createdAt = epoch}
|
|
|
|
epoch :: UTCTime
|
|
epoch = posixSecondsToUTCTime 0
|
|
|
|
testLiteralTotals :: IO ()
|
|
testLiteralTotals = do
|
|
amountOf 700 Nothing `shouldBe` Right 700
|
|
amountOf 700 (Just (offer 3 (ODFreeMonths 1))) `shouldBe` Right 1400
|
|
amountOf 700 (Just (offer 12 (ODDiscount 50))) `shouldBe` Right 4200
|
|
amountOf 7000 Nothing `shouldBe` Right 7000
|
|
amountOf 7000 (Just (offer 3 (ODFreeMonths 1))) `shouldBe` Right 14000
|
|
amountOf 7000 (Just (offer 12 (ODDiscount 50))) `shouldBe` Right 42000
|
|
where
|
|
amountOf p o = (\(_, _, CurrencyAmount a) -> a) <$> offerTotal (CurrencyAmount p) o
|
|
|
|
testFreeMonths :: IO ()
|
|
testFreeMonths =
|
|
offerTotal (CurrencyAmount 7000) (Just (offer 3 (ODFreeMonths 1)))
|
|
`shouldBe` Right (3, CurrencyAmount 21000, CurrencyAmount 14000)
|
|
|
|
testDiscountTruncates :: IO ()
|
|
testDiscountTruncates =
|
|
offerTotal (CurrencyAmount 333) (Just (offer 3 (ODDiscount 33)))
|
|
`shouldBe` Right (3, CurrencyAmount 999, CurrencyAmount 669)
|
|
|
|
testRefusals :: IO ()
|
|
testRefusals = do
|
|
offerTotal (CurrencyAmount 700) (Just (offer 0 (ODDiscount 10))) `shouldBe` Left OIZeroMonths
|
|
offerTotal (CurrencyAmount 700) (Just (offer 3 (ODFreeMonths 3))) `shouldBe` Left OIFreeMonthsExceedTerm
|
|
offerTotal (CurrencyAmount 700) (Just (offer 3 (ODDiscount 100))) `shouldBe` Left OIDiscountTooLarge
|
|
offerTotal (CurrencyAmount 0) Nothing `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 20000000) (Just (offer 12 (ODDiscount 50))) `shouldBe` Left OIAmountUnsellable
|
|
|
|
testGrossIsBounded :: IO ()
|
|
testGrossIsBounded = do
|
|
let chargedOf p o = (\(_, _, CurrencyAmount a) -> a) <$> offerTotal (CurrencyAmount p) o
|
|
chargedOf 100000000 (Just (offer 1 (ODDiscount 0))) `shouldBe` Right 100000000
|
|
offerTotal (CurrencyAmount 100000000) (Just (offer 43 (ODFreeMonths 42))) `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 100000000) (Just (offer 2 (ODDiscount 50))) `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 100000000) Nothing
|
|
`shouldBe` Right (1, CurrencyAmount 100000000, CurrencyAmount 100000000)
|
|
|
|
mkPrice :: T.Text -> BadgeType -> Word32 -> BadgeItemStatus -> BadgePrice
|
|
mkPrice priceIdText priceBadgeType monthPriceAmount priceStatus =
|
|
BadgePrice
|
|
{ priceId = BadgePriceId priceIdText,
|
|
badgeType = priceBadgeType,
|
|
monthPrice = CurrencyAmount monthPriceAmount,
|
|
currency = "usd",
|
|
status = priceStatus,
|
|
createdAt = epoch
|
|
}
|
|
|
|
mkOffer :: T.Text -> Maybe T.Text -> Word8 -> OfferDiscount -> BadgeItemStatus -> BadgeOffer
|
|
mkOffer offerIdText offerPriceIdText offerMonths offerDiscount offerStatus =
|
|
BadgeOffer
|
|
{ offerId = BadgeOfferId offerIdText,
|
|
priceId = BadgePriceId <$> offerPriceIdText,
|
|
months = offerMonths,
|
|
discount = offerDiscount,
|
|
status = offerStatus,
|
|
createdAt = epoch
|
|
}
|
|
|
|
testPriceOfferUnknownPrice :: IO ()
|
|
testPriceOfferUnknownPrice =
|
|
priceOffer [] [] (BadgePriceId "missing") Nothing `shouldBe` Left CRUnknownPrice
|
|
|
|
testPriceOfferUnsoldBadgeType :: IO ()
|
|
testPriceOfferUnsoldBadgeType =
|
|
priceOffer [mkPrice "p1" BTInvestor 1000 BISActive] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left (CRUnsoldBadgeType BTInvestor)
|
|
|
|
testPriceOfferDisabledPrice :: IO ()
|
|
testPriceOfferDisabledPrice =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISDisabled] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left CRDisabledPrice
|
|
|
|
testPriceOfferUnknownOffer :: IO ()
|
|
testPriceOfferUnknownOffer =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISActive] [] (BadgePriceId "p1") (Just (BadgeOfferId "missing"))
|
|
`shouldBe` Left CRUnknownOffer
|
|
|
|
testPriceOfferDisabledOffer :: IO ()
|
|
testPriceOfferDisabledOffer =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISDisabled]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left CRDisabledOffer
|
|
|
|
testPriceOfferMismatchedPrice :: IO ()
|
|
testPriceOfferMismatchedPrice =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive, mkPrice "p2" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p2") 3 (ODFreeMonths 1) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left CROfferNotForPrice
|
|
|
|
testPriceOfferUnpriced :: IO ()
|
|
testPriceOfferUnpriced =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 0 (ODDiscount 10) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left (CRUnpriced OIZeroMonths)
|
|
|
|
testPriceOfferOrderUnsoldBeforeDisabled :: IO ()
|
|
testPriceOfferOrderUnsoldBeforeDisabled =
|
|
priceOffer [mkPrice "p1" BTInvestor 1000 BISDisabled] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left (CRUnsoldBadgeType BTInvestor)
|
|
|
|
testPriceOfferDeprecatedPricePriced :: IO ()
|
|
testPriceOfferDeprecatedPricePriced =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISDeprecated] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTSupporter, poMonths = 1, poPrice = CurrencyAmount 700, poAmount = CurrencyAmount 700, poCurrency = "usd"}
|
|
|
|
testPriceOfferDeprecatedOfferPriced :: IO ()
|
|
testPriceOfferDeprecatedOfferPriced =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISDeprecated]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTSupporter, poMonths = 3, poPrice = CurrencyAmount 2100, poAmount = CurrencyAmount 1400, poCurrency = "usd"}
|
|
|
|
testPriceOfferGrossFreeMonths :: IO ()
|
|
testPriceOfferGrossFreeMonths =
|
|
priceOffer
|
|
[mkPrice "p1" BTLegend 7000 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTLegend, poMonths = 3, poPrice = CurrencyAmount 21000, poAmount = CurrencyAmount 14000, poCurrency = "usd"}
|
|
|
|
testPriceOfferGrossDiscount :: IO ()
|
|
testPriceOfferGrossDiscount =
|
|
priceOffer
|
|
[mkPrice "p1" BTLegend 7000 BISActive]
|
|
[mkOffer "o1" (Just "p1") 12 (ODDiscount 50) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTLegend, poMonths = 12, poPrice = CurrencyAmount 84000, poAmount = CurrencyAmount 42000, poCurrency = "usd"}
|
|
|
|
testPriceOfferAnyPrice :: IO ()
|
|
testPriceOfferAnyPrice = do
|
|
let prices = [mkPrice "p1" BTSupporter 700 BISActive, mkPrice "p2" BTSupporter 700 BISActive]
|
|
offers = [mkOffer "o1" Nothing 3 (ODFreeMonths 1) BISActive]
|
|
expected = Right PricedOffer {poBadgeType = BTSupporter, poMonths = 3, poPrice = CurrencyAmount 2100, poAmount = CurrencyAmount 1400, poCurrency = "usd"}
|
|
priceOffer prices offers (BadgePriceId "p1") (Just (BadgeOfferId "o1")) `shouldBe` expected
|
|
priceOffer prices offers (BadgePriceId "p2") (Just (BadgeOfferId "o1")) `shouldBe` expected
|
|
|
|
testNoDriftFromWeb :: IO ()
|
|
testNoDriftFromWeb = do
|
|
src <- T.readFile "apps/simplex-store-service/web/src/catalog.ts"
|
|
case parseCatalogSource src of
|
|
Nothing -> expectationFailure "could not parse CATALOG out of web/src/catalog.ts -- its shape has changed"
|
|
Just (webPrices, webOffers) -> do
|
|
when (null webPrices || null webOffers) $
|
|
expectationFailure "parsed zero prices or offers out of web/src/catalog.ts -- its shape has changed"
|
|
mapM_ checkPrice webPrices
|
|
mapM_ (checkOffer webPrices) webOffers
|
|
where
|
|
literalTotals :: Map.Map T.Text (Word32, Word32, Word32)
|
|
literalTotals = Map.fromList [("supporter", (700, 1400, 4200)), ("legend", (7000, 14000, 42000))]
|
|
checkPrice :: WebPrice -> IO ()
|
|
checkPrice WebPrice {wpPriceId, wpBadgeType, wpMonthPrice} =
|
|
case Map.lookup wpBadgeType literalTotals of
|
|
Nothing -> expectationFailure ("no literal total for badge type " <> T.unpack wpBadgeType <> " (price " <> T.unpack wpPriceId <> ")")
|
|
Just (base, _, _) ->
|
|
offerTotal (CurrencyAmount wpMonthPrice) Nothing
|
|
`shouldBe` Right (1, CurrencyAmount wpMonthPrice, CurrencyAmount base)
|
|
checkOffer :: [WebPrice] -> WebOffer -> IO ()
|
|
checkOffer webPrices WebOffer {woPriceId, woMonths, woDiscount} =
|
|
case find (\WebPrice {wpPriceId} -> wpPriceId == woPriceId) webPrices of
|
|
Nothing -> expectationFailure ("offer names price " <> T.unpack woPriceId <> ", which is not in the catalog")
|
|
Just WebPrice {wpBadgeType, wpMonthPrice} -> case Map.lookup wpBadgeType literalTotals of
|
|
Nothing -> expectationFailure ("no literal total for badge type " <> T.unpack wpBadgeType)
|
|
Just (_, threeMonths, twelveMonths) -> do
|
|
expectedAmount <- case woMonths of
|
|
3 -> pure threeMonths
|
|
12 -> pure twelveMonths
|
|
n -> expectationFailure ("no literal total for a " <> show n <> "-month offer") >> pure 0
|
|
offerTotal (CurrencyAmount wpMonthPrice) (Just (offer woMonths woDiscount))
|
|
`shouldBe` Right (woMonths, CurrencyAmount (wpMonthPrice * fromIntegral woMonths), CurrencyAmount expectedAmount)
|
|
|
|
data WebPrice = WebPrice {wpPriceId :: T.Text, wpBadgeType :: T.Text, wpMonthPrice :: Word32, wpCurrency :: T.Text}
|
|
deriving (Eq, Ord, Show)
|
|
|
|
data WebOffer = WebOffer {woOfferId :: T.Text, woPriceId :: T.Text, woMonths :: Word8, woDiscount :: OfferDiscount}
|
|
deriving (Eq, Show)
|
|
|
|
parseCatalogSource :: T.Text -> Maybe ([WebPrice], [WebOffer])
|
|
parseCatalogSource src = do
|
|
pricesBlock <- betweenMarkers "prices: [" "]" src
|
|
offersBlock <- betweenMarkers "offers: [" "]" src
|
|
prices <- mapM parseWebPrice (topLevelObjects pricesBlock)
|
|
offers <- mapM parseWebOffer (topLevelObjects offersBlock)
|
|
pure (prices, offers)
|
|
|
|
parseWebPrice :: T.Text -> Maybe WebPrice
|
|
parseWebPrice obj =
|
|
WebPrice
|
|
<$> fieldStr "priceId" obj
|
|
<*> fieldStr "badgeType" obj
|
|
<*> (fieldNum "monthPrice" obj :: Maybe Word32)
|
|
<*> fieldStr "currency" obj
|
|
|
|
parseWebOffer :: T.Text -> Maybe WebOffer
|
|
parseWebOffer obj = do
|
|
wOfferId <- fieldStr "offerId" obj
|
|
wPriceId <- fieldStr "priceId" obj
|
|
wMonths <- (fieldNum "months" obj :: Maybe Word8)
|
|
discObj <- betweenMarkers "discount: {" "}" obj
|
|
discType <- fieldStr "type" discObj
|
|
wDiscount <- case discType of
|
|
"freeMonths" -> ODFreeMonths <$> (fieldNum "freeMonths" discObj :: Maybe Word8)
|
|
"discount" -> ODDiscount <$> (fieldNum "discount" discObj :: Maybe Word8)
|
|
_ -> Nothing
|
|
pure WebOffer {woOfferId = wOfferId, woPriceId = wPriceId, woMonths = wMonths, woDiscount = wDiscount}
|
|
|
|
topLevelObjects :: T.Text -> [T.Text]
|
|
topLevelObjects = go (0 :: Int) "" . T.unpack
|
|
where
|
|
go :: Int -> String -> String -> [T.Text]
|
|
go _ _ [] = []
|
|
go 0 _ (c : cs)
|
|
| c == '{' = go 1 [c] cs
|
|
| otherwise = go 0 "" cs
|
|
go depth buf (c : cs)
|
|
| c == '{' = go (depth + 1) (c : buf) cs
|
|
| c == '}' && depth == 1 = T.pack (reverse (c : buf)) : go 0 "" cs
|
|
| c == '}' = go (depth - 1) (c : buf) cs
|
|
| otherwise = go depth (c : buf) cs
|
|
|
|
betweenMarkers :: T.Text -> T.Text -> T.Text -> Maybe T.Text
|
|
betweenMarkers start end t = do
|
|
let (_, afterStart0) = T.breakOn start t
|
|
afterStart <- if T.null afterStart0 then Nothing else Just (T.drop (T.length start) afterStart0)
|
|
let (block, endPart) = T.breakOn end afterStart
|
|
if T.null endPart then Nothing else Just block
|
|
|
|
fieldStr :: T.Text -> T.Text -> Maybe T.Text
|
|
fieldStr key obj = do
|
|
let marker = key <> ": \""
|
|
(_, afterMarker0) = T.breakOn marker obj
|
|
afterMarker <- if T.null afterMarker0 then Nothing else Just (T.drop (T.length marker) afterMarker0)
|
|
let (val, rest) = T.breakOn "\"" afterMarker
|
|
if T.null rest then Nothing else Just val
|
|
|
|
fieldNum :: Integral a => T.Text -> T.Text -> Maybe a
|
|
fieldNum key obj = do
|
|
let marker = key <> ": "
|
|
(_, afterMarker0) = T.breakOn marker obj
|
|
afterMarker <- if T.null afterMarker0 then Nothing else Just (T.drop (T.length marker) afterMarker0)
|
|
case decimal afterMarker of
|
|
Right (n, _) -> Just n
|
|
Left _ -> Nothing
|