pg-migrate-cli-1.1.0.0: test/unit/Test/Handler.hs
module Test.Handler (tests) where
import Control.Exception qualified as Exception
import Data.ByteString qualified as ByteString
import Data.List.NonEmpty (NonEmpty (..))
import Data.Set qualified as Set
import Data.Text qualified as Text
import Database.PostgreSQL.Migrate
import Database.PostgreSQL.Migrate.CLI
import Database.PostgreSQL.Migrate.Embed
( AuthoringError (..),
ManifestError (..),
)
import Hasql.Connection.Settings qualified as Settings
import System.Directory qualified as Directory
import System.FilePath ((</>))
import Test.Tasty
import Test.Tasty.HUnit
tests :: TestTree
tests =
testGroup
"handler"
[ testCase "plan text is stable and preserves component order" testPlanText,
testCase "list filters narrow output without changing plan order" testListFilter,
testCase "unknown inspection filters fail before IO" testUnknownInspectionFilters,
testCase "check returns exact-byte checksums" testCheck,
testCase "new creates and appends a migration without applying it" testNew,
testCase "new rejects control characters before writing files" testNewRejectsControlCharacters,
testCase "new reports when a nonnumeric manifest requires an explicit name" testNewRequiresName,
testCase "manifest IO failures are typed execution outcomes" testCheckIoFailure,
testCase "manifest validation failures remain typed usage outcomes" testCheckValidationFailure,
testCase "new classifies manifest IO failures as execution outcomes" testNewManifestIoFailure
]
testPlanText :: Assertion
testPlanText = do
outcome <-
runMigrationCommand
fixtureEnvironment
(Plan (PlanOptions noInspection textOutput))
exitClass outcome @?= ExitSucceeded
renderMigrationCommandText outcome
@?= Text.unlines
[ "1. accounts depends=[] migrations=1",
"2. billing depends=[accounts] migrations=1"
]
testListFilter :: Assertion
testListFilter = do
let billing = expectRight (componentName "billing")
outcome <-
runMigrationCommand
fixtureEnvironment
(List (ListOptions (InspectionOptions (Just billing) Nothing) textOutput))
exitClass outcome @?= ExitSucceeded
let rendered = renderMigrationCommandText outcome
assertBool "billing migration is present" ("billing/0001" `Text.isInfixOf` rendered)
assertBool "accounts migration is filtered out" (not ("accounts/0001" `Text.isInfixOf` rendered))
assertBool "checksums are lowercase hexadecimal" (Text.all validOutputCharacter rendered)
where
validOutputCharacter character = not (character >= 'A' && character <= 'F')
testUnknownInspectionFilters :: Assertion
testUnknownInspectionFilters = do
let unknownComponent = expectRight (componentName "missing")
unknownMigration = expectRight (migrationName "missing")
accounts = expectRight (componentName "accounts")
commands =
[ Plan (PlanOptions (InspectionOptions (Just unknownComponent) Nothing) textOutput),
List (ListOptions (InspectionOptions (Just unknownComponent) Nothing) textOutput),
Status (StatusOptions (InspectionOptions (Just unknownComponent) Nothing) (ConnectionOptions Nothing) textOutput),
Verify (VerifyOptions (InspectionOptions (Just accounts) (Just unknownMigration)) (ConnectionOptions Nothing) textOutput)
]
outcomes <- mapM (runMigrationCommand fixtureEnvironment) commands
mapM_ assertUnknownFilter outcomes
where
assertUnknownFilter outcome = do
exitClass outcome @?= ExitUsageFailed
case payload outcome of
Left (CliInputError message) -> assertBool "diagnostic names the unknown filter" ("unknown" `Text.isInfixOf` message)
result -> assertFailure ("expected typed unknown-filter error, received: " <> show result)
testCheck :: Assertion
testCheck =
withFixtureDirectory "check" $ \directory -> do
let manifest = directory </> "manifest"
ByteString.writeFile (directory </> "0001.sql") "SELECT 1;\n"
ByteString.writeFile manifest "0001.sql\n"
outcome <-
runMigrationCommand
fixtureEnvironment
(Check (CheckOptions manifest textOutput))
exitClass outcome @?= ExitSucceeded
let rendered = renderMigrationCommandText outcome
assertBool "manifest entry is rendered" ("0001.sql checksum=" `Text.isPrefixOf` rendered)
Text.length (Text.takeWhileEnd (/= '=') (Text.strip rendered)) @?= 64
testNew :: Assertion
testNew =
withFixtureDirectory "new" $ \directory -> do
let manifest = directory </> "manifest"
ByteString.writeFile (directory </> "0001.sql") "SELECT 1;\n"
ByteString.writeFile manifest "0001.sql\n"
outcome <-
runMigrationCommand
fixtureEnvironment
(New (NewOptions manifest "add profile" Nothing textOutput))
exitClass outcome @?= ExitSucceeded
renderMigrationCommandText outcome @?= "created " <> Text.pack (directory </> "0002.sql") <> "\n"
ByteString.readFile (directory </> "0002.sql") >>= (@?= "-- add profile\n\n")
ByteString.readFile manifest >>= (@?= "0001.sql\n0002.sql\n")
testNewRejectsControlCharacters :: Assertion
testNewRejectsControlCharacters =
withFixtureDirectory "new-description" $ \directory -> do
let manifest = directory </> "manifest"
ByteString.writeFile (directory </> "0001.sql") "SELECT 1;\n"
ByteString.writeFile manifest "0001.sql\n"
outcome <-
runMigrationCommand
fixtureEnvironment
(New (NewOptions manifest "add profile\nDROP TABLE accounts" Nothing textOutput))
exitClass outcome @?= ExitUsageFailed
case payload outcome of
Left (CliInputError message) -> assertBool "diagnostic names the newline" ("\\n" `Text.isInfixOf` message)
result -> assertFailure ("expected typed description error, received: " <> show result)
Directory.doesFileExist (directory </> "0002.sql") >>= (@?= False)
ByteString.readFile manifest >>= (@?= "0001.sql\n")
testCheckIoFailure :: Assertion
testCheckIoFailure =
withFixtureDirectory "missing" $ \directory -> do
outcome <-
runMigrationCommand
fixtureEnvironment
(Check (CheckOptions (directory </> "missing-manifest") textOutput))
exitClass outcome @?= ExitExecutionFailed
case payload outcome of
Left (CliManifestError ManifestIoError {}) -> pure ()
result -> assertFailure ("expected typed manifest IO error, received: " <> show result)
testCheckValidationFailure :: Assertion
testCheckValidationFailure =
withFixtureDirectory "invalid-manifest" $ \directory -> do
let manifest = directory </> "manifest"
ByteString.writeFile manifest ""
outcome <-
runMigrationCommand
fixtureEnvironment
(Check (CheckOptions manifest textOutput))
exitClass outcome @?= ExitUsageFailed
case payload outcome of
Left (CliManifestError EmptyManifest) -> pure ()
result -> assertFailure ("expected typed manifest validation error, received: " <> show result)
testNewManifestIoFailure :: Assertion
testNewManifestIoFailure =
withFixtureDirectory "new-missing-manifest" $ \directory -> do
outcome <-
runMigrationCommand
fixtureEnvironment
(New (NewOptions (directory </> "missing-manifest") "add profile" Nothing textOutput))
exitClass outcome @?= ExitExecutionFailed
case payload outcome of
Left (CliAuthoringError (AuthoringManifestError ManifestIoError {})) -> pure ()
result -> assertFailure ("expected typed authoring manifest IO error, received: " <> show result)
testNewRequiresName :: Assertion
testNewRequiresName =
withFixtureDirectory "new-name" $ \directory -> do
let manifest = directory </> "manifest"
ByteString.writeFile (directory </> "baseline.sql") "SELECT 1;\n"
ByteString.writeFile manifest "baseline.sql\n"
outcome <-
runMigrationCommand
fixtureEnvironment
(New (NewOptions manifest "add profile" Nothing textOutput))
exitClass outcome @?= ExitUsageFailed
case payload outcome of
Left (CliAuthoringError ExplicitMigrationNameRequired) -> pure ()
result -> assertFailure ("expected explicit-name usage error, received: " <> show result)
fixtureEnvironment :: CliEnvironment
fixtureEnvironment = cliEnvironment (Settings.connectionString "") fixturePlan defaultRunOptions
fixturePlan :: MigrationPlan
fixturePlan =
expectRight
( migrationPlan
( expectRight
( migrationComponent
"accounts"
Set.empty
(expectRight (sqlMigration "0001" "SELECT 1") :| [])
)
:| [ expectRight
( migrationComponent
"billing"
(Set.singleton "accounts")
(expectRight (sqlMigration "0001" "SELECT 2") :| [])
)
]
)
)
noInspection :: InspectionOptions
noInspection = InspectionOptions Nothing Nothing
textOutput :: OutputOptions
textOutput = OutputOptions TextOutput
withFixtureDirectory :: String -> (FilePath -> IO value) -> IO value
withFixtureDirectory label = Exception.bracket create Directory.removePathForcibly
where
create = do
temporaryDirectory <- Directory.getTemporaryDirectory
let directory = temporaryDirectory </> ("pg-migrate-cli-" <> label)
Directory.removePathForcibly directory
Directory.createDirectory directory
pure directory
expectRight :: (Show error) => Either error value -> value
expectRight = either (error . show) id