packages feed

pgmq-hasql-0.5.0.0: test/NotifyChannelSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | PGH-9: the documented LISTEN\/NOTIFY channel name was wrong everywhere.
--
-- The Haddock and the design note both claimed @pgmq_\<queue_name\>@, so anyone
-- following them listened on a channel that never receives anything. The real
-- channel is @pgmq.q_\<lowercased queue name\>.INSERT@, now computed by
-- 'notifyChannelName'.
--
-- This module pins the contract from both sides: a real notification arrives on
-- exactly the channel the helper computes, and a listener on the old documented
-- name receives nothing.
module NotifyChannelSpec (tests) where

import Control.Concurrent (threadDelay)
import Control.Exception (bracket)
import Control.Monad (unless)
import Data.Aeson qualified as Aeson
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Database.PostgreSQL.LibPQ qualified as LibPQ
import EphemeralDb (Database, TestFixture (..), withTestFixture)
import EphemeralPg qualified as Pg
import Hasql.Pool qualified as Pool
import Pgmq.Hasql.Sessions qualified as Sessions
import Pgmq.Hasql.Statements.Types qualified as StmtTypes
import Pgmq.Types (MessageBody (..), QueueName, notifyChannelName, queueNameToText)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))
import TestUtils (assertSession, cleanupQueue)

tests :: Pool.Pool -> Database -> TestTree
tests p db =
  testGroup
    "Notification Channel Contract"
    [ testCase "a notification arrives on exactly notifyChannelName" $
        withTestFixture p $ \TestFixture {pool, queueName} -> do
          enableNotify pool queueName
          received <- withListener db (notifyChannelName queueName) $ \conn -> do
            sendProbe pool queueName
            awaitNotify conn 20
          case received of
            Nothing ->
              assertFailure $
                "expected a notification on " <> show (notifyChannelName queueName) <> " within 2s, got none"
            Just notification ->
              LibPQ.notifyRelname notification @?= TE.encodeUtf8 (notifyChannelName queueName)
          cleanupQueue pool queueName,
      testCase "nothing arrives on the old documented channel name" $
        withTestFixture p $ \TestFixture {pool, queueName} -> do
          enableNotify pool queueName
          received <- withListener db (legacyChannelName queueName) $ \conn -> do
            sendProbe pool queueName
            awaitNotify conn 10
          case received of
            Nothing -> pure ()
            Just notification ->
              assertFailure $
                "the old documented channel "
                  <> show (legacyChannelName queueName)
                  <> " received "
                  <> show (LibPQ.notifyRelname notification)
          cleanupQueue pool queueName
    ]

-- | The channel name this library's documentation claimed until 2026-08-05.
legacyChannelName :: QueueName -> Text
legacyChannelName qn = "pgmq_" <> queueNameToText qn

enableNotify :: Pool.Pool -> QueueName -> IO ()
enableNotify pool qn = do
  assertSession pool (Sessions.createQueue qn)
  assertSession pool $
    Sessions.enableNotifyInsert
      StmtTypes.EnableNotifyInsert
        { StmtTypes.queueName = qn,
          StmtTypes.throttleIntervalMs = Just 0 -- 0 = never throttle
        }

sendProbe :: Pool.Pool -> QueueName -> IO ()
sendProbe pool qn =
  ()
    <$ assertSession
      pool
      ( Sessions.sendMessage
          StmtTypes.SendMessage
            { StmtTypes.queueName = qn,
              StmtTypes.messageBody = MessageBody (Aeson.String "notify-probe"),
              StmtTypes.delay = Nothing
            }
      )

-- | Open a raw libpq connection (hasql 1.10 exposes no notification API) and
-- subscribe to @channel@. ephemeral-pg hands out connection strings as 'Text'
-- while libpq consumes 'ByteString', so both the conninfo and the command are
-- encoded explicitly.
withListener :: Database -> Text -> (LibPQ.Connection -> IO a) -> IO a
withListener db channel action =
  bracket (LibPQ.connectdb (TE.encodeUtf8 (Pg.connectionString db))) LibPQ.finish $ \conn -> do
    connStatus <- LibPQ.status conn
    unless (connStatus == LibPQ.ConnectionOk) $ do
      err <- LibPQ.errorMessage conn
      assertFailure $ "libpq connection failed: " <> show err
    -- The channel contains dots, so LISTEN needs the identifier double-quoted.
    result <- LibPQ.exec conn (TE.encodeUtf8 ("LISTEN " <> quoteIdentifier channel))
    case result of
      Nothing -> assertFailure "LISTEN returned no result"
      Just res -> do
        execStatus <- LibPQ.resultStatus res
        unless (execStatus == LibPQ.CommandOk) $
          assertFailure ("LISTEN failed with " <> show execStatus)
    action conn

quoteIdentifier :: Text -> Text
quoteIdentifier ident = "\"" <> T.replace "\"" "\"\"" ident <> "\""

-- | Poll for a notification, 100 ms per attempt.
awaitNotify :: LibPQ.Connection -> Int -> IO (Maybe LibPQ.Notify)
awaitNotify conn attempts
  | attempts <= 0 = pure Nothing
  | otherwise = do
      _ <- LibPQ.consumeInput conn
      pending <- LibPQ.notifies conn
      case pending of
        Just n -> pure (Just n)
        Nothing -> do
          threadDelay 100_000
          awaitNotify conn (attempts - 1)