packages feed

plexus-protocol-0.6.0.0: test/Main.hs

-- | plexus-protocol 0.6 against plexus 0.7.
--
-- The golden half reads RETURNS-7's conformance fixture by sibling path
-- (@../plexus-macros/fixtures/conformance/expected/@) and checks that every
-- schema, wire frame and error frame there parses into the Haskell types and
-- re-encodes to the same JSON (compared as parsed values, so key order does
-- not count; frame @timestamp@ normalized to 0 — the fixture README's
-- comparison rules). Its hashes are real since RETURNS-10: every frame's
-- @plexus_hash@ is the schema's @hash@.
--
-- The rest pins the shapes the fixture does not exercise: every
-- 'RemoteError' variant against RETURNS-4's wire literals
-- (@plexus-client/tests/remote_error_wire_tests.rs@), the JSON-RPC
-- rejection's @data@, and the decode errors that replace 0.6's silent
-- defaults.
module Main (main) where

import Control.Monad (forM_, unless)
import Data.Aeson
import Data.Aeson.Types (parseEither)
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import Data.Foldable (toList)
import Data.List (isInfixOf, sort)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath (takeExtension, (</>))
import Test.Hspec

import Plexus.Auth (Refusal (..), View (..))
import Plexus.RemoteError
import Plexus.Schema.Recursive
import Plexus.Types hiding (GuidanceErrorType (..))

-- ============================================================================
-- The fixture
-- ============================================================================

fixtureDir :: FilePath
fixtureDir = ".." </> "plexus-macros" </> "fixtures" </> "conformance" </> "expected"

readJson :: FilePath -> IO Value
readJson path = do
  bytes <- LBS.readFile path
  either (fail . ((path <> ": ") <>)) pure (eitherDecode bytes)

-- | One JSON value per non-empty line.
readJsonl :: FilePath -> IO [Value]
readJsonl path = do
  bytes <- BS.readFile path
  let ls = filter (not . BS.null) (BS8.lines bytes)
  sequence
    [ either (\e -> fail (path <> ":" <> show n <> ": " <> e)) pure (eitherDecodeStrict l)
    | (n, l) <- zip [1 :: Int ..] ls
    ]

-- | The wire files @calls.json@ names, and the @errors.jsonl@ lines it names.
data Calls = Calls
  { callWireFiles  :: Set.Set FilePath
  , callErrorLines :: [Int]
  }

readCalls :: IO Calls
readCalls = do
  v <- readJson (fixtureDir </> "calls.json")
  calls <- either fail pure $ parseEither (withArray "calls" (mapM callFrames . toList)) v
  pure Calls
    { callWireFiles  = Set.fromList [f | (f, Nothing) <- calls]
    , callErrorLines = sort [n | ("errors.jsonl", Just n) <- calls]
    }
  where
    callFrames = withObject "call" $ \o -> (,) <$> o .: "frames" <*> o .:? "line"

-- ============================================================================
-- Comparing (the fixture README's rules)
-- ============================================================================

-- | A frame's @metadata.timestamp@, set to 0.
normalizeFrame :: Value -> Value
normalizeFrame (Object o) = Object $ case KM.lookup "metadata" o of
  Just m  -> KM.insert "metadata" (zeroTimestamp m) o
  Nothing -> o
  where
    zeroTimestamp (Object m) | KM.member "timestamp" m = Object (KM.insert "timestamp" (Number 0) m)
    zeroTimestamp m = m
normalizeFrame v = v

-- | @expected `matches` actual@: equal as parsed JSON.
matches :: Value -> Value -> Bool
matches (Object e) (Object a) =
  sort (KM.keys e) == sort (KM.keys a)
    && and [sub ve (KM.lookup k a) | (k, ve) <- KM.toList e]
  where
    sub ve (Just va) = matches ve va
    sub _ Nothing = False
matches (Array e) (Array a) = length e == length a && and (zipWith matches (toList e) (toList a))
matches e a = e == a

-- | Parse @v@ as an @a@, re-encode it, and require the result to match @v@.
roundTrips :: forall a. (FromJSON a, ToJSON a) => (Value -> Value) -> Value -> IO a
roundTrips norm v = case parseEither parseJSON v of
  Left err -> expectationFailure ("does not parse: " <> err <> "\n  " <> encodeStr v) >> fail "unreachable"
  Right (x :: a) -> do
    let back = toJSON x
    unless (norm v `matches` norm back) $
      expectationFailure $ "re-encodes differently\n  expected: " <> encodeStr v <> "\n  actual:   " <> encodeStr back
    pure x

encodeStr :: Value -> String
encodeStr = BS8.unpack . LBS.toStrict . encode

decodes :: FromJSON a => Value -> Either String a
decodes = parseEither parseJSON

field :: Text -> Value -> Maybe Value
field k (Object o) = KM.lookup (Key.fromText k) o
field _ _ = Nothing

title :: Value -> Maybe Value
title = field "title"

-- | 16 lowercase hex characters: every Plexus hash's form (RETURNS-10).
isHash16 :: Text -> Bool
isHash16 h = T.length h == 16 && T.all (`elem` ("0123456789abcdef" :: String)) h

-- | The fixture activation's hash, as @schema.json@ pins it.
fixtureHash :: IO Text
fixtureHash = do
  v <- readJson (fixtureDir </> "schema.json")
  either fail (pure . psHash) (decodes @PluginSchema v)

-- ============================================================================
-- Tests
-- ============================================================================

main :: IO ()
main = do
  present <- doesDirectoryExist fixtureDir
  unless present $
    fail $ "conformance fixture not found at " <> fixtureDir
      <> " (plexus-macros on returns/0.7 must be this repository's sibling)"
  hspec spec

spec :: Spec
spec = do
  schemaSpec
  wireSpec
  errorsSpec
  remoteErrorSpec
  rejectionSpec
  strictnessSpec
  mergedSpec

schemaSpec :: Spec
schemaSpec = describe "expected/schema.json" $ do
  let load = readJson (fixtureDir </> "schema.json")

  it "parses as a PluginSchema and re-encodes to the same JSON" $ do
    v <- load
    schema <- roundTrips @PluginSchema id v
    psNamespace schema `shouldBe` "conformance"
    psVersion schema `shouldBe` "0.7.0"
    fmap isHash16 (psSelfHash schema) `shouldBe` Just True
    isHash16 (psHash schema) `shouldBe` True
    forM_ (psMethods schema) $ \m -> (methodName m, isHash16 (methodHash m)) `shouldBe` (methodName m, True)
    psChildren schema `shouldBe` Nothing
    map methodName (psMethods schema) `shouldBe` ["ping", "lookup", "count", "scan", "load", "warm", "schema"]

  it "carries an activation's request schema as psRequest (CONTEXT-5) and omits it when absent" $ do
    v <- load
    plain <- either fail pure (decodes @PluginSchema v)
    psRequest plain `shouldBe` Nothing
    let req = object
          [ "type" .= ("object" :: Text)
          , "properties" .= object
              [ "auth_token" .= object [ "type" .= ("string" :: Text), "x-plexus-source" .= object [ "from" .= ("cookie" :: Text), "name" .= ("access_token" :: Text) ] ] ]
          ]
        withReq = case v of
          Object o -> Object (KM.insert "request" req o)
          other    -> other
    schema <- roundTrips @PluginSchema id withReq
    psRequest schema `shouldBe` Just req
    toJSON plain `shouldBe` v

  it "parses as a SchemaResult (a plugin) and re-encodes to the same JSON" $ do
    v <- load
    result <- roundTrips @SchemaResult id v
    case result of
      SchemaPlugin _ -> pure ()
      SchemaMethod _ -> expectationFailure "a plugin schema parsed as a method"

  it "each method alone parses as a MethodSchema and as a SchemaResult (a method)" $ do
    v <- load
    methods <- maybe (fail "no methods") (pure . toList) $ case field "methods" v of
      Just (Array ms) -> Just ms
      _ -> Nothing
    length methods `shouldBe` 7
    forM_ methods $ \m -> do
      _ <- roundTrips @MethodSchema id m
      result <- roundTrips @SchemaResult id m
      case result of
        SchemaMethod _ -> pure ()
        SchemaPlugin _ -> expectationFailure "a method schema parsed as a plugin"

  it "reads each method's returns, error, progress and cardinality" $ do
    schema <- load >>= either fail pure . decodes @PluginSchema
    let shape m =
          ( methodName m
          , title (methodReturns m)
          , title =<< methodError m
          , title =<< methodProgress m
          , methodCardinality m
          )
    map shape (psMethods schema) `shouldBe`
      [ ("ping",   Just "Pong",    Nothing,            Nothing,         CardinalityOne)
      , ("lookup", Just "Thing",   Just "LookupError", Nothing,         CardinalityOne)
      , ("count",  Just "Tick",    Nothing,            Nothing,         CardinalityMany)
      , ("scan",   Just "Row",     Just "ScanError",   Nothing,         CardinalityMany)
      , ("load",   Just "Loaded",  Just "LoadError",   Just "LoadStep", CardinalityMany)
      , ("warm",   Just "Warmed",  Nothing,            Just "Note",     CardinalityMany)
      , ("schema", Just "SchemaResult", Nothing,      Nothing,         CardinalityOne)  -- the macro-generated method (RETURNS-26: a PluginSchema, or one MethodSchema)
      ]
    forM_ (psMethods schema) $ \m -> do
      methodHttpMethod m `shouldBe` "POST"
      methodRole m `shouldBe` MethodRoleRpc
      methodBidirectional m `shouldBe` False

wireSpec :: Spec
wireSpec = describe "expected/wire/*.jsonl" $ do
  let wireDir = fixtureDir </> "wire"
      frames f = readJsonl (wireDir </> f)
      parsed f = frames f >>= mapM (either fail pure . decodes @PlexusStreamItem)

  it "holds exactly the wire files calls.json names" $ do
    files <- filter ((== ".jsonl") . takeExtension) <$> listDirectory wireDir
    calls <- readCalls
    Set.fromList (map ("wire" </>) files) `shouldBe` callWireFiles calls

  it "every frame of every call parses and re-encodes to the same JSON" $ do
    hash <- fixtureHash
    files <- sort . filter ((== ".jsonl") . takeExtension) <$> listDirectory wireDir
    length files `shouldBe` 7
    forM_ files $ \f -> do
      vs <- frames f
      items <- mapM (roundTrips @PlexusStreamItem normalizeFrame) vs
      -- each call ends in its one done
      [() | StreamDone{} <- items] `shouldBe` [()]
      case last items of
        StreamDone{} -> pure ()
        other -> expectationFailure (f <> " does not end in done: " <> show other)
      forM_ items $ \item -> do
        itemProvenance item `shouldBe` Provenance ["conformance"]
        itemPlexusHash item `shouldBe` hash

  it "load: two typed progress frames (LoadStep), then its {ok} item" $ do
    items <- parsed "load.jsonl"
    [ (ct, c) | StreamProgress _ _ ct c <- items ] `shouldBe`
      [ ("conformance.load", object ["type" .= ("batch" :: Text), "done" .= (1 :: Int), "of" .= (2 :: Int)])
      , ("conformance.load", object ["type" .= ("batch" :: Text), "done" .= (2 :: Int), "of" .= (2 :: Int)])
      ]
    [ (ct, c) | StreamData _ _ ct c <- items ] `shouldBe`
      [ ("conformance.load", object ["ok" .= object ["items" .= (2 :: Int)]]) ]

  it "warm: two Note progress frames, the second with a percentage" $ do
    items <- parsed "warm.jsonl"
    [ c | StreamProgress _ _ "conformance.warm" c <- items ] `shouldBe`
      [ object ["message" .= ("cache cold" :: Text)]
      , object ["message" .= ("cache warming" :: Text), "percentage" .= (50.0 :: Double)]
      ]

  it "scan: an {err} item between two {ok} items, the stream carrying on" $ do
    items <- parsed "scan.jsonl"
    [ c | StreamData _ _ "conformance.scan" c <- items ] `shouldBe`
      [ object ["ok" .= object ["key" .= ("a1" :: Text)]]
      , object ["err" .= object ["type" .= ("unreadable" :: Text), "key" .= ("a2" :: Text)]]
      , object ["ok" .= object ["key" .= ("a3" :: Text)]]
      ]

  it "plain-T methods carry their item unwrapped" $ do
    ping <- parsed "ping.jsonl"
    [ c | StreamData _ _ _ c <- ping ] `shouldBe` [object ["message" .= ("pong" :: Text)]]
    count <- parsed "count.jsonl"
    [ c | StreamData _ _ _ c <- count ] `shouldBe` [object ["n" .= n] | n <- [1 :: Int, 2, 3]]

errorsSpec :: Spec
errorsSpec = describe "expected/errors.jsonl" $ do
  let load = readJsonl (fixtureDir </> "errors.jsonl")

  it "holds exactly the lines calls.json names" $ do
    vs <- load
    calls <- readCalls
    callErrorLines calls `shouldBe` [1 .. length vs]

  it "every error frame parses and re-encodes to the same JSON" $ do
    hash <- fixtureHash
    vs <- load
    items <- mapM (roundTrips @PlexusStreamItem normalizeFrame) vs
    length items `shouldBe` 3
    forM_ items $ \item -> itemPlexusHash item `shouldBe` hash

  it "carries a typed RemoteError, not recoverable" $ do
    items <- load >>= mapM (either fail pure . decodes @PlexusStreamItem)
    [ (e, r) | StreamError _ _ e r <- items ] `shouldBe`
      [ (MethodNotFound "conformance" "frob", False)
      , (InvalidParams (Just "id") Missing, False)
      , (InvalidParams (Just "to") (Malformed "invalid type: string \"three\", expected u32"), False)
      ]
    [ renderRemoteError e | StreamError _ _ e _ <- items ] `shouldBe`
      [ "method not found: conformance.frob"
      , "invalid params: missing `id`"
      , "invalid params: malformed `to`: invalid type: string \"three\", expected u32"
      ]

-- | RETURNS-4's wire literals, variant by variant.
wireLiterals :: [(RemoteError, Value)]
wireLiterals =
  [ ( ActivationNotFound "tickets"
    , object ["kind" .= s "activation_not_found", "activation" .= s "tickets"] )
  , ( MethodNotFound "tickets" "frob"
    , object ["kind" .= s "method_not_found", "activation" .= s "tickets", "method" .= s "frob"] )
  , ( InvalidParams (Just "id") Missing
    , object ["kind" .= s "invalid_params", "param" .= s "id", "problem" .= s "missing"] )
  , ( InvalidParams (Just "limit") (Malformed "invalid type: string \"ten\", expected u32")
    , object [ "kind" .= s "invalid_params", "param" .= s "limit", "problem" .= s "malformed"
             , "detail" .= s "invalid type: string \"ten\", expected u32" ] )
  , ( InvalidParams Nothing (Malformed "params must be an object")
    , object ["kind" .= s "invalid_params", "problem" .= s "malformed", "detail" .= s "params must be an object"] )
  , ( InvalidParams Nothing Missing
    , object ["kind" .= s "invalid_params", "problem" .= s "missing"] )
  , ( Unauthenticated
    , object ["kind" .= s "unauthenticated"] )
  , ( Forbidden (MissingScope "tickets:write")
    , object ["kind" .= s "forbidden", "reason" .= s "missing_scope", "scope" .= s "tickets:write"] )
  , ( Forbidden TenantBoundary
    , object ["kind" .= s "forbidden", "reason" .= s "tenant_boundary"] )
  , ( Forbidden NotAccepted
    , object ["kind" .= s "forbidden", "reason" .= s "not_accepted"] )
  , ( HandleNotSupported "tickets"
    , object ["kind" .= s "handle_not_supported", "activation" .= s "tickets"] )
  , ( Execution "database is locked"
    , object ["kind" .= s "execution", "message" .= s "database is locked"] )
    -- main's refusals in the vocabulary (RETURNS-36; plexus-client's remote_error.rs)
  , ( Forbidden (MissingGrant "tickets.create" "*" ["editor", "admin"] [])
    , object [ "kind" .= s "forbidden", "reason" .= s "missing_grant", "grant" .= s "tickets.create", "scope" .= s "*"
             , "roles" .= [s "editor", s "admin"], "held_on" .= ([] :: [Text]) ] )
  , ( TooManyCalls 8 Nothing
    , object ["kind" .= s "too_many_calls", "max" .= (8 :: Int)] )
  , ( TooManyCalls 4 (Just "echo")
    , object ["kind" .= s "too_many_calls", "max" .= (4 :: Int), "child" .= s "echo"] )
  , ( ChildUnavailable "echo" "the connection has closed"
    , object ["kind" .= s "child_unavailable", "child" .= s "echo", "reason" .= s "the connection has closed"] )
  , ( ChildTimeout "echo" 5000
    , object ["kind" .= s "child_timeout", "child" .= s "echo", "timeout_ms" .= (5000 :: Int)] )
  ]

s :: Text -> Text
s = id

remoteErrorSpec :: Spec
remoteErrorSpec = describe "RemoteError (RETURNS-4 wire literals)" $ do
  forM_ wireLiterals $ \(err, wire) ->
    it ("round-trips " <> encodeStr wire) $ do
      toJSON err `shouldBe` wire
      decodes wire `shouldBe` Right err
      eitherDecode (encode wire) `shouldBe` Right err

  it "an invalid_params param of null decodes as Nothing" $
    decodes (object ["kind" .= s "invalid_params", "param" .= Null, "problem" .= s "missing"])
      `shouldBe` Right (InvalidParams Nothing Missing)

  -- RETURNS-36 (the Rust side's review #4, plexus-client's remote_error.rs):
  -- a kind this build does not know is RemoteError's open variant, its kind
  -- and every other field kept and written back exactly as they came; a
  -- known kind that does not fit (a reason or problem it does not know) is
  -- still a decode error
  it "an unknown kind is kept with every field and written back as it came" $
    forM_
      [ object ["kind" .= s "too_many_connections", "max" .= (64 :: Int)]
      , object ["kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int), "message" .= s "slow down"]
      ] $ \wire -> do
        e <- either fail pure (decodes @RemoteError wire)
        toJSON e `shouldBe` wire
        case e of
          Other k _ -> Just k `shouldBe` (field "kind" wire >>= \case { String t -> Just t; _ -> Nothing })
          _ -> expectationFailure ("not the open variant: " <> show e)

  it "an unknown kind renders as its kind and message, as the Rust Display" $ do
    e <- either fail pure (decodes @RemoteError (object ["kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int), "message" .= s "slow down"]))
    renderRemoteError e `shouldBe` "too_many_writes: slow down"
    c <- either fail pure (decodes @RemoteError (object ["kind" .= s "too_many_connections", "max" .= (64 :: Int)]))
    renderRemoteError c `shouldBe` "too_many_connections"

  it "a known kind is not the open one" $
    decodes @RemoteError (object ["kind" .= s "unauthenticated"]) `shouldBe` Right Unauthenticated

  it "a reason or problem it does not know is a decode error, naming it" $
    forM_
      [ (object ["kind" .= s "forbidden", "reason" .= s "too_tall"], "too_tall")
      , (object ["kind" .= s "invalid_params", "param" .= s "id", "problem" .= s "smelly"], "smelly")
      ] $ \(wire, name) -> case decodes @RemoteError wire of
        Right e -> expectationFailure ("decoded " <> show e <> " from " <> encodeStr wire)
        Left msg -> msg `shouldSatisfy` (name `isInfixOf`)

  it "a required field that is absent is a decode error" $
    forM_
      [ object ["kind" .= s "method_not_found", "activation" .= s "tickets"]
      , object ["kind" .= s "invalid_params", "param" .= s "id"]
      , object ["kind" .= s "invalid_params", "problem" .= s "malformed"]
      , object ["kind" .= s "forbidden"]
      , object ["kind" .= s "forbidden", "reason" .= s "missing_scope"]
      , object ["activation" .= s "tickets"]
      ] $ \wire -> decodes @RemoteError wire `shouldSatisfy` isLeft

  it "renders the same one line as the Rust Display" $
    map (renderRemoteError . fst) wireLiterals `shouldBe`
      [ "activation not found: tickets"
      , "method not found: tickets.frob"
      , "invalid params: missing `id`"
      , "invalid params: malformed `limit`: invalid type: string \"ten\", expected u32"
      , "invalid params: malformed: params must be an object"
      , "invalid params: a required parameter is missing"
      , "unauthenticated: credentials required"
      , "forbidden: missing required scope `tickets:write`"
      , "forbidden: tenant boundary"
      , "forbidden: call not accepted"
      , "handle resolution not supported by activation: tickets"
      , "execution failed: database is locked"
      , "forbidden: needs `tickets.create`"
      , "too many calls open (at most 8): close one, or call again later"
      , "too many calls open on child echo (at most 4): close one, or call again later"
      , "child echo is unavailable: the connection has closed"
      , "child echo did not open the call in 5000 ms"
      ]

  it "folds line breaks in server text so the line stays one line" $
    renderRemoteError (Execution "panicked at src/lib.rs:1:1:\nboom\r\nagain")
      `shouldBe` "execution failed: panicked at src/lib.rs:1:1: boom  again"

rejectionSpec :: Spec
rejectionSpec = describe "JSON-RPC rejection" $ do
  let rejection = object
        [ "code" .= (-32601 :: Int)
        , "message" .= s "method not found: tickets.frob"
        , "data" .= object ["kind" .= s "method_not_found", "activation" .= s "tickets", "method" .= s "frob"]
        ]

  it "reads data as a RemoteError and re-encodes to the same JSON" $ do
    e <- roundTrips @RpcError id rejection
    e `shouldBe` RpcErrorObj (-32601) "method not found: tickets.frob" (Just (MethodNotFound "tickets" "frob"))

  it "a response carrying it is an RpcError" $
    decodes (object ["jsonrpc" .= s "2.0", "id" .= (7 :: Int), "error" .= rejection])
      `shouldBe` Right (RpcError (RequestId 7) (RpcErrorObj (-32601) "method not found: tickets.frob" (Just (MethodNotFound "tickets" "frob"))))

  it "a rejection without data (not a Plexus call failure) has no RemoteError" $ do
    e <- roundTrips @RpcError id (object ["code" .= (-32601 :: Int), "message" .= s "Method not found"])
    errData e `shouldBe` Nothing
    decodes (object ["code" .= (-32602 :: Int), "message" .= s "Method 'x' not found", "data" .= Null])
      `shouldBe` Right (RpcErrorObj (-32602) "Method 'x' not found" Nothing)

  it "jsonrpsee's own param rejection (string data) has no RemoteError" $
    decodes (object ["code" .= (-32602 :: Int), "message" .= s "Invalid params", "data" .= s "missing field `method`"])
      `shouldBe` Right (RpcErrorObj (-32602) "Invalid params" Nothing)

  it "a kind-tagged data of an unknown kind is the open variant" $
    fmap errData (decodes @RpcError (object ["code" .= (-32000 :: Int), "message" .= s "x", "data" .= object ["kind" .= s "rate_limited"]]))
      `shouldBe` Right (Just (Other "rate_limited" mempty))

strictnessSpec :: Spec
strictnessSpec = describe "0.7 shapes are required, not defaulted" $ do
  let method extra = object $
        [ "name" .= s "get", "description" .= s "d", "hash" .= s "h" ] <> extra
      returns = "returns" .= object []
      returns' = "returns" .= object ["type" .= s "string"]

  it "Cardinality is \"one\" or \"many\"" $ do
    toJSON CardinalityOne `shouldBe` String "one"
    toJSON CardinalityMany `shouldBe` String "many"
    decodes (String "one") `shouldBe` Right CardinalityOne
    decodes (String "many") `shouldBe` Right CardinalityMany
    decodes @Cardinality (String "some") `shouldSatisfy` isLeft

  it "a method with neither cardinality nor streaming does not parse" $
    decodes @MethodSchema (method [returns]) `shouldSatisfy` isLeft

  it "a method without returns does not parse" $
    decodes @MethodSchema (method ["cardinality" .= s "one"]) `shouldSatisfy` isLeft

  it "a minimal 0.7 method parses with the Rust defaults" $ do
    m <- either fail pure $ decodes @MethodSchema (method [returns, "cardinality" .= s "many"])
    methodCardinality m `shouldBe` CardinalityMany
    methodError m `shouldBe` Nothing
    methodProgress m `shouldBe` Nothing
    methodHttpMethod m `shouldBe` "POST"
    methodRole m `shouldBe` MethodRoleRpc
    toJSON m `shouldBe` method
      [ returns, "cardinality" .= s "many", "bidirectional" .= False
      , "http_method" .= s "POST", "role" .= object ["kind" .= s "rpc"] ]

  let meta = "metadata" .= object
        ["provenance" .= [s "tickets"], "plexus_hash" .= s "h", "timestamp" .= (0 :: Int)]

  -- RETURNS-36 (the coordinator's decision): synapse 4.0 reads the
  -- published 0.7.0 line too, as plexus-core's child seam does
  -- (remote_child/peer.rs) — from what the peer itself declared, never
  -- guessing; a 0.8 schema is read strictly, as above.
  it "a 0.7.0 method: its cardinality is its streaming flag, its missing returns {}" $ do
    many <- either fail pure $ decodes @MethodSchema (method ["streaming" .= True])
    methodCardinality many `shouldBe` CardinalityMany
    methodReturns many `shouldBe` object []
    one <- either fail pure $ decodes @MethodSchema (method [returns', "streaming" .= False])
    methodCardinality one `shouldBe` CardinalityOne
    methodReturns one `shouldBe` object ["type" .= s "string"]
    -- written back in 0.8's shape
    toJSON many `shouldBe` method
      [ returns, "cardinality" .= s "many", "bidirectional" .= False
      , "http_method" .= s "POST", "role" .= object ["kind" .= s "rpc"] ]

  it "a 0.8 method's cardinality wins over a streaming flag beside it" $ do
    m <- either fail pure $ decodes @MethodSchema (method [returns, "cardinality" .= s "one", "streaming" .= True])
    methodCardinality m `shouldBe` CardinalityOne

  it "a 0.7.0 progress frame (message, percentage) reads as the default progress type, Note" $ do
    item <- either fail pure $ decodes @PlexusStreamItem
      (object ["type" .= s "progress", meta, "message" .= s "half", "percentage" .= (50 :: Int)])
    case item of
      StreamProgress _ _ _ c -> c `shouldBe` object ["message" .= s "half", "percentage" .= (50 :: Int)]
      other -> expectationFailure (show other)

  it "a 0.7.0 progress frame's null percentage is left out, as spec05.rs reads it" $ do
    item <- either fail pure $ decodes @PlexusStreamItem
      (object ["type" .= s "progress", meta, "message" .= s "started", "percentage" .= Null])
    case item of
      StreamProgress _ _ _ c -> c `shouldBe` object ["message" .= s "started"]
      other -> expectationFailure (show other)

  it "a 0.7.0 error frame (message, code) reads typed from its fields, and keeps them" $ do
    let wire = object ["type" .= s "error", meta, "message" .= s "boom", "code" .= s "-32000", "recoverable" .= False]
    item <- either fail pure $ decodes @PlexusStreamItem wire
    itemError item `shouldBe` Execution "boom"
    toJSON item `shouldBe` object
      [ "type" .= s "error", meta, "message" .= s "boom", "code" .= s "-32000", "recoverable" .= False
      , "error" .= object ["kind" .= s "execution", "message" .= s "boom"] ]

  it "a 0.7.0 error frame: error_kind first, else code, as peer.rs reads it" $ do
    let frame extra = object (["type" .= s "error", meta, "recoverable" .= False] <> extra)
        typed extra = either error itemError (decodes @PlexusStreamItem (frame extra))
    typed [ "message" .= s "needs tickets.set_status on IDENTITY", "code" .= s "-32003", "error_kind" .= s "forbidden"
          , "grant" .= s "tickets.set_status", "scope" .= s "IDENTITY"
          , "roles" .= [s "editor", s "admin"], "held_on" .= [s "DOCS"] ]
      `shouldBe` Forbidden (MissingGrant "tickets.set_status" "IDENTITY" ["editor", "admin"] ["DOCS"])
    typed ["message" .= s "x", "code" .= s "-32003"] `shouldBe` Forbidden (MissingGrant "" "" [] [])
    typed ["message" .= s "x", "error_kind" .= s "forbidden", "reason" .= s "missing_scope", "scope" .= s "t:w"]
      `shouldBe` Forbidden (MissingScope "t:w")
    typed ["message" .= s "bad", "code" .= s "-32602"] `shouldBe` InvalidParams Nothing (Malformed "bad")
    typed ["message" .= s "bad", "error_kind" .= s "invalid_params"] `shouldBe` InvalidParams Nothing (Malformed "bad")
    typed ["message" .= s "sign in", "code" .= (-32001 :: Int)] `shouldBe` Unauthenticated
    typed ["message" .= s "busy", "error_kind" .= s "too_many_calls", "max" .= (8 :: Int)] `shouldBe` TooManyCalls 8 Nothing
    typed ["error_kind" .= s "child_unavailable", "child" .= s "echo", "reason" .= s "closed"] `shouldBe` ChildUnavailable "echo" "closed"
    typed ["error_kind" .= s "child_timeout", "child" .= s "echo", "timeout_ms" .= (5000 :: Int)] `shouldBe` ChildTimeout "echo" 5000
    -- spec/05: -32601 is an activation or method not found, its message naming which
    typed ["message" .= s "Method not found: tickets.nosuch", "code" .= s "-32601"] `shouldBe` MethodNotFound "tickets" "nosuch"
    typed ["message" .= s "Activation not found: nosuch", "code" .= s "-32601"] `shouldBe` ActivationNotFound "nosuch"
    typed ["message" .= s "Method not found: tickets.inputs.nosuch", "code" .= s "-32601"] `shouldBe` MethodNotFound "tickets.inputs" "nosuch"
    -- as plexus-client's spec05.rs reads it: no dot, the method alone
    typed ["message" .= s "Method not found: nosuch", "code" .= s "-32601"] `shouldBe` MethodNotFound "" "nosuch"
    itemRefusal (either error id (decodes @PlexusStreamItem (frame
      [ "message" .= s "needs x", "error_kind" .= s "forbidden", "grant" .= s "tickets.get", "scope" .= s "*" ])))
      `shouldSatisfy` maybe False ((== Just "tickets.get") . refusalGrant)

  it "an error frame must say whether it is recoverable" $
    decodes @PlexusStreamItem
      (object ["type" .= s "error", meta, "error" .= object ["kind" .= s "unauthenticated"]])
      `shouldSatisfy` isLeft

isLeft :: Either a b -> Bool
isLeft = either (const True) (const False)

-- | RETURNS-36: what main brought beside 0.7 — AUTH-2's refusal on a typed
-- error frame, AUTH-3/4's schema fields, SYNAPSE-13's and SYNAPSE-CC-8's.
mergedSpec :: Spec
mergedSpec = describe "0.7 merged with main's auth (RETURNS-36)" $ do
  let meta = "metadata" .= object
        ["provenance" .= [s "tickets"], "plexus_hash" .= s "h", "timestamp" .= (0 :: Int)]
      grantWire = object
        [ "kind" .= s "forbidden", "reason" .= s "missing_grant", "grant" .= s "tickets.set_status"
        , "scope" .= s "IDENTITY", "roles" .= [s "editor", s "admin"], "held_on" .= [s "DOCS"] ]
      grantErr = Forbidden (MissingGrant "tickets.set_status" "IDENTITY" ["editor", "admin"] ["DOCS"])
      refusal = Refusal
        { refusalGrant = Just "tickets.set_status", refusalScope = Just "IDENTITY"
        , refusalRoles = ["editor", "admin"], refusalHeldOn = ["DOCS"], refusalCeiling = []
        , refusalMessage = "needs tickets.set_status on IDENTITY" }

  it "a missing grant is a forbidden RemoteError, and round-trips" $ do
    decodes grantWire `shouldBe` Right grantErr
    toJSON grantErr `shouldBe` grantWire
    -- the Rust Display's line (plexus-client's remote_error.rs)
    renderRemoteError grantErr `shouldBe` "forbidden: needs `tickets.set_status` on `IDENTITY`"
    renderRemoteError (Forbidden (MissingGrant "auth.role_set" "*" [] []))
      `shouldBe` "forbidden: needs `auth.role_set`"

  it "an error frame whose typed error is a missing grant names its refusal" $ do
    item <- roundTrips @PlexusStreamItem id
      (object ["type" .= s "error", meta, "recoverable" .= False, "error" .= grantWire
              , "message" .= s "needs tickets.set_status on IDENTITY"])
    itemError item `shouldBe` grantErr
    itemErrorRefusal item `shouldBe` Nothing
    itemRefusal item `shouldBe` Just refusal

  it "AUTH-2's refusal fields beside the typed error are kept and re-encoded" $ do
    let wire = object
          [ "type" .= s "error", meta, "recoverable" .= False, "error" .= grantWire
          , "message" .= s "needs tickets.set_status on IDENTITY", "code" .= s "-32003"
          , "error_kind" .= s "forbidden", "grant" .= s "tickets.set_status", "scope" .= s "IDENTITY"
          , "roles" .= [s "editor", s "admin"], "held_on" .= [s "DOCS"] ]
    item <- roundTrips @PlexusStreamItem id wire
    itemErrorRefusal item `shouldBe` Just refusal
    itemRefusal item `shouldBe` Just refusal

  it "main's message, code and error_kind beside the typed error are kept as sent" $ do
    item <- roundTrips @PlexusStreamItem id $ object
      [ "type" .= s "error", meta, "recoverable" .= False
      , "error" .= object ["kind" .= s "invalid_params", "param" .= s "id", "problem" .= s "missing"]
      , "message" .= s "Invalid params: missing field: id", "code" .= s "-32602", "error_kind" .= s "invalid_params" ]
    itemError item `shouldBe` InvalidParams (Just "id") Missing
    itemRefusal item `shouldBe` Nothing

  -- the review (RETURNS-36 #1): the frames plexus-core 0.8 writes
  -- (PlexusStreamItem::from_plexus_error: message, code, the typed error and
  -- spec/05's fields), and the server's message as callers read it
  it "a signed-out session's frame, as plexus-core 0.8 writes it: unauthenticated, the server's message kept" $ do
    item <- roundTrips @PlexusStreamItem id $ object
      [ "type" .= s "error", meta, "recoverable" .= False
      , "message" .= s "Authentication required: you have signed out: sign in again", "code" .= s "-32001"
      , "error" .= object ["kind" .= s "unauthenticated"], "error_kind" .= s "unauthenticated" ]
    itemError item `shouldBe` Unauthenticated
    itemErrorMessage item `shouldBe` Just "Authentication required: you have signed out: sign in again"

  it "a missing param's frame, as plexus-core 0.8 writes it" $ do
    item <- roundTrips @PlexusStreamItem id $ object
      [ "type" .= s "error", meta, "recoverable" .= False
      , "message" .= s "Invalid params: missing field: id", "code" .= s "-32602"
      , "error" .= object ["kind" .= s "invalid_params", "param" .= s "id", "problem" .= s "missing"]
      , "error_kind" .= s "invalid_params" ]
    itemError item `shouldBe` InvalidParams (Just "id") Missing
    itemErrorMessage item `shouldBe` Just "Invalid params: missing field: id"

  it "a 0.7.0 frame's message is the server's too" $ do
    item <- either fail pure $ decodes @PlexusStreamItem
      (object ["type" .= s "error", meta, "message" .= s "your sign-in has expired: sign in again", "code" .= s "-32001", "recoverable" .= False])
    itemError item `shouldBe` Unauthenticated
    itemErrorMessage item `shouldBe` Just "your sign-in has expired: sign in again"

  -- review §4: an error frame written here carries message, so plexus-core
  -- (whose frame requires it) reads it
  it "an error frame built here is written with its message" $ do
    let item = StreamError "h" (Provenance ["tickets"]) (Execution "boom") False
    field "message" (toJSON item) `shouldBe` Just (String "execution failed: boom")
    itemErrorMessage item `shouldBe` Nothing
    fmap itemError (decodes @PlexusStreamItem (toJSON item)) `shouldBe` Right (Execution "boom")

  -- the Rust side's review: a frame's message is optional on reading
  -- (plexus-core always writes it); without it a caller renders the typed error
  it "an error frame without message reads; written back with one" $ do
    item <- either fail pure $ decodes @PlexusStreamItem
      (object ["type" .= s "error", meta, "recoverable" .= False, "error" .= object ["kind" .= s "unauthenticated"]])
    itemError item `shouldBe` Unauthenticated
    itemErrorMessage item `shouldBe` Nothing
    field "message" (toJSON item) `shouldBe` Just (String "unauthenticated: credentials required")

  it "an error frame whose typed error is an unknown kind round-trips" $ do
    item <- roundTrips @PlexusStreamItem id $ object
      [ "type" .= s "error", meta, "recoverable" .= False, "message" .= s "too_many_writes: slow down", "code" .= s "too_many_writes"
      , "error" .= object ["kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int), "message" .= s "slow down"]
      , "error_kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int) ]
    renderRemoteError (itemError item) `shouldBe` "too_many_writes: slow down"

  it "a 0.7.0 frame with an unknown error_kind is that kind, its fields kept (peer.rs)" $ do
    item <- either fail pure $ decodes @PlexusStreamItem $ object
      [ "type" .= s "error", meta, "recoverable" .= False, "code" .= s "too_many_writes"
      , "error_kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int), "message" .= s "slow down" ]
    toJSON (itemError item) `shouldBe` object ["kind" .= s "too_many_writes", "retry_after_ms" .= (250 :: Int), "message" .= s "slow down"]

  it "any other typed error has no refusal" $ do
    item <- either fail pure $ decodes @PlexusStreamItem
      (object ["type" .= s "error", meta, "recoverable" .= False, "error" .= object ["kind" .= s "unauthenticated"]])
    itemRefusal item `shouldBe` Nothing

  it "a method keeps main's auth, group, examples and input beside 0.7's contract" $ do
    let wire = object
          [ "name" .= s "set_status", "description" .= s "d", "hash" .= s "h"
          , "returns" .= object [], "error" .= object ["title" .= s "SetStatusError"]
          , "cardinality" .= s "one", "bidirectional" .= False, "http_method" .= s "POST"
          , "role" .= object ["kind" .= s "rpc"]
          , "long_description" .= s "long", "grant" .= s "tickets.set_status", "target" .= s "id"
          , "held_on" .= [s "*"], "public" .= True, "group" .= s "write"
          , "examples" .= [object ["params" .= object ["id" .= s "A-1"], "note" .= s "one"]]
          , "input" .= object ["type" .= s "string"] ]
    m <- roundTrips @MethodSchema id wire
    methodGrant m `shouldBe` Just "tickets.set_status"
    methodGroup m `shouldBe` Just "write"
    methodCardinality m `shouldBe` CardinalityOne

  it "a plugin keeps main's roles and view beside 0.7's self_hash and CONTEXT-5's request" $ do
    let wire = object
          [ "namespace" .= s "tickets", "version" .= s "1", "description" .= s "d", "hash" .= s "h"
          , "self_hash" .= s "sh", "methods" .= ([] :: [Value])
          , "request" .= object ["type" .= s "object"]
          , "roles" .= object ["viewer" .= [s "tickets.get"]], "view" .= s "caller" ]
    p <- roundTrips @PluginSchema id wire
    psView p `shouldBe` Just Caller
    psSelfHash p `shouldBe` Just "sh"

  it "an anonymous view's plugin may omit its hash, version and description (AUTH-4)" $ do
    p <- either fail pure $ decodes @PluginSchema
      (object ["namespace" .= s "menger", "methods" .= ([] :: [Value]), "view" .= s "anonymous"])
    psHash p `shouldBe` ""
    psView p `shouldBe` Just Anonymous