packages feed

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

{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE ScopedTypeVariables #-}

{- | End-to-end test of the WebSocket endpoints (EP-3): boot a real store with the
collector wired in, start the store-aware server on an OS-assigned port, then
drive both channels over a real socket with the @websockets@ client.

  * @/ws/events@: subscribe, append three events from the test thread, assert
    three @event@ messages arrive in global-position order with the expected
    @eventType@s. After the client disconnects, the publisher's transient
    broadcast subscriber must be cleaned up (count returns to baseline) — proof
    the tail's @finally@ wiring deregisters and that it leaves no trace.
  * @/ws/metrics@: connect, receive a @snapshot@, assert @store.global_position@
    reflects the appended events.
-}
module Test.WebSocketSpec (spec) where

import Control.Concurrent (myThreadId, threadDelay)
import Control.Concurrent.Async (async, asyncThreadId, wait)
import Control.Concurrent.STM (
    STM,
    TVar,
    atomically,
    check,
    newTVarIO,
    orElse,
    readTVar,
    readTVarIO,
    registerDelay,
    writeTVar,
 )
import Control.Exception (finally)
import Control.Lens ((&), (.~))
import Control.Monad (replicateM, unless, when)
import Data.Aeson (Value (..), decode, encode, object, (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy qualified as LBS
import Data.Foldable (for_)
import Data.IntMap.Strict qualified as IntMap
import Data.Scientific (toRealFloat)
import Data.Text (Text)
import Data.Text qualified as T
import Hasql.Pool qualified as Pool
import Hasql.Session qualified as Session
import Network.WebSockets qualified as WS
import System.Timeout (timeout)
import Test.Hspec

import Kiroku.Metrics (
    MetricsServer (..),
    defaultConfig,
    metricsEventHandler,
    metricsObservationHandler,
    newKirokuMetricsWith,
    startMetricsServerWithStore,
    stopMetricsServer,
 )
import Kiroku.Metrics.Config (MetricsServerConfig (..))
import Kiroku.Store (
    EventData (..),
    EventType (..),
    ExpectedVersion (..),
    KirokuStore (..),
    StreamName (..),
    appendToStream,
    defaultConnectionSettings,
    runStoreIO,
    withStore,
 )
import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition, subscribePublisher)
import Kiroku.Store.Subscription.Types (OverflowPolicy (..))
import Kiroku.Store.Types (GlobalPosition (..))
import Kiroku.Test.Postgres (withMigratedTestDatabase)

spec :: Spec
spec = describe "Kiroku.Metrics.WebSocket (endpoints)" $ do
    it "streams appended events over /ws/events and a snapshot over /ws/metrics" $
        withMigratedTestDatabase $ \connStr -> do
            storeVar <- newTVarIO Nothing
            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)
            let settings =
                    defaultConnectionSettings connStr
                        & #eventHandler .~ Just (metricsEventHandler km Nothing)
                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)
            withStore settings $ \store -> do
                atomically (writeTVar storeVar (Just store))
                let serverCfg = defaultConfig{port = 0}
                srv <- startMetricsServerWithStore serverCfg km store []
                threadDelay 300_000
                let port = srv.serverPort
                    stream = StreamName "ws-events-stream"

                -- /ws/events: subscribe, then append three events and read them back.
                received <-
                    requireJust "ws/events client timed out" $
                        timeout 15_000_000 $
                            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do
                                sendJSON conn (object ["type" .= ("subscribe_events" :: Text)])
                                _ <- waitForType conn "event_stream_started"
                                appendThread <- async (appendStoreEvents store stream 3)
                                evs <- replicateM 3 (readEventType conn)
                                wait appendThread
                                pure evs
                received `shouldBe` ["E1", "E2", "E3"]

                -- The tail uses the public broadcast and unsubscribes on disconnect;
                -- the publisher's subscriber count must return to baseline (0).
                waitForSubscriberCount store 0 5_000_000

                -- /ws/metrics: the snapshot reflects the three appended events.
                gpos <-
                    requireJust "ws/metrics client timed out" $
                        timeout 15_000_000 $
                            WS.runClient "127.0.0.1" port "/ws/metrics" $ \conn -> do
                                snap <- waitForType conn "snapshot"
                                pure (globalPositionOf snap)
                gpos `shouldSatisfy` (>= 3)

                stopMetricsServer srv

    it "replays history from a position then continues live without duplicating the boundary" $
        withMigratedTestDatabase $ \connStr -> do
            storeVar <- newTVarIO Nothing
            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)
            let settings =
                    defaultConnectionSettings connStr
                        & #eventHandler .~ Just (metricsEventHandler km Nothing)
                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)
            withStore settings $ \store -> do
                atomically (writeTVar storeVar (Just store))
                let serverCfg = defaultConfig{port = 0}
                srv <- startMetricsServerWithStore serverCfg km store []
                threadDelay 300_000
                let port = srv.serverPort

                -- Two events exist before the client connects (stream A); they must
                -- be replayed. The live event lands on a second stream (B) so the
                -- @NoStream@ append helper does not conflict.
                appendStoreEvents store (StreamName "ws-replay-a") 2
                received <-
                    requireJust "ws/events replay client timed out" $
                        timeout 15_000_000 $
                            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do
                                sendJSON
                                    conn
                                    (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])
                                _ <- waitForType conn "event_stream_started"
                                -- Two replayed (A's E1,E2), then one appended live
                                -- (B's E1) — three in global-position order, with no
                                -- duplicate at the replay/live boundary.
                                replayed <- replicateM 2 (readEventType conn)
                                liveThread <- async (appendStoreEvents store (StreamName "ws-replay-b") 1)
                                live <- readEventType conn
                                wait liveThread
                                pure (replayed <> [live])
                received `shouldBe` ["E1", "E2", "E1"]

                stopMetricsServer srv

    it "delivers each global position exactly once when the replay overlaps live appends" $
        withMigratedTestDatabase $ \connStr -> do
            gateVar <- newTVarIO True
            storeVar <- newTVarIO Nothing
            let publisherGate e = do
                    mStore <- readTVarIO storeVar
                    for_ mStore $ \s -> do
                        tid <- myThreadId
                        when (tid == asyncThreadId (publisherThread s.publisher)) $
                            atomically (readTVar gateVar >>= check)
                    pure e
                settings =
                    defaultConnectionSettings connStr
                        & #storeSettings . #decodeHook .~ Just publisherGate
            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)
            withStore settings $ \store -> do
                atomically (writeTVar storeVar (Just store))
                let serverCfg = defaultConfig{port = 0}
                srv <- startMetricsServerWithStore serverCfg km store []
                threadDelay 300_000
                let port = srv.serverPort

                appendStoreEvents store (StreamName "ws-race-a") 2
                waitForPublisherPosition store 2 5_000_000

                (_, _, unsubscribeDummy) <- atomically (subscribePublisher store.publisher 16 DropOldest)
                received <-
                    flip finally unsubscribeDummy $
                        do
                            atomically (writeTVar gateVar False)
                            appendStoreEvents store (StreamName "ws-race-b") 3

                            requireJust "ws/events exactly-once client timed out" $
                                timeout 15_000_000 $
                                    WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do
                                        sendJSON
                                            conn
                                            (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])
                                        _ <- waitForType conn "event_stream_started"
                                        replayed <- replicateM 5 (readEventPosition conn)

                                        atomically (writeTVar gateVar True)
                                        appendStoreEvents store (StreamName "ws-race-c") 1
                                        live <- readEventPosition conn
                                        quiet <- timeout 500_000 (WS.receiveData conn :: IO LBS.ByteString)
                                        quiet `shouldBe` Nothing
                                        pure (replayed <> [live])
                received `shouldBe` [1, 2, 3, 4, 5, 6]

                stopMetricsServer srv

    it "terminates the tail after a replay error instead of streaming with a gap" $
        withMigratedTestDatabase $ \connStr -> do
            storeVar <- newTVarIO Nothing
            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)
            let settings =
                    defaultConnectionSettings connStr
                        & #eventHandler .~ Just (metricsEventHandler km Nothing)
                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)
            withStore settings $ \store -> do
                atomically (writeTVar storeVar (Just store))
                let serverCfg = defaultConfig{port = 0}
                srv <- startMetricsServerWithStore serverCfg km store []
                threadDelay 300_000
                let port = srv.serverPort

                appendStoreEvents store (StreamName "ws-err-a") 2
                waitForPublisherPosition store 2 5_000_000
                renamed <- Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden")
                renamed `shouldBe` Right ()

                requireJust "ws/events replay-error client timed out" $
                    timeout 15_000_000 $
                        WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do
                            sendJSON
                                conn
                                (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])
                            _ <- waitForType conn "event_stream_started"
                            err <- waitForType conn "error"
                            case look ["message"] err of
                                Just (String msg) -> msg `shouldSatisfy` T.isInfixOf "replay error"
                                other -> expectationFailure ("error without message: " <> show other)
                            waitForSubscriberCount store 0 5_000_000

                stopMetricsServer srv

-- | Fail the example with a message if the timed action returned 'Nothing'.
requireJust :: String -> IO (Maybe a) -> IO a
requireJust msg act = act >>= maybe (expectationFailure msg >> error msg) pure

sendJSON :: WS.Connection -> Value -> IO ()
sendJSON conn = WS.sendTextData conn . encode

recvValue :: WS.Connection -> IO Value
recvValue conn = do
    raw <- WS.receiveData conn :: IO LBS.ByteString
    case decode raw of
        Just v -> pure v
        Nothing -> expectationFailure ("undecodable frame: " <> show raw) >> error "undecodable"

-- | Read server messages until one with the given @"type"@ arrives; return it.
waitForType :: WS.Connection -> Text -> IO Value
waitForType conn want = go
  where
    go = do
        v <- recvValue conn
        if look ["type"] v == Just (String want) then pure v else go

-- | Read until an @event@ message, returning its @event.eventType@.
readEventType :: WS.Connection -> IO Text
readEventType conn = do
    v <- waitForType conn "event"
    case look ["event", "eventType"] v of
        Just (String t) -> pure t
        other -> expectationFailure ("event without eventType: " <> show other) >> error "no eventType"

-- | Read until an @event@ message, returning its @event.globalPosition@.
readEventPosition :: WS.Connection -> IO Int
readEventPosition conn = do
    v <- waitForType conn "event"
    case look ["event", "globalPosition"] v of
        Just (Number n) -> pure (truncate (toRealFloat n :: Double))
        other -> expectationFailure ("event without globalPosition: " <> show other) >> error "no position"

-- | Navigate nested object keys.
look :: [Text] -> Value -> Maybe Value
look [] v = Just v
look (k : ks) (Object o) = KM.lookup (Key.fromText k) o >>= look ks
look _ _ = Nothing

-- | Extract @metrics.store.global_position@ from a @snapshot@ message.
globalPositionOf :: Value -> Int
globalPositionOf v =
    case look ["metrics", "store", "global_position"] v of
        Just (Number n) -> truncate (toRealFloat n :: Double)
        _ -> -1

readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition
readPosition storeVar =
    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))

readSubscribers :: TVar (Maybe KirokuStore) -> STM Int
readSubscribers storeVar =
    readTVar storeVar
        >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))

-- | Block until the publisher's subscriber count reaches @target@ or time out.
waitForSubscriberCount :: KirokuStore -> Int -> Int -> IO ()
waitForSubscriberCount store target timeoutMicros = do
    timeoutVar <- registerDelay timeoutMicros
    ok <-
        atomically $
            ( do
                c <- IntMap.size <$> readTVar (subscribers store.publisher)
                check (c == target)
                pure True
            )
                `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)
    unless ok $ do
        actual <- atomically (IntMap.size <$> readTVar (subscribers store.publisher))
        expectationFailure
            ("subscriber count did not reach " <> show target <> "; still " <> show actual)

-- | Block until the publisher cursor reaches @target@ or time out.
waitForPublisherPosition :: KirokuStore -> Int -> Int -> IO ()
waitForPublisherPosition store target timeoutMicros = do
    timeoutVar <- registerDelay timeoutMicros
    ok <-
        atomically $
            ( do
                GlobalPosition p <- publisherPosition store.publisher
                check (p >= fromIntegral target)
                pure True
            )
                `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)
    unless ok $ do
        GlobalPosition actual <- atomically (publisherPosition store.publisher)
        expectationFailure
            ("publisher position did not reach " <> show target <> "; still " <> show actual)

appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO ()
appendStoreEvents store stream n = do
    let events =
            [ EventData
                { eventId = Nothing
                , eventType = EventType ("E" <> T.pack (show i))
                , payload = Null
                , metadata = Nothing
                , causationId = Nothing
                , correlationId = Nothing
                }
            | i <- [1 .. n]
            ]
    result <- runStoreIO store (appendToStream stream NoStream events)
    case result of
        Right _ -> pure ()
        Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)