packages feed

atelier-testing-0.1.0.0: src/Atelier/Testing/Database.hs

-- | Hspec helpers for running tests against a real, ephemeral PostgreSQL
-- database.
--
-- 'withCleanTestDatabase' starts a temporary PostgreSQL server once per test
-- process, applies your migrations into a template database, and hands each spec
-- group a fresh database (as 'DBPools') that is truncated between individual
-- tests. Configure it with a 'TmpDbConfig'.
module Atelier.Testing.Database
    ( TmpDbConfig (..)
    , withCleanTestDatabase
    )
where

import Atelier.Effects.DB.Config (DBConfig (..), DBPools (..), PoolConfig (..), acquireDatabasePool, acquireDatabasePools)
import Atelier.Exception (trySyncIO)
import Control.Concurrent (MVar, forkIO, modifyMVar, newEmptyMVar, newMVar, putMVar, takeMVar, threadDelay, tryPutMVar, withMVar)
import Data.String.Conversions (cs)
import Database.PostgreSQL.Simple.Options (Options (..))
import Hasql.Session (Session, statement)
import Hasql.Statement (Statement (..))
import System.IO.Unsafe (unsafePerformIO)
import System.Posix.User (getEffectiveUserName)
import Test.Hspec (Spec, SpecWith, around, runIO)

import Data.Text qualified as Text
import Data.UUID qualified as UUID
import Data.UUID.V4 qualified as UUIDv4
import Database.Postgres.Temp qualified as TmpPostgres
import Hasql.Decoders qualified as Decoders
import Hasql.Pool qualified as Pool


-- | Project-specific configuration for the temporary test database.
data TmpDbConfig = TmpDbConfig
    { readerUser :: Text
    -- ^ Role used for read-only connections
    , writerUser :: Text
    -- ^ Role used for read-write connections
    , schemaName :: Text
    -- ^ Application schema name
    , excludedTables :: [Text]
    -- ^ Tables to exclude from truncation between tests (e.g. migration tracking tables)
    , setupTemplate :: DBConfig -> Text -> IO ()
    -- ^ Called with admin config and template DB name; should apply all migrations
    }


-- | Shared postgres server and migrated template database, created once for
-- the entire test process. All calls to 'withCleanTestDatabase' share this
-- server, so all callers must use the same 'TmpDbConfig'.
data SharedServer = SharedServer
    { socketDir :: String
    , portNum :: Word16
    , tmpUser :: String
    , templateDbName :: Text
    , defaultPool :: PoolConfig
    }


{-# NOINLINE sharedServerVar #-}
sharedServerVar :: MVar (Maybe SharedServer)
sharedServerVar = unsafePerformIO (newMVar Nothing)


acquireSharedServer :: TmpDbConfig -> IO SharedServer
acquireSharedServer cfg = modifyMVar sharedServerVar $ \case
    Just server -> pure (Just server, server)
    Nothing -> do
        server <- startSharedServer cfg
        pure (Just server, server)


-- | Starts a TmpPostgres server in a background thread, applies migrations
-- once into a template database, then keeps the server alive for the process
-- lifetime.
startSharedServer :: TmpDbConfig -> IO SharedServer
startSharedServer cfg = do
    effectiveUser <- getEffectiveUserName
    readyVar <- newEmptyMVar
    void $ forkIO $ do
        TmpPostgres.withDbCache $ \dbCache -> do
            let config = TmpPostgres.cacheConfig dbCache
            result <- TmpPostgres.withConfig config $ \db -> do
                let Options {host = Last mHost, port = Last mPort, user = Last mUser} =
                        TmpPostgres.toConnectionOptions db
                    socketDir = fromMaybe "localhost" mHost
                    portNum = fromIntegral (fromMaybe 5432 mPort)
                    tmpUser = fromMaybe effectiveUser mUser
                    pool =
                        PoolConfig
                            { size = 10
                            , acquisitionTimeoutSeconds = 5
                            , agingTimeoutSeconds = 1800
                            , idlenessTimeoutSeconds = 600
                            }
                    adminConfig =
                        DBConfig
                            { host = cs socketDir
                            , port = portNum
                            , user = cs tmpUser
                            , password = ""
                            , databaseName = "postgres"
                            , pool = pool
                            }
                setupResult <- trySyncIO $ do
                    templateName <- generateUniqueDatabaseName
                    adminPool <- acquireDatabasePool adminConfig
                    createDatabase adminPool templateName
                    cfg.setupTemplate adminConfig templateName
                    grantTruncatePrivileges adminConfig templateName cfg.schemaName cfg.writerUser
                    pure $ SharedServer {socketDir, portNum, tmpUser, templateDbName = templateName, defaultPool = pool}
                case setupResult of
                    Left e -> putMVar readyVar (Left (show e))
                    Right server -> do
                        putMVar readyVar (Right server)
                        forever $ threadDelay 1_000_000_000
            case result of
                Left e -> void $ tryPutMVar readyVar (Left (show e))
                Right _ -> pure ()
    takeMVar readyVar >>= either error pure


-- | Sets up a fresh database for the spec group (once, during spec
-- construction) and truncates all tables before each individual test.
-- The postgres server and template are shared across the process.
--
-- Usage:
-- @
-- spec :: Spec
-- spec = withCleanTestDatabase myConfig $ do
--   it "can read from the database" $ \pools -> do
--     -- Test code using pools
-- @
withCleanTestDatabase :: TmpDbConfig -> SpecWith DBPools -> Spec
withCleanTestDatabase cfg spec = do
    pools <- runIO $ setupTestDatabase cfg
    lock <- runIO $ newMVar ()
    around (\action -> withMVar lock $ \_ -> cleanDatabase cfg pools >> action pools) spec


-- | Create a database from the shared template and acquire connection pools.
-- The database is not explicitly dropped — TmpPostgres cleans up on exit.
setupTestDatabase :: TmpDbConfig -> IO DBPools
setupTestDatabase cfg = do
    server <- acquireSharedServer cfg
    dbName <- generateUniqueDatabaseName
    let adminConfig =
            DBConfig
                { host = cs server.socketDir
                , port = server.portNum
                , user = cs server.tmpUser
                , password = ""
                , databaseName = "postgres"
                , pool = server.defaultPool
                }
        readerConfig =
            DBConfig
                { host = cs server.socketDir
                , port = server.portNum
                , user = cfg.readerUser
                , password = ""
                , databaseName = dbName
                , pool = server.defaultPool
                }
        writerConfig =
            DBConfig
                { host = cs server.socketDir
                , port = server.portNum
                , user = cfg.writerUser
                , password = ""
                , databaseName = dbName
                , pool = server.defaultPool
                }
    createDatabaseFromTemplate adminConfig dbName server.templateDbName
    acquireDatabasePools readerConfig writerConfig


createDatabaseFromTemplate :: DBConfig -> Text -> Text -> IO ()
createDatabaseFromTemplate adminConfig dbName templateName = do
    pool <- acquireDatabasePool adminConfig
    runOrThrow pool
        $ statement ()
        $ Statement
            (cs $ "CREATE DATABASE " <> dbName <> " TEMPLATE " <> templateName)
            mempty
            Decoders.noResult
            False


createDatabase :: Pool.Pool -> Text -> IO ()
createDatabase pool dbName =
    runOrThrow pool
        $ statement ()
        $ Statement (cs $ "CREATE DATABASE " <> dbName) mempty Decoders.noResult False


-- | Grant TRUNCATE on all tables in the schema to the writer role.
-- Applied to the template database so all copies inherit the grant.
grantTruncatePrivileges :: DBConfig -> Text -> Text -> Text -> IO ()
grantTruncatePrivileges config dbName schema writerRole = do
    pool <- acquireDatabasePool (config {databaseName = dbName})
    runOrThrow pool
        $ statement ()
        $ Statement
            (cs $ "GRANT TRUNCATE ON ALL TABLES IN SCHEMA " <> schema <> " TO " <> writerRole)
            mempty
            Decoders.noResult
            False


-- | Truncate all tables in the schema except excluded ones.
cleanDatabase :: TmpDbConfig -> DBPools -> IO ()
cleanDatabase cfg pools =
    runOrThrow pools.writerPool $ do
        tableNames <- statement () queryTableNames
        unless (null tableNames)
            $ truncateTables (Text.intercalate "," (map (\t -> cfg.schemaName <> "." <> t) tableNames))
  where
    queryTableNames :: Statement () [Text]
    queryTableNames =
        Statement
            ( cs
                $ "SELECT tablename FROM pg_tables WHERE schemaname = '"
                    <> cfg.schemaName
                    <> "'"
                    <> exclusions
            )
            mempty
            (decodeList Decoders.text)
            True

    exclusions =
        if null cfg.excludedTables then
            ""
        else
            " AND tablename NOT IN ("
                <> Text.intercalate "," (map (\t -> "'" <> t <> "'") cfg.excludedTables)
                <> ")"

    truncateTables :: Text -> Session ()
    truncateTables tableNames =
        statement ()
            $ Statement
                (cs $ "TRUNCATE TABLE " <> tableNames <> " CASCADE")
                mempty
                Decoders.noResult
                False


decodeList :: Decoders.Value a -> Decoders.Result [a]
decodeList val = Decoders.rowList $ Decoders.column $ Decoders.nonNullable val


runOrThrow :: Pool.Pool -> Session a -> IO a
runOrThrow pool sess =
    Pool.use pool sess >>= \case
        Left e -> error $ "Database operation failed: " <> show e
        Right a -> pure a


generateUniqueDatabaseName :: IO Text
generateUniqueDatabaseName = do
    uuid <- UUIDv4.nextRandom
    pure $ "testdb_" <> Text.replace "-" "_" (UUID.toText uuid)