keiki-codec-json-0.2.0.0: test/Keiki/Codec/JSON/Fixtures.hs
{-# LANGUAGE DeriveAnyClass #-}
-- | Shared fixtures for the property, sensitivity, and golden hash
-- specs. Defines an exemplar slot list (used by every spec as the
-- baseline), schema-evolution mutations of it, and an inductive
-- 'Arbitrary' generator for 'RegFile rs'.
module Keiki.Codec.JSON.Fixtures
( -- * Baseline slot list
ExemplarSlots,
-- * Maybe wire-semantics slot lists
MaybeSlots,
NestedMaybeSlots,
-- * Schema-evolution mutations (EP-36 §4 cases #1–9)
AddSlots,
RemoveSlots,
RenameSlots,
ReorderSlots,
TypeChangeSameJsonSlots,
NewtypeWrapSlots,
RecordReplaceSlots,
SplitSlots,
RenamedTypeSlots,
-- * Auxiliary types for the mutations
OrderId (..),
Address (..),
RenamedAddress (..),
-- * Arbitrary generator
ArbitraryRegFile (..),
-- * Value equality
EqRegFile (..),
)
where
import Data.Aeson qualified as Aeson
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word32)
import GHC.Generics (Generic)
import GHC.TypeLits (KnownSymbol)
-- Arbitrary UTCTime, Text, etc.
import Keiki.Core (RegFile (..), Slot)
import Keiki.Shape (CanonicalTypeName)
import Test.QuickCheck (Arbitrary (..), Gen)
import Test.QuickCheck.Instances ()
-- * Baseline slot list -------------------------------------------------------
-- | Exemplar three-slot list used by every spec as the baseline against
-- which mutations are compared.
type ExemplarSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationId", Text)
]
-- | Optional fields whose absent values are encoded as explicit JSON @null@.
type MaybeSlots =
'[ '("lastError", Maybe Text),
'("approvedAt", Maybe UTCTime),
'("shippingAddress", Maybe Address)
]
-- | A deliberately lossy aeson shape: @Just Nothing@ and @Nothing@ both
-- encode as JSON @null@.
type NestedMaybeSlots = '[ '("nested", Maybe (Maybe Int))]
-- * Schema-evolution mutations (EP-36 §4) ------------------------------------
-- | §4 case #1 — Add slot. A fourth slot is appended to the baseline.
type AddSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationId", Text),
'("dispatchedAt", UTCTime)
]
-- | §4 case #2 — Remove slot. The baseline minus @correlationId@.
type RemoveSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime)
]
-- | §4 case #3 — Rename slot. @cooldownUntil@ becomes @retryAfter@.
type RenameSlots =
'[ '("retryCount", Int),
'("retryAfter", UTCTime),
'("correlationId", Text)
]
-- | §4 case #4 — Reorder slots. @cooldownUntil@ and @retryCount@ swap.
type ReorderSlots =
'[ '("cooldownUntil", UTCTime),
'("retryCount", Int),
'("correlationId", Text)
]
-- | §4 case #5 — Slot type change, same JSON. @retryCount@ moves from
-- 'Int' to 'Word32' (both encode identically on positive integers, but
-- the TypeRep differs).
type TypeChangeSameJsonSlots =
'[ '("retryCount", Word32),
'("cooldownUntil", UTCTime),
'("correlationId", Text)
]
-- | §4 case #6 — Newtype wrap. @correlationId :: Text@ becomes
-- @correlationId :: OrderId@ where 'OrderId' is a 'Text' newtype with
-- @deriving newtype@ JSON instances. Wire-compatible, but the type's
-- name differs (@OrderId@ vs @Text@) so the hash changes.
type NewtypeWrapSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationId", OrderId)
]
-- | §4 case #7 — Replace primitive with record. @correlationId :: Text@
-- becomes @correlationId :: Address@.
type RecordReplaceSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationId", Address)
]
-- | §4 case #8 — Split slot. @correlationId :: Text@ is split into two
-- slots @correlationStream :: Text@ + @correlationId :: Text@.
type SplitSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationStream", Text),
'("correlationId", Text)
]
-- | §4 case #9 — Slot type's internal record changes. To exercise the
-- hash's sensitivity here we use a /distinctly-named/ second type
-- 'RenamedAddress' in place of 'Address'. (Two definitions of @data
-- Address@ with different fields cannot coexist in one Haskell
-- module; for the hash to discriminate them they must differ in
-- @tyConModule + tyConName@. The §4 row notes "Maybe (TypeRep)" — the
-- hash flips when the user actually renames the type to signal the
-- breaking change, which is the disciplined practice.)
type RenamedTypeSlots =
'[ '("retryCount", Int),
'("cooldownUntil", UTCTime),
'("correlationId", RenamedAddress)
]
-- * Auxiliary types used by the mutations ------------------------------------
newtype OrderId = OrderId Text
deriving stock (Eq, Show, Generic)
deriving newtype (Aeson.ToJSON, Aeson.FromJSON, Arbitrary)
deriving anyclass (CanonicalTypeName)
data Address = Address
{ line :: Text,
city :: Text,
postcode :: Text
}
deriving stock (Eq, Show, Generic)
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON, CanonicalTypeName)
instance Arbitrary Address where
arbitrary = Address <$> arbitrary <*> arbitrary <*> arbitrary
-- | A distinctly-named near-copy of 'Address' used by 'RenamedTypeSlots'
-- to demonstrate the §4 case #9 detection path (rename-on-breaking-
-- change). Adds a @country@ field to also illustrate the "Address adds
-- country field" example from §4.
data RenamedAddress = RenamedAddress
{ line :: Text,
city :: Text,
postcode :: Text,
country :: Text
}
deriving stock (Eq, Show, Generic)
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON, CanonicalTypeName)
instance Arbitrary RenamedAddress where
arbitrary =
RenamedAddress <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary
-- * Arbitrary generator for 'RegFile rs' -------------------------------------
-- | Inductive generator over the slot list. Each slot value comes from
-- the slot type's 'Arbitrary' instance.
class ArbitraryRegFile (rs :: [Slot]) where
arbRegFile :: Gen (RegFile rs)
instance ArbitraryRegFile '[] where
arbRegFile = pure RNil
instance
( KnownSymbol s,
Arbitrary t,
ArbitraryRegFile rs
) =>
ArbitraryRegFile ('(s, t) ': rs)
where
arbRegFile = RCons (Proxy @s) <$> arbitrary <*> arbRegFile
-- * Value equality for 'RegFile rs' -----------------------------------------
-- | Inductive heterogeneous equality. Each slot value is compared with its
-- own 'Eq' instance.
class EqRegFile (rs :: [Slot]) where
eqRegFile :: RegFile rs -> RegFile rs -> Bool
instance EqRegFile '[] where
eqRegFile _ _ = True
instance (Eq t, EqRegFile rs) => EqRegFile ('(s, t) ': rs) where
eqRegFile (RCons _ x xs) (RCons _ y ys) = x == y && eqRegFile xs ys