packages feed

acolyte-test-0.1.0.0: src/Acolyte/Test.hs

-- | Testing utilities for acolyte APIs.
--
-- Test APIs by dispatching requests directly through the spire Service
-- — no network, no warp, no ports. Fast and deterministic.
--
-- @
-- import Acolyte.Test
--
-- test = do
--   let svc = mkServer @MyAPI handlers
--   resp <- request svc "GET" "/health" [] ""
--   resp `shouldHaveStatus` 200
--   resp `shouldHaveBody` "ok"
-- @
module Acolyte.Test
  ( -- * Making requests
    request
  , get
  , post
  , put_
  , delete_
  , patch
    -- * Response assertions
  , shouldHaveStatus
  , shouldHaveHeader
  , shouldHaveBody
  , shouldHaveJsonBody
  , shouldMatchJson
    -- * Utilities
  , responseBody'
  , responseStatus'
  , responseHeaders'
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Network.HTTP.Types (Status, statusCode, RequestHeaders, Header, HeaderName)

import qualified Data.Aeson as Aeson

import Spire.Service (Service (..))
import Http.Core


-- ===================================================================
-- Making requests
-- ===================================================================

-- | Send a request to a spire Service (no network).
request
  :: Service IO (Request ByteString) (Response ByteString)
  -> ByteString     -- ^ HTTP method
  -> ByteString     -- ^ Path (e.g., "/users/42")
  -> RequestHeaders -- ^ Headers
  -> ByteString     -- ^ Body
  -> IO (Response ByteString)
request svc method path headers body = do
  exts <- emptyExtensions
  let segments = filter (/= "") $ T.splitOn "/" (TE.decodeUtf8 path)
  let req = Request
        { requestMethod     = method
        , requestPathRaw    = path
        , requestPath       = segments
        , requestQuery      = []
        , requestHeaders    = headers
        , requestBody       = body
        , requestExtensions = exts
        }
  runService svc req


-- | GET request with no body.
get :: Service IO (Request ByteString) (Response ByteString)
    -> ByteString -> IO (Response ByteString)
get svc path = request svc "GET" path [] ""


-- | POST request with JSON body.
post :: Aeson.ToJSON a
     => Service IO (Request ByteString) (Response ByteString)
     -> ByteString -> a -> IO (Response ByteString)
post svc path body = request svc "POST" path
  [("Content-Type", "application/json")]
  (LBS.toStrict (Aeson.encode body))


-- | PUT request with JSON body.
put_ :: Aeson.ToJSON a
     => Service IO (Request ByteString) (Response ByteString)
     -> ByteString -> a -> IO (Response ByteString)
put_ svc path body = request svc "PUT" path
  [("Content-Type", "application/json")]
  (LBS.toStrict (Aeson.encode body))


-- | DELETE request with no body.
delete_ :: Service IO (Request ByteString) (Response ByteString)
        -> ByteString -> IO (Response ByteString)
delete_ svc path = request svc "DELETE" path [] ""


-- | PATCH request with JSON body.
patch :: Aeson.ToJSON a
      => Service IO (Request ByteString) (Response ByteString)
      -> ByteString -> a -> IO (Response ByteString)
patch svc path body = request svc "PATCH" path
  [("Content-Type", "application/json")]
  (LBS.toStrict (Aeson.encode body))


-- ===================================================================
-- Response assertions
-- ===================================================================

-- | Assert response has the expected status code.
shouldHaveStatus :: Response ByteString -> Int -> IO ()
shouldHaveStatus resp expected
  | actual == expected = pure ()
  | otherwise = error $
      "Expected status " ++ show expected ++ " but got " ++ show actual
      ++ "\nBody: " ++ show (responseBody resp)
  where actual = statusCode (responseStatus resp)


-- | Assert response has a specific header with the expected value.
shouldHaveHeader :: Response ByteString -> HeaderName -> ByteString -> IO ()
shouldHaveHeader resp name expected =
  case lookup name (responseHeaders resp) of
    Just actual | actual == expected -> pure ()
    Just actual -> error $
      "Header " ++ show name ++ ": expected " ++ show expected
      ++ " but got " ++ show actual
    Nothing -> error $
      "Header " ++ show name ++ " not found in response"


-- | Assert response body equals the expected bytes.
shouldHaveBody :: Response ByteString -> ByteString -> IO ()
shouldHaveBody resp expected
  | responseBody resp == expected = pure ()
  | otherwise = error $
      "Expected body " ++ show expected
      ++ " but got " ++ show (responseBody resp)


-- | Assert response body decodes to the expected JSON value.
shouldHaveJsonBody :: (Aeson.FromJSON a, Eq a, Show a)
                   => Response ByteString -> a -> IO ()
shouldHaveJsonBody resp expected =
  case Aeson.eitherDecodeStrict' (responseBody resp) of
    Right actual | actual == expected -> pure ()
    Right actual -> error $
      "Expected JSON " ++ show expected ++ " but got " ++ show actual
    Left err -> error $
      "Failed to decode JSON: " ++ err ++ "\nBody: " ++ show (responseBody resp)


-- | Assert response body is valid JSON matching the expected Value.
shouldMatchJson :: Response ByteString -> Aeson.Value -> IO ()
shouldMatchJson resp expected =
  case Aeson.eitherDecodeStrict' (responseBody resp) of
    Right actual | actual == expected -> pure ()
    Right actual -> error $
      "JSON mismatch.\nExpected: " ++ show expected
      ++ "\nActual: " ++ show actual
    Left err -> error $ "Failed to decode JSON: " ++ err


-- ===================================================================
-- Utilities
-- ===================================================================

-- | Extract the status code as Int.
responseStatus' :: Response ByteString -> Int
responseStatus' = statusCode . responseStatus

-- | Extract the body.
responseBody' :: Response ByteString -> ByteString
responseBody' = responseBody

-- | Extract headers.
responseHeaders' :: Response ByteString -> [(HeaderName, ByteString)]
responseHeaders' = responseHeaders