packages feed

keiki-codec-json-0.2.0.0: test/Keiki/Codec/JSON/THEventEvolutionSpec.hs

{-# LANGUAGE TemplateHaskell #-}

-- | EP-77 evolution regressions for additive event fields.
module Keiki.Codec.JSON.THEventEvolutionSpec
  ( spec,
    Discount (..),
    ItemAddedData (..),
    AdditiveEvent (..),
    additiveEventToJSON,
    additiveEventFromJSON,
    QuantityRecordedData (..),
    StructuralEvent (..),
    structuralEventToJSON,
    structuralEventFromJSON,
  )
where

import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy (ByteString)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiki.Codec.JSON.Event
  ( EventCodecOptions (..),
    FieldCodec (fcOnMissing),
    defaultEventCodecOptions,
    deriveEventCodecSkeleton,
    fieldCodec,
  )
import Test.Hspec (Spec, describe, it, shouldBe)
import Test.QuickCheck
  ( Gen,
    Property,
    chooseInt,
    elements,
    forAllShow,
    listOf1,
    oneof,
    (===),
  )

newtype Discount = Discount Int
  deriving stock (Eq, Show)

discountToJSON :: Discount -> Aeson.Value
discountToJSON (Discount amount) = Aeson.toJSON amount

discountFromJSON :: Aeson.Value -> Either String Discount
discountFromJSON value = case Aeson.fromJSON value of
  Aeson.Success amount -> Right (Discount amount)
  Aeson.Error message -> Left message

missingDiscount :: Discount
missingDiscount = Discount 0

data ItemAddedData = ItemAddedData
  { orderId :: Text,
    discount :: Discount,
    note :: Maybe Text
  }
  deriving stock (Eq, Show)

data AdditiveEvent = ItemAdded ItemAddedData
  deriving stock (Eq, Show)

$( deriveEventCodecSkeleton
     defaultEventCodecOptions
       { fieldCodecOverrides =
           Map.fromList
             [ ( "discount",
                 (fieldCodec 'discountToJSON 'discountFromJSON)
                   { fcOnMissing = Just 'missingDiscount
                   }
               )
             ],
         passthroughFields = Set.fromList ["orderId", "note"]
       }
     ''AdditiveEvent
 )

data QuantityRecordedData = QuantityRecordedData
  { quantity :: Int
  }
  deriving stock (Eq, Show)

data StructuralEvent = QuantityRecorded QuantityRecordedData
  deriving stock (Eq, Show)

upcastQuantityV1 :: Aeson.Value -> Either String Aeson.Value
upcastQuantityV1 value = case value of
  Aeson.Object object -> case KeyMap.lookup (Key.fromString "qty") object of
    Nothing -> Left "missing qty"
    Just quantityValue ->
      Right
        ( Aeson.Object
            (KeyMap.insert (Key.fromString "quantity") quantityValue object)
        )
  _ -> Left "expected an object"

$( deriveEventCodecSkeleton
     defaultEventCodecOptions
       { passthroughFields = Set.fromList ["quantity"],
         currentVersion = 2,
         upcasters = [(1, 'upcastQuantityV1)]
       }
     ''StructuralEvent
 )

-- These bytes predate the note and discount fields; do not regenerate them.
oldAdditiveBytes :: ByteString
oldAdditiveBytes = "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\"}"

decodeAdditiveBytes :: ByteString -> Either String AdditiveEvent
decodeAdditiveBytes bytes = do
  value <- Aeson.eitherDecode bytes
  additiveEventFromJSON value

decodeStructuralBytes :: ByteString -> Either String StructuralEvent
decodeStructuralBytes bytes = do
  value <- Aeson.eitherDecode bytes
  structuralEventFromJSON value

arbAdditiveEvent :: Gen AdditiveEvent
arbAdditiveEvent = do
  orderNumber <- chooseInt (0, 100000)
  discountAmount <- chooseInt (-1000, 1000)
  generatedNote <-
    oneof
      [ pure Nothing,
        Just . T.pack <$> listOf1 (elements ['a' .. 'z'])
      ]
  pure
    ( ItemAdded
        ItemAddedData
          { orderId = T.pack ("ord-" <> show orderNumber),
            discount = Discount discountAmount,
            note = generatedNote
          }
    )

roundTripProperty :: AdditiveEvent -> Property
roundTripProperty event =
  additiveEventFromJSON (additiveEventToJSON event) === Right event

spec :: Spec
spec = describe "default-on-missing event fields" $ do
  it "exports the additive fixture's wire metadata" $ do
    additiveEventSchemaVersion `shouldBe` 1
    additiveEventEventTypes `shouldBe` [T.pack "ItemAdded"]
    additiveEventKindMap
      `shouldBe` [(T.pack "ItemAdded", T.pack "ItemAdded")]

  it "decodes bytes written before the additive fields existed" $
    decodeAdditiveBytes oldAdditiveBytes
      `shouldBe` Right
        ( ItemAdded
            ItemAddedData
              { orderId = T.pack "ord-7",
                discount = missingDiscount,
                note = Nothing
              }
        )

  it "decodes a present null Maybe field as Nothing" $
    decodeAdditiveBytes
      "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\",\"discount\":12,\"note\":null}"
      `shouldBe` Right
        ( ItemAdded
            ItemAddedData
              { orderId = T.pack "ord-7",
                discount = Discount 12,
                note = Nothing
              }
        )

  it "round-trips a fully populated value" $
    let event =
          ItemAdded
            ItemAddedData
              { orderId = T.pack "ord-8",
                discount = Discount 15,
                note = Just (T.pack "priority")
              }
     in additiveEventFromJSON (additiveEventToJSON event) `shouldBe` Right event

  it "round-trips generated Nothing and Just values" $
    forAllShow arbAdditiveEvent show roundTripProperty

  it "keeps required fields strict" $
    decodeAdditiveBytes "{\"kind\":\"ItemAdded\"}"
      `shouldBe` (Left "missing field: orderId" :: Either String AdditiveEvent)

  describe "upcaster chain" $ do
    let expected = QuantityRecorded (QuantityRecordedData 3)

    it "exports the structural fixture's current wire metadata" $ do
      structuralEventSchemaVersion `shouldBe` 2
      structuralEventEventTypes `shouldBe` [T.pack "QuantityRecorded"]
      structuralEventKindMap
        `shouldBe` [(T.pack "QuantityRecorded", T.pack "QuantityRecorded")]

    it "decodes version-1 bytes after renaming qty to quantity" $
      decodeStructuralBytes
        "{\"kind\":\"QuantityRecorded\",\"v\":1,\"qty\":3}"
        `shouldBe` Right expected

    it "runs the version-1 upcaster for an absent version stamp" $
      decodeStructuralBytes
        "{\"kind\":\"QuantityRecorded\",\"qty\":3}"
        `shouldBe` Right expected

    it "decodes current-version bytes without running an old rung" $ do
      decodeStructuralBytes
        "{\"kind\":\"QuantityRecorded\",\"v\":2,\"quantity\":3}"
        `shouldBe` Right expected
      structuralEventFromJSON (structuralEventToJSON expected)
        `shouldBe` Right expected

    it "surfaces a rung failure with its source version" $
      decodeStructuralBytes
        "{\"kind\":\"QuantityRecorded\",\"v\":1}"
        `shouldBe` ( Left "upcaster from version 1: missing qty" ::
                       Either String StructuralEvent
                   )