diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8ee75d8..d2b3762 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,10 +2,7 @@ name: Docker on: push: - branches: [main] tags: ["v*"] - pull_request: - branches: [main] env: REGISTRY: ghcr.io @@ -29,7 +26,6 @@ jobs: publish: runs-on: ubuntu-latest needs: smoke - if: github.event_name != 'pull_request' permissions: contents: read packages: write @@ -48,12 +44,10 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix= - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest - uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..48fe626 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,82 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: ShellCheck + run: | + shellcheck pgsync.sh install.sh completions/pgsync.bash scripts/test.sh + shellcheck tests/helpers/common.bash + + unit: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + + - name: Install Bats + run: | + git clone --depth 1 --branch v1.11.1 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + + - name: Unit tests + run: bats tests/unit + + integration: + runs-on: ubuntu-latest + needs: unit + services: + source: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d app" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + target: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U postgres -d app" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - name: Install Bats and PostgreSQL client + run: | + git clone --depth 1 --branch v1.11.1 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + sudo apt-get update + sudo apt-get install -y postgresql-client + + - name: Integration tests + env: + PGSYNC_SOURCE_URI: postgresql://postgres:postgres@localhost:5432/app + PGSYNC_TARGET_URI: postgresql://postgres:postgres@localhost:5433/app + run: bats tests/integration diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..816c7a1 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Run lint + unit tests locally. Set INTEGRATION=1 for Postgres integration tests. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if ! command -v shellcheck >/dev/null; then + echo "test: shellcheck not found" >&2 + exit 1 +fi + +shellcheck pgsync.sh install.sh completions/pgsync.bash scripts/test.sh +shellcheck tests/helpers/common.bash + +if ! command -v bats >/dev/null; then + echo "test: bats not found (install bats-core)" >&2 + exit 1 +fi + +echo "==> unit tests" +bats tests/unit + +if [[ "${INTEGRATION:-0}" == "1" ]]; then + if ! command -v psql >/dev/null; then + echo "test: psql not found (required for integration tests)" >&2 + exit 1 + fi + echo "==> integration tests" + bats tests/integration +fi + +echo "test: ok" diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..a89b2db --- /dev/null +++ b/tests/helpers/common.bash @@ -0,0 +1,44 @@ +# Shared helpers for bats tests. + +bats_require_minimum_version 1.5.0 + +pgsync_bin() { + printf '%s' "${BATS_TEST_DIRNAME}/../../pgsync.sh" +} + +require_psql() { + if ! command -v psql >/dev/null; then + skip "psql not available" + fi +} + +psql_source() { + psql -v ON_ERROR_STOP=1 "$PGSYNC_SOURCE_URI" "$@" +} + +psql_target() { + psql -v ON_ERROR_STOP=1 "$PGSYNC_TARGET_URI" "$@" +} + +reset_databases() { + psql_source -c 'DROP TABLE IF EXISTS pgsync_probe CASCADE;' >/dev/null + psql_target -c 'DROP TABLE IF EXISTS pgsync_probe CASCADE;' >/dev/null +} + +seed_source() { + psql_source -c ' + CREATE TABLE pgsync_probe ( + id serial PRIMARY KEY, + label text NOT NULL + ); + INSERT INTO pgsync_probe (label) VALUES ('\''alpha'\''); + ' >/dev/null +} + +target_row_count() { + psql_target -tAc 'SELECT count(*) FROM pgsync_probe;' +} + +target_has_table() { + psql_target -tAc "SELECT to_regclass('public.pgsync_probe') IS NOT NULL;" +} diff --git a/tests/integration/sync.bats b/tests/integration/sync.bats new file mode 100644 index 0000000..ef5e0e0 --- /dev/null +++ b/tests/integration/sync.bats @@ -0,0 +1,95 @@ +setup() { + load ../helpers/common + export PGSYNC="$(pgsync_bin)" + export PGSYNC_SOURCE_URI="${PGSYNC_SOURCE_URI:-postgresql://postgres:postgres@localhost:5432/app}" + export PGSYNC_TARGET_URI="${PGSYNC_TARGET_URI:-postgresql://postgres:postgres@localhost:5433/app}" + require_psql + reset_databases +} + +@test "full sync copies rows from source to target" { + seed_source + + run --separate-stderr "$PGSYNC" -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + [[ "$stderr" == *"pgsync: ok"* ]] + + [ "$(target_row_count)" = "1" ] + [ "$(psql_target -tAc "SELECT label FROM pgsync_probe LIMIT 1;")" = "alpha" ] +} + +@test "quiet mode suppresses success line" { + seed_source + + run --separate-stderr "$PGSYNC" -q -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + [[ "$stderr" != *"pgsync: ok"* ]] +} + +@test "verbose mode prints exec pipeline" { + seed_source + + run --separate-stderr "$PGSYNC" -v -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + [[ "$stderr" == *"pgsync: exec:"* ]] + [[ "$stderr" == *"pg_dump"* ]] +} + +@test "delete drops target public schema before restore" { + seed_source + psql_target -c ' + CREATE TABLE pgsync_probe (id int PRIMARY KEY, label text); + INSERT INTO pgsync_probe VALUES (99, '\''stale'\''); + ' >/dev/null + + run --separate-stderr "$PGSYNC" -q --delete -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + + [ "$(target_row_count)" = "1" ] + [ "$(psql_target -tAc "SELECT label FROM pgsync_probe LIMIT 1;")" = "alpha" ] +} + +@test "schema-only sync creates table without copying rows" { + seed_source + + run --separate-stderr "$PGSYNC" -q --schema-only -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + + [ "$(target_has_table)" = "t" ] + [ "$(target_row_count)" = "0" ] +} + +@test "data-only sync copies rows into existing table" { + seed_source + psql_target -c ' + CREATE TABLE pgsync_probe ( + id serial PRIMARY KEY, + label text NOT NULL + ); + ' >/dev/null + + run --separate-stderr "$PGSYNC" -q --data-only --no-clean \ + -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + + [ "$(target_row_count)" = "1" ] +} + +@test "single-transaction wraps restore in one transaction" { + seed_source + + run --separate-stderr "$PGSYNC" -q --single-transaction \ + -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + [ "$(target_row_count)" = "1" ] +} + +@test "second sync is idempotent for unchanged source" { + seed_source + + "$PGSYNC" -q -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + + run --separate-stderr "$PGSYNC" -q -s "$PGSYNC_SOURCE_URI" -t "$PGSYNC_TARGET_URI" + [ "$status" -eq 0 ] + [ "$(target_row_count)" = "1" ] +} diff --git a/tests/unit/cli.bats b/tests/unit/cli.bats new file mode 100644 index 0000000..b759234 --- /dev/null +++ b/tests/unit/cli.bats @@ -0,0 +1,43 @@ +setup() { + load ../helpers/common + PGSYNC="$(pgsync_bin)" +} + +@test "version prints semver on stderr" { + run --separate-stderr "$PGSYNC" --version + [ "$status" -eq 0 ] + [ "$stderr" = "pgsync 2.0.0" ] + [ -z "$output" ] +} + +@test "help exits 1 and prints usage on stderr" { + run --separate-stderr "$PGSYNC" --help + [ "$status" -eq 1 ] + [[ "$stderr" == *"PostgreSQL logical sync"* ]] + [[ "$stderr" == *"--source URI"* ]] +} + +@test "missing source and target exits 1" { + run --separate-stderr "$PGSYNC" + [ "$status" -eq 1 ] + [[ "$stderr" == *"PostgreSQL logical sync"* ]] +} + +@test "missing target exits 1" { + run --separate-stderr "$PGSYNC" -s 'postgresql://a/db' + [ "$status" -eq 1 ] +} + +@test "unknown flag exits 1" { + run --separate-stderr "$PGSYNC" -s 'postgresql://a/db' -t 'postgresql://b/db' --nope + [ "$status" -eq 1 ] +} + +@test "pg_dump missing exits 1 with clear error" { + fake_bin="$(mktemp -d)" + ln -s "$(command -v bash)" "$fake_bin/bash" + run --separate-stderr env PATH="$fake_bin" "$PGSYNC" \ + -s 'postgresql://a/db' -t 'postgresql://b/db' + [ "$status" -eq 1 ] + [[ "$stderr" == *"pg_dump not found"* ]] +} diff --git a/tests/unit/dry_run.bats b/tests/unit/dry_run.bats new file mode 100644 index 0000000..b917178 --- /dev/null +++ b/tests/unit/dry_run.bats @@ -0,0 +1,66 @@ +setup() { + load ../helpers/common + PGSYNC="$(pgsync_bin)" + SRC='postgresql://user:pass@source:5432/app' + DST='postgresql://user:pass@target:5432/app' +} + +@test "dry-run prints default pg_dump pipeline" { + run --separate-stderr "$PGSYNC" -n -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"would run:"* ]] + [[ "$stderr" == *"pg_dump --no-owner --no-privileges --clean --if-exists"* ]] + [[ "$stderr" == *"psql -v ON_ERROR_STOP=1"* ]] + [[ "$stderr" == *"$SRC"* ]] + [[ "$stderr" == *"$DST"* ]] +} + +@test "dry-run with --delete mentions public schema reset" { + run --separate-stderr "$PGSYNC" -n --delete -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"drop+create public schema on target"* ]] +} + +@test "dry-run --schema-only adds pg_dump flag" { + run --separate-stderr "$PGSYNC" -n --schema-only -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"--schema-only"* ]] +} + +@test "dry-run --data-only adds pg_dump flag" { + run --separate-stderr "$PGSYNC" -n --data-only -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"--data-only"* ]] +} + +@test "dry-run --no-clean omits clean flags" { + run --separate-stderr "$PGSYNC" -n --no-clean -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" != *"--clean --if-exists"* ]] +} + +@test "dry-run --single-transaction adds psql -1" { + run --separate-stderr "$PGSYNC" -n --single-transaction -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"psql -v ON_ERROR_STOP=1 -1"* ]] +} + +@test "equals-form URIs are accepted" { + run --separate-stderr "$PGSYNC" -n \ + --source="$SRC" --target="$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"$SRC"* ]] + [[ "$stderr" == *"$DST"* ]] +} + +@test "schema-only and data-only together exit 1" { + run --separate-stderr "$PGSYNC" -s "$SRC" -t "$DST" --schema-only --data-only + [ "$status" -eq 1 ] + [[ "$stderr" == *"mutually exclusive"* ]] +} + +@test "dry-run verbose prints dry-run banner" { + run --separate-stderr "$PGSYNC" -n -v -s "$SRC" -t "$DST" + [ "$status" -eq 0 ] + [[ "$stderr" == *"pgsync: dry-run"* ]] +}