bloodhound-1.0.0.0: tests/Test/SSESpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Pure unit tests for the SSE parser
-- ('Database.Bloodhound.Internal.Utils.SSE'). The parser is exercised
-- against literal byte streams copied from the OpenSearch ML Commons
-- Execute Stream Agent documentation (both the conversational-agent
-- @data:@ framing and the AG-UI event framing), so these tests pin the
-- wire shape against future drift. No live cluster is required: a
-- 'Network.HTTP.Client.BodyReader' is synthesised from an in-memory
-- 'ByteString' via a small helper.
module Test.SSESpec (spec) where
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text (Text)
import Data.Text qualified as T
import Database.Bloodhound.Internal.Utils.SSE
import Network.HTTP.Client (BodyReader)
import Test.Hspec
import Prelude hiding (head)
-- | Build a 'BodyReader' that yields the given bytes in one shot then
-- EOF. Mirrors how @http-client@ delivers a fully-buffered chunk for
-- small responses; sufficient for these parser tests.
mkBodyReader :: ByteString -> IO BodyReader
mkBodyReader bytes = do
ref <- newIORef bytes
pure $
readIORef ref >>= \remaining ->
if BS.null remaining
then pure BS.empty
else do
writeIORef ref BS.empty
pure remaining
spec :: Spec
spec = do
describe "SSE parser" $ do
it "parses a single data: event" $ do
br <- mkBodyReader "data: {\"hello\":1}\n\n"
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "{\"hello\":1}"
sseEvent <$> ev `shouldBe` Just Nothing
it "parses multiple consecutive events" $ do
br <- mkBodyReader "data: a\n\ndata: b\n\ndata: c\n\n"
st <- newSSEState br
e1 <- readSSEEvent st
e2 <- readSSEEvent st
e3 <- readSSEEvent st
e4 <- readSSEEvent st
sseData <$> e1 `shouldBe` Just "a"
sseData <$> e2 `shouldBe` Just "b"
sseData <$> e3 `shouldBe` Just "c"
e4 `shouldBe` Nothing
it "joins multi-line data: fields with \\n (per spec)" $ do
br <- mkBodyReader "data: line1\ndata: line2\n\n"
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "line1\nline2"
it "strips a single leading space after the colon" $ do
br <- mkBodyReader "data: padded\n\n"
st <- newSSEState br
ev <- readSSEEvent st
-- Only the FIRST space is stripped; the rest are preserved.
sseData <$> ev `shouldBe` Just " padded"
it "tolerates CRLF line endings" $ do
br <- mkBodyReader "data: crlf\r\n\r\n"
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "crlf"
it "ignores comment lines starting with :" $ do
br <- mkBodyReader ": this is a comment\ndata: real\n\n"
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "real"
it "parses event:/id:/retry: fields" $ do
br <- mkBodyReader "event: add\ndata: x\nid: 7\nretry: 3000\n\n"
st <- newSSEState br
ev <- readSSEEvent st
ev
`shouldBe` Just
(SSEEvent {sseEvent = Just "add", sseData = "x", sseId = Just "7", sseRetry = Just 3000})
it "handles incremental byte delivery (chunks smaller than a frame)" $ do
-- A BodyReader that yields 3 bytes at a time, simulating a slow
-- network stream. The parser must reassemble across reads.
ref <- newIORef ("data: hello\n\n" :: ByteString)
let br :: BodyReader
br = do
remaining <- readIORef ref
let (chunk, rest) = BS.splitAt 3 remaining
writeIORef ref rest
pure chunk
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "hello"
it "returns Nothing at end of stream" $ do
br <- mkBodyReader ""
st <- newSSEState br
ev <- readSSEEvent st
ev `shouldBe` Nothing
it "dispatches a trailing partial event at EOF" $ do
br <- mkBodyReader "data: tail-no-separator"
st <- newSSEState br
ev <- readSSEEvent st
sseData <$> ev `shouldBe` Just "tail-no-separator"
it "parses a realistic conversational-agent chunk sequence" $ do
let stream :: ByteString =
"data: {\"inference_results\":[{\"output\":[{\"name\":\"memory_id\",\"result\":\"m1\"}]}]}\n\n\
\data: {\"inference_results\":[{\"output\":[{\"name\":\"response\",\"dataAsMap\":{\"content\":\"There\",\"is_last\":false}}]}]}\n\n\
\data: {\"inference_results\":[{\"output\":[{\"name\":\"response\",\"dataAsMap\":{\"content\":\" are\",\"is_last\":false}}]}]}\n\n\
\data: {\"inference_results\":[{\"output\":[{\"name\":\"response\",\"dataAsMap\":{\"content\":\"\",\"is_last\":true}}]}]}\n\n"
br <- mkBodyReader stream
st <- newSSEState br
chunks <- collect st 10
length chunks `shouldBe` 4
case chunks of
firstEv : _ -> sseData firstEv `shouldSatisfy` T.isInfixOf "memory_id"
[] -> expectationFailure "expected at least one chunk"
-- | Collect up to @n@ events from the parser, then stop.
collect :: SSEState -> Int -> IO [SSEEvent]
collect _ 0 = pure []
collect st n = do
next <- readSSEEvent st
case next of
Just ev -> (ev :) <$> collect st (n - 1)
Nothing -> pure []