packages feed

pgmq-hasql-0.1.0.0: test/DecoderValidationSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Field-specific decoder validation tests
-- These tests verify each decoded field contains semantically correct values
-- This catches column swap bugs (e.g., reading read_ct into msg_id)
module DecoderValidationSpec (tests) where

import Data.Aeson (Value, object, (.=))
import Data.Time (addUTCTime, getCurrentTime)
import Data.Vector qualified as V
import EphemeralDb (TestFixture (..), withTestFixture)
import Hasql.Pool qualified as Pool
import Hasql.Session (Session)
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Hasql.Statements.Types
  ( ReadMessage (..),
    SendMessage (..),
    SendMessageWithHeaders (..),
    VisibilityTimeoutQuery (..),
  )
import Pgmq.Types
  ( Message (..),
    MessageBody (..),
    MessageHeaders (..),
    QueueName,
    unMessageId,
  )
import Pgmq.Types qualified
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
import TestUtils (assertSession, cleanupQueue)

-- | All decoder validation tests
tests :: Pool.Pool -> TestTree
tests p =
  testGroup
    "Decoder Field Validation"
    [ testMessageIdIsPositive p,
      testReadCountIncrements p,
      testEnqueuedAtBeforeRead p,
      testLastReadAtUpdates p,
      testVisibilityTimeAfterRead p,
      testBodyMatchesSent p,
      testHeadersMatchSent p,
      testNullHeadersWhenNotSent p
    ]

-- | Test that messageId is always positive
testMessageIdIsPositive :: Pool.Pool -> TestTree
testMessageIdIsPositive p = testCase "messageId is positive" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message
    let sendMsg = mkSendMessage queueName (object ["test" .= ("positive_id" :: String)])
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    -- Read message
    messages <- assertSession pool (mkReadMessage queueName)
    assertEqual "Should have 1 message" 1 (V.length messages)

    let msg = V.head messages
    assertBool
      "messageId should be positive (> 0)"
      (unMessageId (Pgmq.Types.messageId msg) > 0)
    cleanupQueue pool queueName

-- | Test that readCount increments on each read
testReadCountIncrements :: Pool.Pool -> TestTree
testReadCountIncrements p = testCase "readCount increments on each read" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message
    let sendMsg = mkSendMessage queueName (object ["test" .= ("read_count" :: String)])
    msgId <- assertSession pool (Sessions.sendMessage sendMsg)

    -- First read
    messages1 <- assertSession pool (mkReadMessage queueName)
    assertEqual "Should have 1 message" 1 (V.length messages1)
    let msg1 = V.head messages1
    assertEqual "readCount should be 1 after first read" 1 (readCount msg1)

    -- Make message visible again via set_vt
    let vtQuery =
          VisibilityTimeoutQuery
            { queueName = queueName,
              messageId = msgId,
              visibilityTimeoutOffset = 0 -- Make visible immediately
            }
    _ <- assertSession pool (Sessions.changeVisibilityTimeout vtQuery)

    -- Second read
    messages2 <- assertSession pool (mkReadMessage queueName)
    assertEqual "Should have 1 message" 1 (V.length messages2)
    let msg2 = V.head messages2
    assertEqual "readCount should be 2 after second read" 2 (readCount msg2)

    cleanupQueue pool queueName

-- | Test that enqueuedAt is before read time
testEnqueuedAtBeforeRead :: Pool.Pool -> TestTree
testEnqueuedAtBeforeRead p = testCase "enqueuedAt is before read time" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    beforeEnqueue <- getCurrentTime
    assertSession pool (Sessions.createQueue queueName)

    -- Send message
    let sendMsg = mkSendMessage queueName (object ["test" .= ("enqueued_at" :: String)])
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    afterEnqueue <- getCurrentTime

    -- Read message
    messages <- assertSession pool (mkReadMessage queueName)
    let msg = V.head messages

    -- enqueuedAt should be between beforeEnqueue and afterEnqueue
    assertBool
      "enqueuedAt should be after test start"
      (enqueuedAt msg >= beforeEnqueue)
    assertBool
      "enqueuedAt should be before read"
      (enqueuedAt msg <= afterEnqueue)

    cleanupQueue pool queueName

-- | Test that lastReadAt is set after reading
testLastReadAtUpdates :: Pool.Pool -> TestTree
testLastReadAtUpdates p = testCase "lastReadAt is set after reading" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message
    let sendMsg = mkSendMessage queueName (object ["test" .= ("last_read_at" :: String)])
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    beforeRead <- getCurrentTime

    -- First read
    messages1 <- assertSession pool (mkReadMessage queueName)
    let msg1 = V.head messages1

    afterRead <- getCurrentTime

    -- lastReadAt should be Just and between beforeRead and afterRead
    case lastReadAt msg1 of
      Nothing -> assertBool "lastReadAt should be set after first read" False
      Just lra -> do
        assertBool
          "lastReadAt should be >= beforeRead"
          (lra >= addUTCTime (-1) beforeRead) -- Allow 1 second tolerance
        assertBool
          "lastReadAt should be <= afterRead"
          (lra <= addUTCTime 1 afterRead) -- Allow 1 second tolerance
    cleanupQueue pool queueName

-- | Test that visibilityTime is in future after read with delay
testVisibilityTimeAfterRead :: Pool.Pool -> TestTree
testVisibilityTimeAfterRead p = testCase "visibilityTime is in future after read" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message
    let sendMsg = mkSendMessage queueName (object ["test" .= ("vt" :: String)])
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    beforeRead <- getCurrentTime

    -- Read message with 60 second visibility timeout
    let readQuery =
          ReadMessage
            { queueName = queueName,
              delay = 60, -- 60 second visibility timeout
              batchSize = Just 1,
              conditional = Nothing
            }
    messages <- assertSession pool (Sessions.readMessage readQuery)
    let msg = V.head messages

    -- visibilityTime should be at least 55 seconds in the future
    let expectedMinVt = addUTCTime 55 beforeRead
    assertBool
      "visibilityTime should be at least 55 seconds in future"
      (visibilityTime msg >= expectedMinVt)

    cleanupQueue pool queueName

-- | Test that body exactly matches what was sent
testBodyMatchesSent :: Pool.Pool -> TestTree
testBodyMatchesSent p = testCase "body exactly matches sent message" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message with specific body
    let sentBody =
          object
            [ "string_field" .= ("test value" :: String),
              "int_field" .= (42 :: Int),
              "bool_field" .= True,
              "nested" .= object ["inner" .= ("nested value" :: String)]
            ]
    let sendMsg = mkSendMessage queueName sentBody
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    -- Read message
    messages <- assertSession pool (mkReadMessage queueName)
    let msg = V.head messages
    let receivedBody = unMessageBody (Pgmq.Types.body msg)

    assertEqual "body should exactly match sent message" sentBody receivedBody

    cleanupQueue pool queueName

-- | Test that headers exactly match what was sent
testHeadersMatchSent :: Pool.Pool -> TestTree
testHeadersMatchSent p = testCase "headers exactly match sent message" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message with headers
    let sentBody = object ["data" .= ("test" :: String)]
    let sentHeaders =
          object
            [ "trace_id" .= ("abc-123-xyz" :: String),
              "priority" .= (1 :: Int),
              "routing_key" .= ("queue.important" :: String)
            ]
    let sendMsg =
          SendMessageWithHeaders
            { queueName = queueName,
              messageBody = MessageBody sentBody,
              messageHeaders = MessageHeaders sentHeaders,
              delay = Nothing
            }
    _ <- assertSession pool (Sessions.sendMessageWithHeaders sendMsg)

    -- Read message
    messages <- assertSession pool (mkReadMessage queueName)
    let msg = V.head messages

    assertEqual "headers should exactly match sent message" (Just sentHeaders) (Pgmq.Types.headers msg)

    cleanupQueue pool queueName

-- | Test that headers are null when not sent
testNullHeadersWhenNotSent :: Pool.Pool -> TestTree
testNullHeadersWhenNotSent p = testCase "headers are null when not sent" $ do
  withTestFixture p $ \TestFixture {pool, queueName} -> do
    assertSession pool (Sessions.createQueue queueName)

    -- Send message without headers
    let sendMsg = mkSendMessage queueName (object ["test" .= ("no_headers" :: String)])
    _ <- assertSession pool (Sessions.sendMessage sendMsg)

    -- Read message
    messages <- assertSession pool (mkReadMessage queueName)
    let msg = V.head messages

    assertEqual "headers should be Nothing when not sent" Nothing (Pgmq.Types.headers msg)

    cleanupQueue pool queueName

-- Helper functions

mkSendMessage :: QueueName -> Value -> SendMessage
mkSendMessage qName bodyVal =
  SendMessage
    { queueName = qName,
      messageBody = MessageBody bodyVal,
      delay = Nothing
    }

mkReadMessage :: QueueName -> Session (V.Vector Message)
mkReadMessage qName =
  Sessions.readMessage
    ReadMessage
      { queueName = qName,
        delay = 30,
        batchSize = Just 1,
        conditional = Nothing
      }