2026-07-20 03:20:08 +09:00
|
|
|
module Nuthatch.Test.HttpContract
|
|
|
|
|
( tests
|
|
|
|
|
) where
|
|
|
|
|
|
|
|
|
|
import qualified Data.Aeson as A
|
|
|
|
|
import qualified Data.Aeson.Key as Key
|
|
|
|
|
import qualified Data.Aeson.KeyMap as KeyMap
|
|
|
|
|
import qualified Data.ByteString.Lazy as BL
|
|
|
|
|
import Data.Proxy (Proxy (..))
|
|
|
|
|
import qualified Data.Text as T
|
|
|
|
|
import qualified Data.Text.IO as TIO
|
|
|
|
|
import Nuthatch.Api
|
|
|
|
|
import Nuthatch.Http.Contract
|
|
|
|
|
( renderContractFixtures
|
|
|
|
|
, renderTypeScriptContract
|
|
|
|
|
, contractShapeErrors
|
|
|
|
|
)
|
2026-07-20 21:34:25 +09:00
|
|
|
import Nuthatch.Types (MutationResult (..))
|
2026-07-20 03:20:08 +09:00
|
|
|
import Test.Tasty (TestTree, testGroup)
|
|
|
|
|
import Test.Tasty.HUnit ((@?=), Assertion, assertBool, testCase)
|
|
|
|
|
|
|
|
|
|
tests :: TestTree
|
|
|
|
|
tests = testGroup "Haskell to TypeScript contract"
|
|
|
|
|
[ testCase "checked artifacts are byte-identical to production generation" $ do
|
|
|
|
|
contract <- TIO.readFile "web/src/generated/contract.ts"
|
|
|
|
|
fixtures <- BL.readFile "web/test/fixtures/contract.json"
|
|
|
|
|
contract @?= renderTypeScriptContract
|
|
|
|
|
fixtures @?= renderContractFixtures
|
|
|
|
|
, testCase "shared fixtures round-trip every wire DTO" $ do
|
|
|
|
|
fixture <- decodeFixture
|
|
|
|
|
assertFixture fixture "session_response" (Proxy :: Proxy SessionResponse)
|
|
|
|
|
assertFixture fixture "entry_response" (Proxy :: Proxy EntryResponse)
|
|
|
|
|
assertFixture fixture "browse_response" (Proxy :: Proxy BrowseResponse)
|
|
|
|
|
assertFixture fixture "delete_request" (Proxy :: Proxy DeleteRequest)
|
|
|
|
|
assertFixture fixture "upload_response" (Proxy :: Proxy UploadResponse)
|
|
|
|
|
assertFixture fixture "delete_response" (Proxy :: Proxy DeleteResponse)
|
|
|
|
|
assertFixture fixture "delete_plan_response"
|
|
|
|
|
(Proxy :: Proxy DeletePlanResponse)
|
|
|
|
|
assertFixture fixture "delete_batch_item" (Proxy :: Proxy DeleteBatchItem)
|
|
|
|
|
assertFixture fixture "delete_batch_request"
|
|
|
|
|
(Proxy :: Proxy DeleteBatchRequest)
|
|
|
|
|
assertFixture fixture "delete_item_response"
|
|
|
|
|
(Proxy :: Proxy DeleteItemResponse)
|
|
|
|
|
assertFixture fixture "delete_batch_response"
|
|
|
|
|
(Proxy :: Proxy DeleteBatchResponse)
|
|
|
|
|
, testCase "explicit TypeScript mappings accept the Aeson wire shapes" $
|
|
|
|
|
contractShapeErrors @?= []
|
2026-07-20 21:34:25 +09:00
|
|
|
, testCase "delete item responses contain exactly one typed outcome" $ do
|
|
|
|
|
let path = "folder/file.txt" :: T.Text
|
|
|
|
|
operation = "operation-1" :: T.Text
|
|
|
|
|
decode result failure =
|
|
|
|
|
A.fromJSON
|
|
|
|
|
(A.object
|
|
|
|
|
[ "path" A..= path
|
|
|
|
|
, "operation_id" A..= operation
|
|
|
|
|
, "result" A..= result
|
|
|
|
|
, "error" A..= failure
|
|
|
|
|
])
|
|
|
|
|
:: A.Result DeleteItemResponse
|
|
|
|
|
assertSuccess
|
|
|
|
|
(decode
|
|
|
|
|
(Just MutationSucceeded)
|
|
|
|
|
(Nothing :: Maybe DeleteItemFailure))
|
|
|
|
|
assertSuccess
|
|
|
|
|
(decode
|
|
|
|
|
(Nothing :: Maybe MutationResult)
|
|
|
|
|
(Just DeleteItemDatabaseUnavailable))
|
|
|
|
|
assertFailure
|
|
|
|
|
(decode
|
|
|
|
|
(Just MutationSucceeded)
|
|
|
|
|
(Just DeleteItemDatabaseUnavailable))
|
|
|
|
|
assertFailure
|
|
|
|
|
(decode
|
|
|
|
|
(Nothing :: Maybe MutationResult)
|
|
|
|
|
(Nothing :: Maybe DeleteItemFailure))
|
2026-07-20 03:20:08 +09:00
|
|
|
, testCase "generated error unions cover every fixture error code" $ do
|
|
|
|
|
fixture <- decodeFixture
|
|
|
|
|
errors <- fixtureValue fixture "errors"
|
|
|
|
|
case errors of
|
|
|
|
|
A.Array values ->
|
|
|
|
|
mapM_ assertGeneratedErrorCode (toList values)
|
|
|
|
|
_ -> fail "errors fixture must be an array"
|
|
|
|
|
, testCase "non-TypeScript invariants remain explicit" $ do
|
|
|
|
|
let contract = renderTypeScriptContract
|
|
|
|
|
assertBool "relative path invariant is named"
|
|
|
|
|
("relativePath:" `T.isInfixOf` contract)
|
|
|
|
|
assertBool "operation id invariant is named"
|
|
|
|
|
("operationId:" `T.isInfixOf` contract)
|
|
|
|
|
assertBool "content identity invariant is named"
|
|
|
|
|
("contentSha256:" `T.isInfixOf` contract)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
decodeFixture :: IO A.Object
|
|
|
|
|
decodeFixture = do
|
|
|
|
|
bytes <- BL.readFile "web/test/fixtures/contract.json"
|
|
|
|
|
value <- either fail pure (A.eitherDecode bytes)
|
|
|
|
|
case value of
|
|
|
|
|
A.Object fixture -> pure fixture
|
|
|
|
|
_ -> fail "contract fixture must be an object"
|
|
|
|
|
|
|
|
|
|
assertFixture
|
|
|
|
|
:: (A.FromJSON value, A.ToJSON value)
|
|
|
|
|
=> A.Object
|
|
|
|
|
-> Key.Key
|
|
|
|
|
-> Proxy value
|
|
|
|
|
-> Assertion
|
|
|
|
|
assertFixture fixture key proxy = do
|
|
|
|
|
raw <- fixtureValue fixture key
|
|
|
|
|
decoded <- case A.fromJSON raw of
|
|
|
|
|
A.Error message -> fail message
|
|
|
|
|
A.Success value -> pure value
|
|
|
|
|
A.toJSON (asProxyType proxy decoded) @?= raw
|
|
|
|
|
|
|
|
|
|
fixtureValue :: A.Object -> Key.Key -> IO A.Value
|
|
|
|
|
fixtureValue fixture key =
|
|
|
|
|
maybe (fail ("missing fixture " <> show key)) pure (KeyMap.lookup key fixture)
|
|
|
|
|
|
|
|
|
|
assertGeneratedErrorCode :: A.Value -> Assertion
|
|
|
|
|
assertGeneratedErrorCode value =
|
|
|
|
|
case value of
|
|
|
|
|
A.Object objectValue ->
|
|
|
|
|
case KeyMap.lookup "error" objectValue of
|
|
|
|
|
Just (A.String code) ->
|
|
|
|
|
assertBool ("missing generated error code " <> T.unpack code)
|
|
|
|
|
(("'" <> code <> "'") `T.isInfixOf` renderTypeScriptContract)
|
|
|
|
|
_ -> fail "error fixture must contain an error string"
|
|
|
|
|
_ -> fail "error fixture must be an object"
|
|
|
|
|
|
|
|
|
|
asProxyType :: Proxy value -> value -> value
|
|
|
|
|
asProxyType _ = id
|
|
|
|
|
|
|
|
|
|
toList :: Foldable collection => collection value -> [value]
|
|
|
|
|
toList = foldr (:) []
|
2026-07-20 21:34:25 +09:00
|
|
|
|
|
|
|
|
assertSuccess :: A.Result value -> Assertion
|
|
|
|
|
assertSuccess (A.Success _) = pure ()
|
|
|
|
|
assertSuccess (A.Error message) = fail message
|
|
|
|
|
|
|
|
|
|
assertFailure :: A.Result value -> Assertion
|
|
|
|
|
assertFailure (A.Error _) = pure ()
|
|
|
|
|
assertFailure (A.Success _) = fail "unexpected successful decode"
|