packages feed

pg-migrate-cli-1.1.0.0: test/integration/Main.hs

module Main (main) where

import Control.Exception qualified as Exception
import Data.ByteString (ByteString)
import Data.IORef qualified as IORef
import Data.Int (Int64)
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 Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Settings
import Hasql.Session qualified as Session
import System.Environment (lookupEnv)
import Test.Tasty
import Test.Tasty.HUnit

main :: IO ()
main = do
  maybeConnectionString <- lookupEnv "PG_CONNECTION_STRING"
  case maybeConnectionString of
    Nothing ->
      putStrLn
        "pg-migrate-cli integration tests skipped: PG_CONNECTION_STRING is not set"
    Just connectionString ->
      defaultMain (tests (Settings.connectionString (Text.pack connectionString)))

tests :: Settings.Settings -> TestTree
tests settings =
  testGroup
    "pg-migrate CLI PostgreSQL"
    [ testCase "status verify and full up preserve their contracts" (testCommandLifecycle settings),
      testCase "confirmed repair dispatches through the shared lifecycle" (testRepairLifecycle settings),
      testCase "filtered verification keeps full-report exit classification" (testFilteredVerification settings)
    ]

testCommandLifecycle :: Settings.Settings -> Assertion
testCommandLifecycle settings =
  withCleanLedger settings "pgmigrate_cli_commands" 0x70676D636C690001 $ \options -> do
    observedEvents <- IORef.newIORef []
    let configuredOptions =
          withEventHandler (IORef.modifyIORef' observedEvents . (:)) $
            withStatementTimeout (Just 5) $
              withLockWait NoWait options
        environment = cliEnvironment settings commandPlan configuredOptions

    statusBefore <- runMigrationCommand environment statusCommand
    exitClass statusBefore @?= ExitSucceeded
    case payload statusBefore of
      Right (StatusPayload (StatusReport issues applied pending unknown)) -> do
        issues @?= []
        applied @?= []
        length pending @?= 1
        unknown @?= []
      other -> assertFailure ("unexpected status outcome: " <> show other)

    verifyBefore <- runMigrationCommand environment verifyCommand
    exitClass verifyBefore @?= ExitVerificationFailed
    case payload verifyBefore of
      Right (VerifyPayload (VerificationReport issues _ pending _)) -> do
        length issues @?= 1
        length pending @?= 1
      other -> assertFailure ("unexpected pre-run verify outcome: " <> show other)

    firstUp <- runMigrationCommand environment upCommand
    exitClass firstUp @?= ExitSucceeded
    assertSingleOutcome AppliedNow firstUp
    events <- IORef.readIORef observedEvents
    assertBool "application lock-wait setting survives absent CLI flags" (LockWaitStarted NoWait `elem` events)

    verifyAfter <- runMigrationCommand environment verifyCommand
    exitClass verifyAfter @?= ExitSucceeded
    case payload verifyAfter of
      Right (VerifyPayload (VerificationReport issues applied pending unknown)) -> do
        issues @?= []
        length applied @?= 1
        pending @?= []
        unknown @?= []
      other -> assertFailure ("unexpected post-run verify outcome: " <> show other)

    secondUp <- runMigrationCommand environment upCommand
    exitClass secondUp @?= ExitSucceeded
    assertSingleOutcome AlreadyApplied secondUp

testRepairLifecycle :: Settings.Settings -> Assertion
testRepairLifecycle settings =
  withCleanLedger settings "pgmigrate_cli_repair" 0x70676D636C690002 $ \options -> do
    let environment = cliEnvironment settings failingPlan options

    failedUp <- runMigrationCommand environment upCommand
    exitClass failedUp @?= ExitExecutionFailed

    repaired <-
      runMigrationCommand
        environment
        ( Repair
            ( RepairOptions
                failingMigrationId
                MarkApplied
                "operator inspected the database result"
                Confirmed
                noConnectionOverride
                defaultExecution
                jsonOutput
            )
        )
    exitClass repaired @?= ExitSucceeded
    case payload repaired of
      Right (RepairPayload (RepairReport identifier MarkApplied Failed Applied [])) ->
        identifier @?= failingMigrationId
      other -> assertFailure ("unexpected repair outcome: " <> show other)

    verified <- runMigrationCommand environment verifyCommand
    exitClass verified @?= ExitSucceeded

testFilteredVerification :: Settings.Settings -> Assertion
testFilteredVerification settings =
  withCleanLedger settings "pgmigrate_cli_filters" 0x70676D636C690003 $ \options -> do
    let originalEnvironment = cliEnvironment settings (filteredPlan "SELECT 2") options
        changedEnvironment = cliEnvironment settings (filteredPlan "SELECT 3") options
        selectedInspection = InspectionOptions (Just filterAccounts) Nothing
        selectedVerification = Verify (VerifyOptions selectedInspection noConnectionOverride jsonOutput)

    applied <- runMigrationCommand originalEnvironment upCommand
    exitClass applied @?= ExitSucceeded

    filtered <- runMigrationCommand changedEnvironment selectedVerification
    exitClass filtered @?= ExitVerificationFailed
    case payload filtered of
      Right (VerifyPayload (VerificationReport issues appliedMigrations pending unknown)) -> do
        issues @?= []
        appliedMigrations @?= [filterAccountsMigration]
        pending @?= []
        unknown @?= []
      other -> assertFailure ("unexpected filtered verification outcome: " <> show other)

assertSingleOutcome :: MigrationOutcome -> CliOutcome -> Assertion
assertSingleOutcome expected outcome =
  case payload outcome of
    Right (UpPayload (MigrationReport _ _ (MigrationResult _ actual _ :| []) [])) ->
      actual @?= expected
    other -> assertFailure ("unexpected up outcome: " <> show other)

statusCommand :: MigrationCommand
statusCommand = Status (StatusOptions noInspection noConnectionOverride jsonOutput)

verifyCommand :: MigrationCommand
verifyCommand = Verify (VerifyOptions noInspection noConnectionOverride jsonOutput)

upCommand :: MigrationCommand
upCommand = Up (UpOptions noConnectionOverride defaultExecution jsonOutput)

noInspection :: InspectionOptions
noInspection = InspectionOptions Nothing Nothing

noConnectionOverride :: ConnectionOptions
noConnectionOverride = ConnectionOptions Nothing

defaultExecution :: ExecutionOptions
defaultExecution = ExecutionOptions Nothing Nothing

jsonOutput :: OutputOptions
jsonOutput = OutputOptions JsonOutput

commandPlan :: MigrationPlan
commandPlan =
  expectRight
    ( migrationPlan
        ( expectRight
            ( migrationComponent
                "cli-commands"
                Set.empty
                ( expectRight
                    ( sqlMigration
                        "0001"
                        """
                        DO $$
                        BEGIN
                          IF current_setting('statement_timeout') <> '5s' THEN
                            RAISE EXCEPTION 'application statement timeout was overridden';
                          END IF;
                        END
                        $$;
                        CREATE TABLE pgmigrate_cli_commands.cli_command_probe (id bigint PRIMARY KEY)
                        """
                    )
                    :| []
                )
            )
            :| []
        )
    )

filteredPlan :: ByteString -> MigrationPlan
filteredPlan billingSql =
  expectRight
    ( migrationPlan
        ( expectRight
            ( migrationComponent
                "filter-accounts"
                Set.empty
                (expectRight (sqlMigration "0001" "SELECT 1") :| [])
            )
            :| [ expectRight
                   ( migrationComponent
                       "filter-billing"
                       (Set.singleton "filter-accounts")
                       (expectRight (sqlMigration "0001" billingSql) :| [])
                   )
               ]
        )
    )

filterAccounts :: ComponentName
filterAccounts = expectRight (componentName "filter-accounts")

filterAccountsMigration :: MigrationId
filterAccountsMigration = expectRight (migrationId "filter-accounts" "0001")

failingPlan :: MigrationPlan
failingPlan =
  expectRight
    ( migrationPlan
        ( expectRight
            ( migrationComponent
                "cli-repair"
                Set.empty
                (failingMigration :| [])
            )
            :| []
        )
    )

failingMigration :: Migration
failingMigration =
  expectRight
    ( sessionMigration
        "0001"
        (migrationFingerprint "cli-repair-v1")
        (Session.script "SELECT * FROM pgmigrate_cli_deliberately_missing_table")
    )

failingMigrationId :: MigrationId
failingMigrationId = expectRight (migrationId "cli-repair" "0001")

withCleanLedger ::
  Settings.Settings ->
  Text.Text ->
  Int64 ->
  (RunOptions -> IO value) ->
  IO value
withCleanLedger settings schemaName lockKey action = do
  let config = expectRight (ledgerConfig schemaName lockKey)
  Exception.bracket_
    (dropSchema settings schemaName)
    (dropSchema settings schemaName)
    (action (withLedger config defaultRunOptions))

dropSchema :: Settings.Settings -> Text.Text -> IO ()
dropSchema settings schemaName = do
  acquired <- Connection.acquire settings
  case acquired of
    Left connectionError -> assertFailure ("could not acquire integration connection: " <> show connectionError)
    Right connection ->
      Exception.finally
        ( do
            result <- Connection.use connection (Session.script ("DROP SCHEMA IF EXISTS " <> schemaName <> " CASCADE"))
            case result of
              Left sessionError -> assertFailure ("could not clean integration schema: " <> show sessionError)
              Right () -> pure ()
        )
        (Connection.release connection)

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