packages feed

kiroku-metrics-0.1.0.0: test/Test/SubscriptionsSpec.hs

{- | EP-5 tests for the @GET /subscriptions@ endpoint and the CLI remote client.

  * Cross-package shape: rows produced by the server-side mapping
    ('subscriptionStatusRows') encode and decode back through the shared
    @kiroku-cli@ codec — guarding against either side re-encoding locally.
  * End-to-end: boot a real store with a live subscription, serve
    @\/subscriptions@ from 'storeSubscriptionStatus', and assert the real CLI
    client ('fetchRemoteSubscriptionStatusRows') reports the live subscription
    with a sane phase and the expected cursor. A raw GET checks the by-name route
    (known → one row, unknown → @[]@), and a server with no provider yields the
    configured-404.
-}
module Test.SubscriptionsSpec (spec) where

import Control.Concurrent (threadDelay)
import Data.Aeson qualified as Aeson
import Data.Int (Int32, Int64)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Network.HTTP.Client (
    defaultManagerSettings,
    httpLbs,
    newManager,
    parseRequest,
    responseBody,
    responseStatus,
 )
import Network.HTTP.Types.Status (statusCode)
import Network.Wai.Handler.Warp qualified as Warp
import Test.Hspec

import Kiroku.Cli.Command (RemoteEndpoint (..))
import Kiroku.Cli.Subscription.Status (
    SubscriptionStatusRow (..),
    fetchRemoteSubscriptionStatusRows,
    subscriptionStatusRows,
 )
import Kiroku.Metrics (
    MetricsServer (..),
    defaultConfig,
    newKirokuMetrics,
    startMetricsServer,
    stopMetricsServer,
    storeSubscriptionStatus,
    subscriptionsApp,
    withMetricsServerSubscriptions,
 )
import Kiroku.Metrics.Config (MetricsServerConfig (..))
import Kiroku.Store (
    EventData (..),
    EventType (..),
    ExpectedVersion (..),
    KirokuStore,
    StreamName (..),
    SubscriptionResult (..),
    SubscriptionTarget (..),
    appendToStream,
    cancel,
    defaultConnectionSettings,
    defaultSubscriptionConfig,
    runStoreIO,
    subscribe,
    withStore,
 )
import Kiroku.Store.Subscription (SubscriptionStateView (..), subscriptionStates)
import Kiroku.Store.Subscription.Fsm (SubscriptionState (..))
import Kiroku.Store.Subscription.Types (SubscriptionName (..))
import Kiroku.Store.Types (GlobalPosition (..))
import Kiroku.Test.Postgres (withMigratedTestDatabase)

spec :: Spec
spec = describe "Kiroku.Metrics.Subscriptions (/subscriptions)" $ do
    it "encodes server-side rows that decode back through the shared CLI codec" $ do
        let views =
                Map.fromList
                    [ ((SubscriptionName "beta", 1), mkView "beta" 1 "live" 42)
                    , ((SubscriptionName "alpha", 0), mkView "alpha" 0 "catching_up" 7)
                    ]
            rows = subscriptionStatusRows views
        Aeson.eitherDecode (Aeson.encode rows) `shouldBe` Right rows

    it "reports a live subscription to the CLI remote client end to end" $
        withMigratedTestDatabase $ \connStr ->
            withStore (defaultConnectionSettings connStr) $ \store -> do
                m <- newKirokuMetrics store
                let name = SubscriptionName "subs-endpoint-it"
                Right _ <-
                    runStoreIO store $
                        appendToStream (StreamName "subs-endpoint-1") NoStream [ev "A", ev "B", ev "C"]
                handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))
                live <- waitUntilPhase 10_000_000 store (name, 0) "live"
                live `shouldBe` True

                withMetricsServerSubscriptions (defaultConfig{port = 0}) m [] (storeSubscriptionStatus store) $ \srv -> do
                    threadDelay 200_000
                    let base = "http://127.0.0.1:" <> show srv.serverPort

                    -- The real CLI client hits GET /subscriptions and decodes the rows.
                    rowsResult <- fetchRemoteSubscriptionStatusRows (RemoteEndpoint (T.pack base))
                    case rowsResult of
                        Left err -> expectationFailure ("expected rows, got error: " <> T.unpack err)
                        Right rows ->
                            case filter (\r -> r.subscription == "subs-endpoint-it") rows of
                                [row] -> do
                                    row.phase `shouldBe` "live"
                                    row.globalPosition `shouldBe` 3
                                other -> expectationFailure ("expected exactly one matching row, got " <> show other)

                    -- By-name route: known name → one row, unknown name → [].
                    known <- getRows (base <> "/subscriptions/subs-endpoint-it")
                    map (.subscription) known `shouldBe` ["subs-endpoint-it"]
                    unknown <- getRows (base <> "/subscriptions/does-not-exist")
                    unknown `shouldBe` []

                cancel handle

    it "returns a configured-404 when no provider is wired" $
        withMigratedTestDatabase $ \connStr ->
            withStore (defaultConnectionSettings connStr) $ \store -> do
                m <- newKirokuMetrics store
                srv <- startMetricsServer (defaultConfig{port = 0}) m []
                threadDelay 200_000
                result <-
                    fetchRemoteSubscriptionStatusRows
                        (RemoteEndpoint (T.pack ("http://127.0.0.1:" <> show srv.serverPort)))
                stopMetricsServer srv
                case result of
                    Left err -> err `shouldSatisfy` T.isInfixOf "404"
                    Right rows -> expectationFailure ("expected a 404 error, got rows: " <> show rows)

    it "returns the documented 404 JSON body for unknown paths when mounted standalone" $
        Warp.testWithApplication (pure (subscriptionsApp (pure []))) $ \port -> do
            mgr <- newManager defaultManagerSettings
            req <- parseRequest ("http://127.0.0.1:" <> show port <> "/definitely/not/a/route")
            resp <- httpLbs req mgr
            statusCode (responseStatus resp) `shouldBe` 404
            Aeson.decode (responseBody resp)
                `shouldBe` Just (Aeson.object ["error" Aeson..= ("Not found" :: Text)])

-- | GET a URL and decode the body as @[SubscriptionStatusRow]@ (via the shared codec).
getRows :: String -> IO [SubscriptionStatusRow]
getRows url = do
    mgr <- newManager defaultManagerSettings
    req <- parseRequest url
    resp <- httpLbs req mgr
    case Aeson.eitherDecode (responseBody resp) of
        Right rows -> pure rows
        Left err -> expectationFailure ("could not decode " <> url <> ": " <> err) >> pure []

mkView :: Text -> Int32 -> Text -> Int64 -> SubscriptionStateView
mkView name member phase position =
    SubscriptionStateView
        { subscriptionName = SubscriptionName name
        , member = member
        , state = Live (GlobalPosition position)
        , statePhase = phase
        , cursor = GlobalPosition position
        }

ev :: Text -> EventData
ev typ =
    EventData
        { eventId = Nothing
        , eventType = EventType typ
        , payload = Aeson.Null
        , metadata = Nothing
        , causationId = Nothing
        , correlationId = Nothing
        }

waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool
waitUntilPhase budget store key phase
    | budget <= 0 = pure False
    | otherwise = do
        snapshot <- subscriptionStates store
        case Map.lookup key snapshot of
            Just status | statePhase status == phase -> pure True
            _ -> do
                threadDelay 20_000
                waitUntilPhase (budget - 20_000) store key phase