packages feed

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

-- | Checked-in persistence fixtures for whole register files and their shape.
--
-- Each register fixture is checked in both directions: current code decodes
-- historical bytes to a fixed heterogeneous value, and current encoding must
-- remain byte-identical. The Value and Encoding paths have separate files
-- because their object-key orders deliberately differ.
--
-- To regenerate only for an intentional wire-format change:
--
-- > KEIKI_UPDATE_GOLDENS=1 cabal test keiki-codec-json:keiki-codec-json-test
-- > cabal test keiki-codec-json:keiki-codec-json-test
-- > git diff keiki-codec-json/test/golden/
--
-- The first command rewrites the files and fails deliberately. The second must
-- be green after the environment variable is unset and the diff reviewed.
module Keiki.Codec.JSON.GoldenFileSpec (spec) where

import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as AesonEnc
import Data.ByteString.Lazy qualified as LBS
import Data.Proxy (Proxy (..))
import Data.Text qualified as T
import Data.Time.Clock (UTCTime)
import Keiki.Codec.JSON (RegFileToJSON, regFileFromJSON, regFileToEncoding, regFileToJSON)
import Keiki.Codec.JSON.Fixtures
  ( Address (..),
    EqRegFile (eqRegFile),
    ExemplarSlots,
    MaybeSlots,
  )
import Keiki.Codec.JSON.THEventEvolutionSpec qualified as Evolution
import Keiki.Codec.JSON.THEventSpec qualified as Event
import Keiki.Core (RegFile (..))
import Keiki.Shape (regFileShapeCanonical, regFileShapeHash)
import System.Environment (lookupEnv)
import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe)

data EncoderPath = ValuePath | EncodingPath

spec :: Spec
spec = do
  describe "checked-in RegFile bytes" $ do
    regFileGoldenSpec
      "exemplar Value path"
      "test/golden/exemplar-regfile.value.json"
      ValuePath
      exemplarRegFile
    regFileGoldenSpec
      "exemplar Encoding path"
      "test/golden/exemplar-regfile.encoding.json"
      EncodingPath
      exemplarRegFile
    regFileGoldenSpec
      "all-Just Maybe slots, Value path"
      "test/golden/maybe-regfile-just.value.json"
      ValuePath
      maybeJustRegFile
    regFileGoldenSpec
      "all-Just Maybe slots, Encoding path"
      "test/golden/maybe-regfile-just.encoding.json"
      EncodingPath
      maybeJustRegFile
    regFileGoldenSpec
      "all-Nothing Maybe slots, Value path"
      "test/golden/maybe-regfile-nothing.value.json"
      ValuePath
      maybeNothingRegFile
    regFileGoldenSpec
      "all-Nothing Maybe slots, Encoding path"
      "test/golden/maybe-regfile-nothing.encoding.json"
      EncodingPath
      maybeNothingRegFile

  shapeGoldenSpec
  eventGoldenSpecs

regFileGoldenSpec ::
  forall rs.
  (RegFileToJSON rs, EqRegFile rs) =>
  String ->
  FilePath ->
  EncoderPath ->
  RegFile rs ->
  Spec
regFileGoldenSpec label path encoder expected = describe label $ do
  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")
  case update of
    Just _ -> do
      runIO (LBS.writeFile path currentBytes)
      it "regenerates and requires a clean review run" $
        expectationFailure regenerationMessage
    Nothing -> do
      historicalBytes <- runIO (LBS.readFile path)
      it "decodes checked-in bytes to the fixed value" $
        case Aeson.decode historicalBytes of
          Nothing -> expectationFailure "golden file is not valid JSON"
          Just value -> case regFileFromJSON @rs value of
            Left message -> expectationFailure ("golden decode failed: " <> message)
            Right actual -> eqRegFile actual expected `shouldBe` True
      it "freezes current encoder bytes" $
        currentBytes `shouldBe` historicalBytes
  where
    currentBytes = case encoder of
      ValuePath -> Aeson.encode (regFileToJSON expected)
      EncodingPath -> AesonEnc.encodingToLazyByteString (regFileToEncoding expected)

shapeGoldenSpec :: Spec
shapeGoldenSpec = describe "checked-in ExemplarSlots shape" $ do
  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")
  case update of
    Just _ -> do
      runIO (LBS.writeFile shapePath currentBytes)
      it "regenerates and requires a clean review run" $
        expectationFailure regenerationMessage
    Nothing -> do
      historicalBytes <- runIO (LBS.readFile shapePath)
      it "pins the canonical text and hash" $
        Aeson.decode historicalBytes `shouldBe` Just currentValue
      it "freezes the shape fixture bytes" $
        currentBytes `shouldBe` historicalBytes
  where
    shapePath = "test/golden/exemplar-shape.json"
    currentValue =
      Aeson.object
        [ "canonical" Aeson..= regFileShapeCanonical (Proxy @ExemplarSlots),
          "hash" Aeson..= regFileShapeHash (Proxy @ExemplarSlots)
        ]
    currentBytes = Aeson.encode currentValue

eventGoldenSpecs :: Spec
eventGoldenSpecs = describe "checked-in versioned event envelopes" $ do
  eventGoldenSpec
    "record payload with pinned kind and version"
    "test/golden/event/order-placed.v1.json"
    placedEvent
    Event.orderEventToJSON
    Event.orderEventFromJSON
  eventGoldenSpec
    "no-payload singleton with version"
    "test/golden/event/order-cancelled.v1.json"
    Event.Cancelled
    Event.orderEventToJSON
    Event.orderEventFromJSON
  eventGoldenSpec
    "current additive payload with explicit defaults"
    "test/golden/event/item-added.v1.json"
    additiveEvent
    Evolution.additiveEventToJSON
    Evolution.additiveEventFromJSON
  historicalEventGoldenSpec
    "pre-addition payload fills missing fields and absent version"
    "test/golden/event/item-added.pre-defaults.json"
    additiveEvent
    historicalAdditiveBytes
    Evolution.additiveEventFromJSON
  eventGoldenSpec
    "current structural payload after upcasting era"
    "test/golden/event/quantity-recorded.v2.json"
    structuralEvent
    Evolution.structuralEventToJSON
    Evolution.structuralEventFromJSON
  historicalEventGoldenSpec
    "version-1 payload runs the quantity upcaster"
    "test/golden/event/quantity-recorded.v1.json"
    structuralEvent
    historicalStructuralBytes
    Evolution.structuralEventFromJSON

eventGoldenSpec ::
  (Eq event, Show event) =>
  String ->
  FilePath ->
  event ->
  (event -> Aeson.Value) ->
  (Aeson.Value -> Either String event) ->
  Spec
eventGoldenSpec label path expected encoder decoder = describe label $ do
  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")
  case update of
    Just _ -> do
      runIO (LBS.writeFile path currentBytes)
      it "regenerates and requires a clean review run" $
        expectationFailure regenerationMessage
    Nothing -> do
      historicalBytes <- runIO (LBS.readFile path)
      it "decodes checked-in bytes to the fixed event" $
        case Aeson.eitherDecode historicalBytes of
          Left message -> expectationFailure ("golden file is not valid JSON: " <> message)
          Right value -> decoder value `shouldBe` Right expected
      it "freezes current event encoder bytes" $
        currentBytes `shouldBe` historicalBytes
  where
    currentBytes = Aeson.encode (encoder expected)

historicalEventGoldenSpec ::
  (Eq event, Show event) =>
  String ->
  FilePath ->
  event ->
  LBS.ByteString ->
  (Aeson.Value -> Either String event) ->
  Spec
historicalEventGoldenSpec label path expected frozenBytes decoder = describe label $ do
  update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")
  case update of
    Just _ -> do
      runIO (LBS.writeFile path frozenBytes)
      it "regenerates and requires a clean review run" $
        expectationFailure regenerationMessage
    Nothing -> do
      historicalBytes <- runIO (LBS.readFile path)
      it "decodes the historical document to the current event" $
        case Aeson.eitherDecode historicalBytes of
          Left message -> expectationFailure ("golden file is not valid JSON: " <> message)
          Right value -> decoder value `shouldBe` Right expected
      it "freezes the historical document bytes" $
        historicalBytes `shouldBe` frozenBytes

exemplarRegFile :: RegFile ExemplarSlots
exemplarRegFile =
  RCons (Proxy @"retryCount") (3 :: Int) $
    RCons (Proxy @"cooldownUntil") exemplarTime $
      RCons (Proxy @"correlationId") (T.pack "order-123") RNil

maybeJustRegFile :: RegFile MaybeSlots
maybeJustRegFile =
  RCons (Proxy @"lastError") (Just (T.pack "address invalid")) $
    RCons (Proxy @"approvedAt") (Just exemplarTime) $
      RCons
        (Proxy @"shippingAddress")
        (Just (Address (T.pack "1 Main St") (T.pack "Portland") (T.pack "97201")))
        RNil

maybeNothingRegFile :: RegFile MaybeSlots
maybeNothingRegFile =
  RCons (Proxy @"lastError") Nothing $
    RCons (Proxy @"approvedAt") Nothing $
      RCons (Proxy @"shippingAddress") Nothing RNil

exemplarTime :: UTCTime
exemplarTime = read "2026-01-02 03:04:05 UTC"

placedEvent :: Event.OrderEvent
placedEvent = Event.Placed (Event.PlacedData (Event.OrderId 7) 3)

additiveEvent :: Evolution.AdditiveEvent
additiveEvent =
  Evolution.ItemAdded
    Evolution.ItemAddedData
      { Evolution.orderId = T.pack "ord-7",
        Evolution.discount = Evolution.Discount 0,
        Evolution.note = Nothing
      }

structuralEvent :: Evolution.StructuralEvent
structuralEvent = Evolution.QuantityRecorded (Evolution.QuantityRecordedData 3)

historicalAdditiveBytes :: LBS.ByteString
historicalAdditiveBytes = "{\"kind\":\"ItemAdded\",\"orderId\":\"ord-7\"}"

historicalStructuralBytes :: LBS.ByteString
historicalStructuralBytes = "{\"kind\":\"QuantityRecorded\",\"v\":1,\"qty\":3}"

regenerationMessage :: String
regenerationMessage =
  "golden regenerated; unset KEIKI_UPDATE_GOLDENS, re-run, and review git diff"