packages feed

pg-migrate-cli-1.0.0.0: test/unit/Test/Parser.hs

module Test.Parser (tests) where

import Data.ByteString.Char8 qualified as ByteString
import Data.Foldable (traverse_)
import Data.List (isInfixOf)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Set qualified as Set
import Database.PostgreSQL.Migrate
import Database.PostgreSQL.Migrate.CLI
import Options.Applicative
import Paths_pg_migrate_cli qualified as Paths
import Test.Tasty
import Test.Tasty.HUnit

tests :: TestTree
tests =
  testGroup
    "parser"
    [ testCase "top-level help groups commands by operator intent" testGroupedHelp,
      testCase "verify help defines ledger verification narrowly" testVerifyHelp,
      testCase "up has no selective execution filters" testUpRejectsFilters,
      testCase "durations must be positive integer milliseconds" testDurationValidation,
      testCase "lock wait flags conflict" testConflictingWaitFlags,
      testCase "repair requires an operation and confirmation" testRepairConfirmation,
      testCase "repair validates the component/migration target" testRepairTarget,
      testCase "parsing does not imply database settings" testNoImplicitDatabaseSettings,
      testCase "plain completion derives all commands from the parser" testPlainCompletion,
      testCase "enriched completion derives execution flags and descriptions" testEnrichedCompletion,
      testGroup "help goldens" (uncurry goldenHelpCase <$> helpGoldens)
    ]

testGroupedHelp :: Assertion
testGroupedHelp = do
  helpText <- parseFailure ["--help"]
  assertContains "Inspection" helpText
  assertContains "plan" helpText
  assertContains "status" helpText
  assertContains "verify" helpText
  assertContains "list" helpText
  assertContains "check" helpText
  assertContains "Execution" helpText
  assertContains "up" helpText
  assertContains "repair" helpText
  assertContains "Authoring" helpText
  assertContains "new" helpText

testVerifyHelp :: Assertion
testVerifyHelp = do
  helpText <- parseFailure ["verify", "--help"]
  assertContains "declared plan" helpText
  assertContains "migration ledger" helpText
  assertContains "not live schema" helpText
  assertContains "snapshots" helpText
  assertContains "Connection" helpText
  assertContains "Output" helpText

testUpRejectsFilters :: Assertion
testUpRejectsFilters = do
  failure <- parseFailure ["up", "--component", "accounts"]
  assertContains "Invalid option" failure

testDurationValidation :: Assertion
testDurationValidation = do
  zeroFailure <- parseFailure ["up", "--lock-timeout", "0"]
  assertContains "positive integer" zeroFailure
  fractionalFailure <- parseFailure ["up", "--statement-timeout", "1.5"]
  assertContains "positive integer" fractionalFailure

testConflictingWaitFlags :: Assertion
testConflictingWaitFlags = do
  failure <- parseFailure ["up", "--no-wait", "--lock-timeout", "100"]
  assertContains "Invalid option" failure

testRepairConfirmation :: Assertion
testRepairConfirmation = do
  missingOperation <- parseFailure ["repair", "accounts/0001", "--reason", "inspected", "--confirm"]
  assertContains "mark-applied" missingOperation
  missingConfirmation <- parseFailure ["repair", "accounts/0001", "--mark-applied", "--reason", "inspected"]
  assertContains "confirm" missingConfirmation

testRepairTarget :: Assertion
testRepairTarget = do
  failure <- parseFailure ["repair", "accounts", "--mark-applied", "--reason", "inspected", "--confirm"]
  assertContains "COMPONENT/MIGRATION" failure

testNoImplicitDatabaseSettings :: Assertion
testNoImplicitDatabaseSettings =
  case parseSuccess ["status"] of
    Status StatusOptions {connection = ConnectionOptions {databaseSettings = Nothing}} -> pure ()
    result -> assertFailure ("expected status without database settings, received: " <> show result)

testPlainCompletion :: Assertion
testPlainCompletion = do
  completion <- completionOutput False 0 []
  traverse_ (\commandName -> assertContains commandName completion) expectedCommands

testEnrichedCompletion :: Assertion
testEnrichedCompletion = do
  commands <- completionOutput True 0 []
  assertContains "verify\tStrictly compare" commands
  flags <- completionOutput True 2 ["test-migrate", "up"]
  assertContains "--database-url\tPostgreSQL URI" flags
  assertContains "--lock-timeout\tWait at most" flags
  assertContains "--no-wait\tFail immediately" flags
  assertContains "--statement-timeout\tPositive PostgreSQL" flags
  assertContains "--json\tEmit JSON schema version 1" flags

completionOutput :: Bool -> Int -> [String] -> IO String
completionOutput enriched index wordsBeforeCursor =
  case execParserPure defaultPrefs commandInfo arguments of
    CompletionInvoked completion -> execCompletion completion "test-migrate"
    Failure failure -> assertFailure (fst (renderFailure failure "test-migrate"))
    Success parsedCommand -> assertFailure ("expected completion, received command: " <> show parsedCommand)
  where
    arguments =
      ["--bash-completion-enriched" | enriched]
        <> ["--bash-completion-index", show index]
        <> concatMap (\word -> ["--bash-completion-word", word]) wordsBeforeCursor

expectedCommands :: [String]
expectedCommands = ["plan", "status", "verify", "list", "check", "up", "repair", "new"]

goldenHelpCase :: FilePath -> [String] -> TestTree
goldenHelpCase name arguments =
  testCase name $ do
    goldenPath <- Paths.getDataFileName ("test/golden/help/" <> name <> ".txt")
    expected <- readFile goldenPath
    actual <- parseFailure (arguments <> ["--help"])
    normalizeHelp actual @?= expected

normalizeHelp :: String -> String
normalizeHelp = unlines . fmap (reverse . dropWhile (== ' ') . reverse) . lines

helpGoldens :: [(FilePath, [String])]
helpGoldens =
  [ ("top", []),
    ("plan", ["plan"]),
    ("status", ["status"]),
    ("verify", ["verify"]),
    ("list", ["list"]),
    ("check", ["check"]),
    ("up", ["up"]),
    ("repair", ["repair"]),
    ("new", ["new"])
  ]

commandInfo :: ParserInfo MigrationCommand
commandInfo =
  info
    (migrationCommandParser fixturePlan <**> helper)
    (fullDesc <> progDesc "Manage the fixture service migration plan")

parseSuccess :: [String] -> MigrationCommand
parseSuccess arguments =
  case execParserPure defaultPrefs commandInfo arguments of
    Success parsedCommand -> parsedCommand
    Failure failure ->
      let (message, _) = renderFailure failure "pg-migrate-cli-help-fixture"
       in error message
    CompletionInvoked _ -> error "unexpected completion"

parseFailure :: [String] -> IO String
parseFailure arguments =
  case execParserPure defaultPrefs commandInfo arguments of
    Failure failure -> pure (fst (renderFailure failure "pg-migrate-cli-help-fixture"))
    Success parsedCommand -> assertFailure ("expected parser failure, received: " <> show parsedCommand)
    CompletionInvoked _ -> assertFailure "expected parser failure, received completion"

assertContains :: String -> String -> Assertion
assertContains needle haystack =
  assertBool ("expected output to contain " <> show needle <> ", received:\n" <> haystack) (needle `isInfixOf` haystack)

fixturePlan :: MigrationPlan
fixturePlan =
  expectRight
    ( migrationPlan
        ( expectRight
            ( migrationComponent
                "accounts"
                Set.empty
                (expectRight (sqlMigration "0001" (ByteString.pack "SELECT 1")) :| [])
            )
            :| []
        )
    )

expectRight :: (Show error) => Either error value -> value
expectRight = either (error . show) id