packages feed

pg-migrate-cli-1.1.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 "absent execution flags preserve application options" testAbsentExecutionOverrides,
      testCase "lock wait flags produce explicit overrides" testLockWaitOverrides,
      testCase "statement timeout flags produce explicit overrides" testStatementTimeoutOverrides,
      testCase "description rejects control characters" testDescriptionValidation,
      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 "check uses the same manifest flag as new" testCheckManifestFlag,
      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
  statementFailure <- parseFailure ["up", "--statement-timeout", "100", "--no-statement-timeout"]
  assertContains "Invalid option" statementFailure

testAbsentExecutionOverrides :: Assertion
testAbsentExecutionOverrides =
  parsedExecution [] @?= ExecutionOptions Nothing Nothing

testLockWaitOverrides :: Assertion
testLockWaitOverrides = do
  parsedExecution ["--no-wait"] @?= ExecutionOptions (Just NoWait) Nothing
  parsedExecution ["--lock-timeout", "250"] @?= ExecutionOptions (Just (WaitFor 0.25)) Nothing
  parsedExecution ["--wait"] @?= ExecutionOptions (Just WaitIndefinitely) Nothing

testStatementTimeoutOverrides :: Assertion
testStatementTimeoutOverrides = do
  parsedExecution ["--statement-timeout", "250"] @?= ExecutionOptions Nothing (Just (Just 0.25))
  parsedExecution ["--no-statement-timeout"] @?= ExecutionOptions Nothing (Just Nothing)

testDescriptionValidation :: Assertion
testDescriptionValidation = do
  failure <- parseFailure ["new", "--manifest", "manifest", "--description", "add profile\nDROP TABLE accounts"]
  assertContains "--description" failure
  assertContains "\\n" 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)

testCheckManifestFlag :: Assertion
testCheckManifestFlag = do
  case parseSuccess ["check", "--manifest", "migrations/manifest"] of
    Check CheckOptions {manifestPath = "migrations/manifest"} -> pure ()
    parsedCommand -> assertFailure ("expected check command, received: " <> show parsedCommand)
  positionalFailure <- parseFailure ["check", "migrations/manifest"]
  assertContains "--manifest" positionalFailure

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 "--wait\tWait indefinitely" flags
  assertContains "--statement-timeout\tSet a positive PostgreSQL" flags
  assertContains "--no-statement-timeout\tRun without" 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"

parsedExecution :: [String] -> ExecutionOptions
parsedExecution arguments =
  case parseSuccess ("up" : arguments) of
    Up UpOptions {execution} -> execution
    parsedCommand -> error ("expected up command, received: " <> show parsedCommand)

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