shibuya-metrics-0.11.0.0: test/Shibuya/Metrics/ServerSpec.hs
module Shibuya.Metrics.ServerSpec (spec) where
import Control.Exception (SomeException, throwIO)
import Data.Aeson (Value (..), decode, object, (.=))
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LBS
import Data.Char (toLower)
import Data.List (isInfixOf)
import Effectful (runEff)
import Network.HTTP.Types (Status, hContentType, status200, status404, status503)
import Network.Wai (Application, requestHeaders)
import Network.Wai.Test (SResponse (..), defaultRequest, request, runSession, setPath)
import Shibuya.App (Master)
import Shibuya.Core.Metrics (ProcessorId (..))
import Shibuya.Core.Metrics qualified as Metrics
import Shibuya.Core.Types (Cursor (..))
import Shibuya.Internal.Runner.Master (registerProcessor)
import Shibuya.Metrics.Config (MetricsServerConfig (..), defaultConfig)
import Shibuya.Metrics.Error (errorResponse)
import Shibuya.Metrics.Health (DependencyCheck, DependencyStatus (..))
import Shibuya.Metrics.Server (combinedApp, startMetricsServer)
import Shibuya.Metrics.TestSupport
( fixedTime,
getResponse,
registerIdleProcessor,
withMaster,
)
import Shibuya.Metrics.WebSocket (newWebSocketState)
import Test.Hspec (Spec, anyException, around, describe, it, shouldBe, shouldSatisfy, shouldThrow)
spec :: Spec
spec = around withMaster $ do
describe "built-in server configuration" $ do
it "defaults to a loopback-only listener" $ \_ ->
defaultConfig.host `shouldBe` "127.0.0.1"
it "validates frame, byte, and send budgets before opening a socket" $ \master ->
mapM_
(\config -> startMetricsServer config master `shouldThrow` anyException)
[ defaultConfig {wsMaxQueuedFrames = 0},
defaultConfig {wsMaxQueuedFrames = 1},
defaultConfig {wsMaxQueuedBytes = 0},
defaultConfig {wsSendTimeoutUs = 0}
]
it "rejects nonpositive WebSocket resource limits" $ \master ->
startMetricsServer defaultConfig {wsMaxSubscriptions = 0} master
`shouldThrow` (anyException :: SomeException -> Bool)
describe "structured error response" $ do
it "protects reserved keys while retaining context and content type" $ \_ -> do
let app _ respond =
respond $
errorResponse
status404
"Not found"
"not_found"
["error" .= ("override" :: String), "code" .= ("override" :: String), "processor" .= ("missing" :: String)]
response <- getResponse app "/"
response.simpleStatus `shouldBe` status404
lookup hContentType response.simpleHeaders `shouldBe` Just "application/json"
decode response.simpleBody
`shouldBe` Just
( object
["error" .= ("Not found" :: String), "code" .= ("not_found" :: String), "processor" .= ("missing" :: String)]
)
describe "combinedApp HTTP routes" $ do
it "exposes progress only after a detailed cursor acknowledgement" $ \master -> do
h <- Metrics.newMetricsHandleWith (Metrics.MetricsDetailed Metrics.defaultDetailOptions) 1 fixedTime
runEff $ registerProcessor master (ProcessorId "progressing") h
_ <- registerIdleProcessor master (ProcessorId "idle")
Metrics.recordAcknowledgedCursor h Nothing (Just (CursorInt 4210))
app <- appFor defaultConfig master []
yes <- getResponse app "/metrics/progressing"
no <- getResponse app "/metrics/idle"
let hasProgress body = case decode body of
Just (Object obj) -> KM.member "progress" obj
_ -> False
hasProgress yes.simpleBody `shouldBe` True
hasProgress no.simpleBody `shouldBe` False
it "preserves headerless defaults on every read route with an Origin" $ \master -> do
_ <- registerIdleProcessor master (ProcessorId "known")
app <- appFor defaultConfig master []
mapM_
( \path -> do
plain <- getResponse app path
origin <- runSession (request $ setPath defaultRequest {requestHeaders = [("Origin", "https://ops.example.com")]} path) app
origin.simpleHeaders `shouldBe` plain.simpleHeaders
origin.simpleHeaders `shouldSatisfy` (not . any (\(name, _) -> "access-control-" `isInfixOf` map toLower (show name)))
lookup "Vary" origin.simpleHeaders `shouldBe` Nothing
)
["/metrics", "/metrics/known", "/metrics/missing", "/metrics/prometheus", "/health", "/health/live", "/health/ready", "/ws", "/unknown"]
it "serves every enabled JSON, health, Prometheus, and WebSocket path" $ \master -> do
_ <- registerIdleProcessor master (ProcessorId "known")
app <- appFor defaultConfig master []
assertResponse app "/metrics" status200 (Just "application/json")
assertResponse app "/metrics/known" status200 (Just "application/json")
assertResponse app "/health" status200 (Just "application/json")
assertResponse app "/health/live" status200 (Just "application/json")
assertResponse app "/health/ready" status200 (Just "application/json")
assertResponse app "/metrics/prometheus" status200 (Just "text/plain; version=0.0.4; charset=utf-8")
wsResponse <- getResponse app "/ws"
wsResponse.simpleStatus `shouldBe` status404
decode wsResponse.simpleBody
`shouldBe` Just (object ["error" .= ("WebSocket endpoint - use ws:// protocol" :: String), "code" .= ("websocket_upgrade_required" :: String)])
it "returns the published JSON error for an unknown processor" $ \master -> do
app <- appFor defaultConfig master []
response <- getResponse app "/metrics/missing"
response.simpleStatus `shouldBe` status404
decode response.simpleBody
`shouldBe` Just
( object
[ "error" .= ("Processor not found" :: String),
"processor" .= ("missing" :: String),
"code" .= ("processor_not_found" :: String)
]
)
it "returns the published JSON error for an unknown path" $ \master -> do
app <- appFor defaultConfig master []
response <- getResponse app "/unknown"
response.simpleStatus `shouldBe` status404
decode response.simpleBody
`shouldBe` Just (object ["error" .= ("Not found" :: String), "code" .= ("not_found" :: String)])
it "returns 404 for every JSON route when JSON endpoints are disabled" $ \master -> do
app <- appFor defaultConfig {enableJSON = False} master []
mapM_
(\path -> assertResponse app path status404 (Just "application/json"))
["/metrics", "/metrics/known", "/health", "/health/live", "/health/ready"]
it "returns 404 when Prometheus is disabled" $ \master -> do
app <- appFor defaultConfig {enablePrometheus = False} master []
assertResponse app "/metrics/prometheus" status404 (Just "application/json")
it "uses the generic 404 for plain HTTP when WebSockets are disabled" $ \master -> do
app <- appFor defaultConfig {enableWebSocket = False} master []
response <- getResponse app "/ws"
response.simpleStatus `shouldBe` status404
decode response.simpleBody
`shouldBe` Just (object ["error" .= ("Not found" :: String), "code" .= ("not_found" :: String)])
it "returns 503 from readiness and detailed health for an unhealthy dependency" $ \master -> do
app <- appFor defaultConfig master [failingDependency]
assertResponse app "/health" status503 (Just "application/json")
assertResponse app "/health/ready" status503 (Just "application/json")
it "returns 503 when a dependency check throws synchronously" $ \master -> do
app <- appFor defaultConfig master [throwIO $ userError "database exploded"]
assertResponse app "/health" status503 (Just "application/json")
assertResponse app "/health/ready" status503 (Just "application/json")
appFor :: MetricsServerConfig -> Master -> [DependencyCheck] -> IO Application
appFor config master dependencies = do
wsState <- newWebSocketState config.wsMaxConnections
pure $ combinedApp config master wsState dependencies
assertResponse :: Application -> ByteString -> Status -> Maybe ByteString -> IO ()
assertResponse app path expectedStatus expectedContentType = do
response <- getResponse app path
response.simpleStatus `shouldBe` expectedStatus
lookup hContentType response.simpleHeaders `shouldBe` expectedContentType
response.simpleBody `shouldSatisfy` (not . LBS.null)
failingDependency :: DependencyCheck
failingDependency =
pure
DependencyStatus
{ name = "database",
healthy = False,
latencyMs = Just 7,
errorMsg = Just "unavailable"
}