packages feed

tmp-postgres-1.13.1.1: test/Spec.hs

import Test.Hspec
import Database.Postgres.Temp.Internal.Core
import Database.Postgres.Temp.Internal.Config
import Database.Postgres.Temp.Internal
import Control.Exception
import System.IO.Error
import System.Process
import System.Exit
import qualified Database.PostgreSQL.Simple as PG
import System.Environment
import System.Posix.Files
import System.IO.Temp
import System.Directory
import qualified Database.PostgreSQL.Simple.Options as Client
import Data.String
import System.Timeout(timeout)
import Control.Monad (void, (<=<))
import Data.Function (fix)
import Control.Concurrent
import Data.Monoid
import Network.Socket.Free (getFreePort)
import qualified Data.Map.Strict as Map
import Plans ()

main :: IO ()
main = hspec spec

withConfig' :: Config -> (DB -> IO a) -> IO a
withConfig' config = either throwIO pure <=< withConfig config

with' :: (DB -> IO a) -> IO a
with' = either throwIO pure <=< with

fromCreateDb :: Maybe ProcessConfig -> Config
fromCreateDb createDb = mempty
  { plan = mempty
      { createDbConfig = createDb
      }
  }

newtype Runner =  Runner (forall a. (DB -> IO a) -> IO a)

withRunner :: (DB -> IO ()) -> Runner -> IO ()
withRunner g (Runner f) = f g

--
-- TODO this test should only use the public interface
--

defaultConfigShouldMatchDefaultPlan :: SpecWith Runner
defaultConfigShouldMatchDefaultPlan =
  it "default options should match default plan" $ withRunner $ \DB{..} -> do
    let Resources {..} = dbResources
        CompletePlan {..} = resourcesPlan
        CompletePostgresPlan {..} = completePlanPostgres
    Client.dbname completePostgresPlanClientOptions `shouldBe` pure "postgres"
    let CTemporary tmpDataDir = resourcesDataDir
    tmpDataDir `shouldStartWith` "/tmp/tmp-postgres-data"
    let Just port = getLast $ Client.port completePostgresPlanClientOptions
    port `shouldSatisfy` (>32768)
    let CUnixSocket (CTemporary unixSocket) = resourcesSocket
    unixSocket `shouldStartWith` "/tmp/tmp-postgres-socket"
    completePostgresPlanClientOptions `shouldBe`
      (mempty
        { Client.port = Client.port completePostgresPlanClientOptions
        , Client.host = Client.host completePostgresPlanClientOptions
        , Client.dbname = Client.dbname completePostgresPlanClientOptions
        }
      )

--
-- This is basically the optionsToDefaultConfig but also tests custom postgresql.conf values
-- Simplify and only test the custom conf and it can be combined with another test
customConfigWork :: (Config -> (DB -> IO ()) -> IO ()) -> Spec
customConfigWork action = do
  let expectedDbName = "thedb"
      expectedPassword = "password"
      expectedUser = "user-name"
      expectedDuration = "100ms"
      extraConfig = "log_min_duration_statement='" <> expectedDuration <> "'"

  it "returns the right client options for the plan" $ withTempDirectory "/tmp" "tmp-postgres-spec" $ \tmpDir -> do
    let customPlan = mempty
          { temporaryDirectory = pure tmpDir
          , plan = mempty
              { postgresPlan = mempty
                  { connectionOptions = mempty
                      { Client.user     = pure expectedUser
                      , Client.password = pure expectedPassword
                      , Client.dbname   = pure expectedDbName
                      }
                  }
              , initDbConfig = pure silentProcessConfig
                  { commandLine = mempty
                      { keyBased =
                          Map.singleton "--username=" $ Just "user-name"
                      }
                  , environmentVariables = mempty
                      { specific = Map.singleton "PGPASSWORD" "password"
                      , inherit = pure True
                      }
                  }
              , createDbConfig = pure silentProcessConfig
                { commandLine = mempty
                  { keyBased =
                      Map.singleton "--username=" $ Just "user-name"
                  , indexBased =
                      Map.singleton 0 expectedDbName
                  }
                , environmentVariables =
                    mempty
                      { specific = Map.singleton "PGPASSWORD" "password"
                      , inherit = pure True
                      }
                }
              , postgresConfigFile = [extraConfig]
              }
          }
    -- hmm maybe I should provide lenses
    let combinedResources = silentConfig <> customPlan

    initialFiles <- listDirectory tmpDir

    action combinedResources $ \db@DB {..} -> do
      bracket (PG.connectPostgreSQL $ toConnectionString db) PG.close $ \conn -> do
        [PG.Only actualDuration] <- PG.query_ conn "SHOW log_min_duration_statement"
        actualDuration `shouldBe` expectedDuration

      let Resources {..} = dbResources
          CompletePlan {..} = resourcesPlan
          actualOptions = completePostgresPlanClientOptions completePlanPostgres
          actualPostgresConfig = completePlanConfig
      Client.user actualOptions `shouldBe` pure expectedUser
      Client.dbname actualOptions `shouldBe` pure expectedDbName
      Client.password actualOptions `shouldBe` pure expectedPassword
      lines actualPostgresConfig `shouldContain` defaultPostgresConfig <> [extraConfig]

    listDirectory tmpDir `shouldReturn` initialFiles

invalidConfigFailsQuickly :: (Config -> IO ()) -> Spec
invalidConfigFailsQuickly action = it "quickly fails with an invalid option" $ do
  let customPlan = mempty
        { plan = mempty
            { postgresConfigFile =
                [ "log_directory = /this/does/not/exist"
                , "logging_collector = true"
                ]
            }
        }
  timeout 5000000 (action $ silentConfig <> customPlan) `shouldThrow`
    (== StartPostgresFailed (ExitFailure 1))


throwsIfCreateDbIsNotOnThePath :: IO a -> Spec
throwsIfCreateDbIsNotOnThePath action = it "throws if createdb is not on the path" $
  withSystemTempDirectory "createdb-not-on-path-test" $ \dir -> do
    Just initDbPath   <- findExecutable "initdb"
    Just postgresPath <- findExecutable "postgres"

    -- create symlinks
    createSymbolicLink initDbPath $ dir <> "/initdb"
    createSymbolicLink postgresPath $ dir <> "/postgres"

    path <-  getEnv "PATH"

    bracket (setEnv "PATH" dir) (const $ setEnv "PATH" path) $ \_ ->
      action `shouldThrow` isDoesNotExistError

throwsIfInitDbIsNotOnThePath :: IO a -> Spec
throwsIfInitDbIsNotOnThePath action = it "throws if initdb is not on the path" $ do
  path <-  getEnv "PATH"

  bracket (setEnv "PATH" "/foo") (const $ setEnv "PATH" path) $ \_ ->
    action `shouldThrow` isDoesNotExistError

withAnyPlan :: SpecWith Runner
withAnyPlan = do
  it "start/stop the postgres process is running and then it is not" $ withRunner $ \db@DB{..} -> do
    getProcessExitCode (postgresProcessHandle dbPostgresProcess) `shouldReturn` Nothing

    stop db

    getProcessExitCode (postgresProcessHandle dbPostgresProcess) `shouldReturn` Just ExitSuccess

  it "Can connect to the db after it starts" $ withRunner $ \db -> do
    one <- fmap (PG.fromOnly . head) $
      bracket (PG.connectPostgreSQL $ toConnectionString db ) PG.close $
        \conn -> PG.query_ conn "SELECT 1"

    one `shouldBe` (1 :: Int)

-- This assumes that the directory is initially empty
withInitDbEmptyInitially :: SpecWith Runner
withInitDbEmptyInitially = describe "with active initDb non-empty folder initially" $
  it "the data directory has been initialize" $ withRunner $ \DB {..} -> do
    initialFiles <- listDirectory $ toFilePath $ resourcesDataDir $ dbResources
    initialFiles `shouldContain` ["PG_VERSION"]

createDbCreatesTheDb :: String -> SpecWith Runner
createDbCreatesTheDb dbName = describe "createdb " $
  it "creates the db if it didn't exit" $ withRunner $ \db -> do
    result <- bracket (PG.connectPostgreSQL $ toConnectionString db ) PG.close $
      \conn -> fmap (PG.fromOnly . head) $ PG.query_ conn $ fromString $
        "SELECT EXISTS (SELECT datname FROM pg_catalog.pg_database WHERE datname = '" <> dbName <> "')"
    result `shouldBe` True

createDbThrowsIfTheDbExists :: SpecWith Runner
createDbThrowsIfTheDbExists = describe "createdb" $
  it "throws if the db is not there" $ \(Runner runner) ->
    runner (const $ pure ()) `shouldThrow` (\(CreateDbFailed theOut theErr code) -> do
          code == ExitFailure 1 && length theErr > 0 && theOut == "")

spec :: Spec
spec = do
  let defaultIpPlan = silentConfig
        { socketClass = IpSocket $ Last Nothing
        }

      specificHostIpPlan = silentConfig
        { socketClass = IpSocket $ pure "localhost"
        }

  describe "withConfig" $ do
    let startAction plan = either throwIO pure =<<
          withConfig plan pure

    throwsIfInitDbIsNotOnThePath $ startAction silentConfig
    invalidConfigFailsQuickly $ void . startAction
    customConfigWork $ \plan f -> either throwIO pure =<<
      withConfig plan f

  let someStandardTests dbName= do
        withAnyPlan
        withInitDbEmptyInitially
        createDbCreatesTheDb dbName

  describe "withRestart" $ do
    let startAction f = with' $ \db ->
          either throwIO pure =<< withRestart db f
    before (pure $ Runner startAction) $ do
      someStandardTests "postgres"
      defaultConfigShouldMatchDefaultPlan

--
-- TODO these can be combined
--

  describe "start/stop" $ do
    before (pure $ Runner with') $ do
      someStandardTests "postgres"
      defaultConfigShouldMatchDefaultPlan
      it "stopPostgres cannot be connected to" $ withRunner $ \db -> do
        stopPostgres db `shouldReturn` ExitSuccess
        PG.connectPostgreSQL (toConnectionString db) `shouldThrow`
          (\(_ :: IOError) -> True)

      it "reloadConfig works" $ withRunner $ \db@DB{..} -> do
        let dataDir = toFilePath (resourcesDataDir dbResources)
            expectedDuration = "100ms"
            extraConfig = "log_min_duration_statement='" <> expectedDuration <> "'"
        appendFile (dataDir ++ "/postgresql.conf") $ extraConfig

        reloadConfig db

        bracket (PG.connectPostgreSQL $ toConnectionString db) PG.close $ \conn -> do
          [PG.Only actualDuration] <- PG.query_ conn "SHOW log_min_duration_statement"
          actualDuration `shouldBe` expectedDuration

--
--  Keep this test. Invalid createdb options test
--
    let invalidCreateDbPlan = silentConfig <> fromCreateDb
          ( pure $ silentProcessConfig
              { commandLine = mempty
                { indexBased =
                    Map.singleton 0 "template1"
                }
              }
          )
    before (pure $ Runner $ either throwIO pure <=< withConfig invalidCreateDbPlan) $
      createDbThrowsIfTheDbExists

-- TODO
-- combine this test with another plan
-- tests that the dbName can be specified in the options
-- This is covered by optionsToDefaultConfig test
    let noCreateTemplate1 = mempty
          { plan = mempty
              { createDbConfig = Nothing
              , postgresPlan = mempty
                  { connectionOptions = mempty
                    { Client.dbname = pure "template1"
                    }
                  }
              }
          }
        noCreateDbPlan = defaultConfig <> noCreateTemplate1
    before (pure $ Runner $ withConfig' noCreateDbPlan) $
      someStandardTests "template1"

--
-- Test the default IpSocket plan. Can be combined.
--
    before (pure $ Runner $ withConfig' defaultIpPlan) $
      someStandardTests "postgres"

-- Specific IpSocket plan
-- Can be combined
--
    before (pure $ Runner $ withConfig' specificHostIpPlan) $
      someStandardTests "postgres"

--
-- This test is okay but can be combine with other tests
-- and should use withNewDbConf mempty instead
-- also need to test that I can make a specific database name

    before (pure $ Runner $ withConfig' silentConfig) $
      it "withNewDb works" $ withRunner $ \db -> do
        bracket (PG.connectPostgreSQL $ toConnectionString db ) PG.close $
          \conn -> do
            _ <- PG.execute_ conn "CREATE TABLE foo ( id int );"
            void $ PG.execute_ conn "INSERT INTO foo (id) VALUES (1);"

        void $ withNewDb db $ \newDb -> do
          one <- fmap (PG.fromOnly . head) $
            bracket (PG.connectPostgreSQL $ toConnectionString newDb ) PG.close $
              \conn -> PG.query_ conn "SELECT id FROM foo"

          one `shouldBe` (1 :: Int)

--
-- This is a optionsToDefaultConfig test
-- It is missing the host options of both
-- a socket and ip address based
--

    thePort <- runIO getFreePort
    let planFromCustomUserDbConnection = optionsToDefaultConfig mempty
          { Client.dbname   = pure "fancy"
          , Client.user     = pure "some_user"
          , Client.port     = pure thePort
          , Client.password = pure "password"
          , Client.host     = pure "localhost"
          }

    before (pure $ Runner $ withConfig' planFromCustomUserDbConnection) $
      someStandardTests "fancy"

--
-- end optionsToDefaultConfig test
--

--
-- Keep this test -- small connection timeout works
--
    let immediantlyTimeout = silentConfig <> mempty
          { plan = mempty
              { connectionTimeout = pure 0
              }
          }

    before (pure $ Runner $ withConfig' immediantlyTimeout) $
      it "should timeout" $ \(Runner runner) ->
        runner (const $ pure ()) `shouldThrow` (== ConnectionTimedOut)
--
-- End this test
--

--
-- Keep these tests
--
    before (createTempDirectory "/tmp" "tmp-postgres-test") $ after rmDirIgnoreErrors $ do
      it "fails on non-empty data directory" $ \dirPath -> do
        writeFile (dirPath <> "/PG_VERSION") "1 million"
        let nonEmptyFolderPlan = silentConfig
              { dataDirectory = Permanent dirPath
              }
            startAction = withConfig' nonEmptyFolderPlan $ const $ pure ()

        startAction `shouldThrow` (\(InitDbFailed theOut theErr code) -> do
          code == ExitFailure 1 && length theOut > 0 && length theErr > 0)

      it "works if on non-empty if initdb is disabled" $ \dirPath -> do
        throwIfNotSuccess id =<< system ("initdb " <> dirPath)
        let nonEmptyFolderPlan = silentConfig
              { dataDirectory = Permanent dirPath
              , plan = (plan silentConfig)
                  { initDbConfig = Nothing
                  }
              }
        withConfig' nonEmptyFolderPlan $ \db -> do
          one <- fmap (PG.fromOnly . head) $
            bracket (PG.connectPostgreSQL $ toConnectionString db ) PG.close $
              \conn -> PG.query_ conn "SELECT 1"

          one `shouldBe` (1 :: Int)

--
-- Backup "test"
--

    let justBackupResources = mempty
          { plan = mempty
              { postgresConfigFile =
                  [ "wal_level=replica"
                  , "archive_mode=on"
                  , "max_wal_senders=2"
                  , "fsync=on"
                  , "synchronous_commit=on"
                  ]
              }
          }
        backupResources = silentConfig <> justBackupResources
    before (pure $ Runner $ withConfig' backupResources) $
      it "can support backup and restore" $ withRunner $ \db@DB {..} -> do
        let dataDir = toFilePath (resourcesDataDir dbResources)
        appendFile (dataDir ++ "/pg_hba.conf") $ "local replication all trust"
        withTempDirectory "/tmp" "tmp-postgres-backup" $ \tempDir -> do
          let walArchiveDir = tempDir ++ "/archive"
              baseBackupFile = tempDir ++ "/backup"
              archiveLine = "archive_command = " ++
                "'test ! -f " ++ walArchiveDir ++ "/%f && cp %p " ++ walArchiveDir ++ "/%f'\n"
          appendFile (dataDir ++ "/postgresql.conf") $ archiveLine

          createDirectory walArchiveDir

          reloadConfig db

          let Just port = getLast $ Client.port $ postgresProcessClientOptions dbPostgresProcess
              Just host = getLast $ Client.host $ postgresProcessClientOptions dbPostgresProcess
              backupCommand = "pg_basebackup -D " ++ baseBackupFile ++ " --format=tar -p"
                ++ show port ++ " -h" ++ host

          system backupCommand `shouldReturn` ExitSuccess

          bracket (PG.connectPostgreSQL $ toConnectionString db ) PG.close $ \conn -> do
            _ <- PG.execute_ conn "CREATE TABLE foo(id int PRIMARY KEY);"
            _ <- PG.execute_ conn "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE; INSERT INTO foo (id) VALUES (1); COMMIT"
            _ :: [PG.Only String] <- PG.query_ conn "SELECT pg_walfile_name(pg_switch_wal())"
            _ :: [PG.Only String] <- PG.query_ conn "SELECT pg_walfile_name(pg_create_restore_point('pitr'))"
            _ <- PG.execute_ conn "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE; INSERT INTO foo (id) VALUES (2); COMMIT"

            PG.query_ conn "SELECT id FROM foo ORDER BY id ASC"
              `shouldReturn` [PG.Only (1 :: Int), PG.Only 2]

          stopPostgres db `shouldReturn` ExitSuccess

          removeDirectoryRecursive dataDir
          createDirectory dataDir

          let untarCommand = "tar -C" ++ dataDir ++ " -xf " ++ baseBackupFile ++ "/base.tar"
          system untarCommand `shouldReturn` ExitSuccess

          system ("chmod -R 700 " ++ dataDir) `shouldReturn` ExitSuccess

          writeFile (dataDir ++ "/recovery.conf") $ "recovery_target_name='pitr'\nrecovery_target_action='promote'\nrecovery_target_inclusive=true\nrestore_command='"
             ++ "cp " ++ walArchiveDir ++ "/%f %p'"

          either throwIO pure <=< withRestart db $ \newDb -> do
            bracket (PG.connectPostgreSQL $ toConnectionString newDb) PG.close $ \conn -> do
              fix $ \next -> do
                fmap (PG.fromOnly . head) (PG.query_ conn "SELECT pg_is_in_recovery()") >>= \case
                  True -> threadDelay 100000 >> next
                  False -> pure ()

              PG.query_ conn "SELECT id FROM foo ORDER BY id ASC"
                `shouldReturn` [PG.Only (1 :: Int)]