packages feed

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

{-# LANGUAGE TemplateHaskell #-}

-- | EP-59 M3 tests for
-- 'Keiki.Codec.JSON.Event.deriveEventCodecSkeleton'.
--
-- The splice derives a @kind@-discriminated encoder/decoder for an event
-- /sum/ type. This spec exercises round-trip, the @kind@ discriminator,
-- that a per-field override actually runs (rather than a generic instance),
-- and the constructor-name list, against a 3-constructor fixture: one
-- payload event with an overridden newtype field, one all-passthrough
-- payload event, and one singleton.
--
-- == Negative checks (manual)
--
-- The no-silent-fallback contract is that an /unhandled/ field (one in
-- neither 'fieldCodecOverrides' nor 'passthroughFields') is never silently
-- given a generic codec. There are two behaviours, neither expressible as a
-- passing unit test:
--
-- 1. @onMissingCodec = FailAtCompileTime@ (the default). Add a field to a
--    payload record without listing it, e.g. give @PlacedData@ a
--    @discount :: Int@ field and do NOT add @"discount"@ to
--    'passthroughFields'. Run
--    @cabal build keiki-codec-json:keiki-codec-json-test@. The build must
--    fail with a message of the form:
--
--    @
--    deriveEventCodecSkeleton: Keiki.Codec.JSON.THEventSpec.OrderEvent has
--    field(s) with no provided codec and onMissingCodec = FailAtCompileTime:
--      - Placed.discount :: GHC.Types.Int
--    Add each field to fieldCodecOverrides or passthroughFields, or set
--    onMissingCodec = EmitTodoBindings.
--    @
--
--    (The type name and field type print fully qualified — @show@/@pprint@
--    of the reified names.) Revert the field.
--
-- 2. @onMissingCodec = EmitTodoBindings@. With the same unhandled field but
--    @onMissingCodec = EmitTodoBindings@ in the options, the build SUCCEEDS
--    and a top-level binding @_todo_Placed_discount :: a@ is emitted whose
--    body is @error "TODO: provide a FieldCodec for Placed.discount :: Int"@.
--    Any actual encode/decode of that field throws the named error rather
--    than guessing. Verify by adding @discount@, switching to
--    @EmitTodoBindings@, and observing the module compiles; referencing
--    @_todo_Placed_discount@ in @cabal repl@ shows the binding exists.
--    Revert afterwards.
--
-- 3. Duplicate wire kinds. Add
--    @kindOverrides = Map.fromList [("Placed", "order.event"),
--    ("Shipped", "order.event")]@, build the test target, and expect:
--
--    @
--    deriveEventCodecSkeleton: wire kind "order.event" is claimed by more
--    than one constructor: Placed, Shipped. Wire kinds must be unique per
--    event type.
--    @
--
--    Revert the override.
--
-- 4. Discriminator collision. Add @kind :: Text@ to @PlacedData@ and list
--    @"kind"@ in 'passthroughFields', build the test target, and expect:
--
--    @
--    deriveEventCodecSkeleton: payload field Placed.kind collides with
--    kindFieldName "kind"; rename the field or choose a kindFieldName no
--    payload uses.
--    @
--
--    Revert the field and passthrough entry.
--
-- 5. Incomplete upcaster chain. In @THEventEvolutionSpec@, set the
--    @StructuralEvent@ codec's @currentVersion = 3@ while retaining only
--    @upcasters = [(1, 'upcastQuantityV1)]@. Build the test target and expect:
--
--    @
--    deriveEventCodecSkeleton: upcasters must cover from-versions [1..2]
--    exactly; missing: [2]
--    @
--
--    Restore @currentVersion = 2@.
--
-- Checks (1) and (2) were verified by hand on 2026-06-06; the observed
-- compile-fail text matched (1) verbatim. Checks (3), (4), and (5) were
-- verified by hand on 2026-07-12 with the messages above.
module Keiki.Codec.JSON.THEventSpec
  ( spec,
    OrderId (..),
    PlacedData (..),
    OrderEvent (..),
    orderEventToJSON,
    orderEventFromJSON,
  )
where

import Data.Aeson qualified as Aeson
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 (..),
    defaultEventCodecOptions,
    deriveEventCodecSkeleton,
    fieldCodec,
  )
import Test.Hspec (Spec, describe, it, shouldBe)
import Text.Read (readMaybe)

-- * Fixtures -----------------------------------------------------------------

-- | A newtype field whose JSON form is overridden (a @"ord-<n>"@ string),
-- proving the override hook runs instead of a generic 'Int' encoding.
newtype OrderId = OrderId Int
  deriving stock (Eq, Show)

orderIdToJSON :: OrderId -> Aeson.Value
orderIdToJSON (OrderId n) = Aeson.toJSON (T.pack ("ord-" <> show n))

orderIdFromJSON :: Aeson.Value -> Either String OrderId
orderIdFromJSON v = case v of
  Aeson.String t
    | Just rest <- T.stripPrefix (T.pack "ord-") t,
      Just n <- readMaybe (T.unpack rest) ->
        Right (OrderId n)
  _ -> Left "orderIdFromJSON: expected an \"ord-<int>\" string"

data PlacedData = PlacedData
  { orderId :: OrderId,
    qty :: Int
  }
  deriving stock (Eq, Show)

data ShippedData = ShippedData
  { trackingNo :: Text
  }
  deriving stock (Eq, Show)

data OrderEvent
  = Placed PlacedData
  | Shipped ShippedData
  | Cancelled
  deriving stock (Eq, Show)

$( deriveEventCodecSkeleton
     defaultEventCodecOptions
       { fieldCodecOverrides =
           Map.fromList [("orderId", fieldCodec 'orderIdToJSON 'orderIdFromJSON)],
         passthroughFields = Set.fromList ["qty", "trackingNo"],
         kindOverrides = Map.fromList [("Placed", "order.placed")]
       }
     ''OrderEvent
 )

-- * Spec --------------------------------------------------------------------

spec :: Spec
spec = describe "deriveEventCodecSkeleton" $ do
  let placed = Placed (PlacedData (OrderId 7) 3)
      shipped = Shipped (ShippedData (T.pack "1Z999"))
      cancelled = Cancelled

  describe "round-trip (decode . encode == Right)" $ do
    it "Placed (payload with an overridden field) round-trips" $
      orderEventFromJSON (orderEventToJSON placed) `shouldBe` Right placed

    it "Shipped (all-passthrough payload) round-trips" $
      orderEventFromJSON (orderEventToJSON shipped) `shouldBe` Right shipped

    it "Cancelled (singleton) round-trips" $
      orderEventFromJSON (orderEventToJSON cancelled) `shouldBe` Right cancelled

  describe "kind discriminator + override usage" $ do
    -- orderEventToJSON (Placed (PlacedData (OrderId 7) 3))
    -- == {"kind":"order.placed","v":1,"orderId":"ord-7","qty":3}
    it "Placed carries the kind key, the override output, and the passthrough field" $
      orderEventToJSON placed
        `shouldBe` Aeson.object
          [ "kind" Aeson..= (T.pack "order.placed" :: Text),
            "v" Aeson..= (1 :: Int),
            "orderId" Aeson..= (T.pack "ord-7" :: Text),
            "qty" Aeson..= (3 :: Int)
          ]

    it "a pinned wire kind decodes independently of the Haskell constructor name" $
      -- If a generic Int codec had been silently used, this would be 7.
      orderEventFromJSON
        ( Aeson.object
            [ "kind" Aeson..= (T.pack "order.placed" :: Text),
              "orderId" Aeson..= (T.pack "ord-7" :: Text),
              "qty" Aeson..= (3 :: Int)
            ]
        )
        `shouldBe` Right placed

    it "Cancelled encodes to just the envelope keys" $
      orderEventToJSON cancelled
        `shouldBe` Aeson.object
          [ "kind" Aeson..= (T.pack "Cancelled" :: Text),
            "v" Aeson..= (1 :: Int)
          ]

  describe "schema version envelope" $ do
    it "exports the current schema version" $
      orderEventSchemaVersion `shouldBe` 1

    it "treats a missing version key as version 1" $
      orderEventFromJSON
        ( Aeson.object
            [ "kind" Aeson..= (T.pack "order.placed" :: Text),
              "orderId" Aeson..= (T.pack "ord-7" :: Text),
              "qty" Aeson..= (3 :: Int)
            ]
        )
        `shouldBe` Right placed

    it "rejects an event written by a newer codec" $
      orderEventFromJSON
        ( Aeson.object
            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),
              "v" Aeson..= (2 :: Int)
            ]
        )
        `shouldBe` ( Left "event schema version 2 is ahead of codec version 1" ::
                       Either String OrderEvent
                   )

    it "rejects a schema version below 1" $
      orderEventFromJSON
        ( Aeson.object
            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),
              "v" Aeson..= (0 :: Int)
            ]
        )
        `shouldBe` (Left "invalid event schema version: 0" :: Either String OrderEvent)

    it "rejects a non-integral schema-version value" $
      orderEventFromJSON
        ( Aeson.object
            [ "kind" Aeson..= (T.pack "Cancelled" :: Text),
              "v" Aeson..= (T.pack "one" :: Text)
            ]
        )
        `shouldBe` ( Left "field v: expected an integer schema version" ::
                       Either String OrderEvent
                   )

  describe "decoder error paths" $ do
    it "an unknown kind is reported" $
      orderEventFromJSON
        (Aeson.object ["kind" Aeson..= (T.pack "Nope" :: Text)])
        `shouldBe` ( Left
                       "unknown event kind: Nope (expected one of: order.placed, Shipped, Cancelled)" ::
                       Either String OrderEvent
                   )

    it "a non-object is reported" $
      orderEventFromJSON (Aeson.toJSON (5 :: Int))
        `shouldBe` (Left "orderEvent: expected a JSON object" :: Either String OrderEvent)

  describe "Keiro-feeding surfaces" $ do
    it "EventTypes lists resolved wire kinds in declaration order" $
      orderEventEventTypes
        `shouldBe` map T.pack ["order.placed", "Shipped", "Cancelled"]

    it "KindMap pairs each constructor name with its kind string" $
      orderEventKindMap
        `shouldBe` [ (T.pack "Placed", T.pack "order.placed"),
                     (T.pack "Shipped", T.pack "Shipped"),
                     (T.pack "Cancelled", T.pack "Cancelled")
                   ]