Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ name: Docker

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]

env:
REGISTRY: ghcr.io
Expand All @@ -29,7 +26,6 @@ jobs:
publish:
runs-on: ubuntu-latest
needs: smoke
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: write
Expand All @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
@@ -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"
44 changes: 44 additions & 0 deletions tests/helpers/common.bash
Original file line number Diff line number Diff line change
@@ -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;"
}
95 changes: 95 additions & 0 deletions tests/integration/sync.bats
Original file line number Diff line number Diff line change
@@ -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" ]
}
43 changes: 43 additions & 0 deletions tests/unit/cli.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
Loading
Loading