3 # Test suite for pg_scribe --restore command
6 # - Creates temporary test databases
7 # - Tests various --restore scenarios
8 # - Verifies expected outcomes
9 # - Cleans up all resources
14 # Colors for test output
19 NC='\033[0m' # No Color
22 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
23 PG_SCRIBE="$SCRIPT_DIR/scripts/pg_scribe"
24 TEST_DIR="/tmp/pg_scribe_test_restore_$$"
25 TEST_DB_PREFIX="pg_scribe_restore_test_$$"
26 PGUSER="${PGUSER:-postgres}"
34 DATABASES_TO_CLEANUP=()
42 echo -e "${BLUE}TEST:${NC} $*"
46 echo -e "${GREEN}PASS:${NC} $*"
51 echo -e "${RED}FAIL:${NC} $*"
56 echo -e "${YELLOW}INFO:${NC} $*"
66 psql -U "$PGUSER" -d "$dbname" -tAq "$@"
72 run_psql "$dbname" -c "$query" 2>/dev/null || true
77 log_info "Creating test database: $dbname"
80 psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
83 psql -U "$PGUSER" -d postgres -c "CREATE DATABASE $dbname;" &>/dev/null
85 DATABASES_TO_CLEANUP+=("$dbname")
88 # shellcheck disable=SC2317 # Function called from cleanup trap handler
91 log_info "Dropping test database: $dbname"
93 # Terminate connections
94 psql -U "$PGUSER" -d postgres -c "
95 SELECT pg_terminate_backend(pid)
97 WHERE datname = '$dbname' AND pid <> pg_backend_pid();
101 psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
104 # shellcheck disable=SC2317 # Function called from cleanup trap handler
105 drop_replication_slot() {
108 log_info "Dropping replication slot: $slot"
110 # Check if slot exists
112 exists=$(query_db "$dbname" "
113 SELECT 1 FROM pg_replication_slots WHERE slot_name = '$slot';
116 if [[ -n "$exists" ]]; then
118 query_db "$dbname" "SELECT pg_drop_replication_slot('$slot');" || true
122 create_table_with_pk() {
126 CREATE TABLE $table (
127 id SERIAL PRIMARY KEY,
129 created_at TIMESTAMP DEFAULT now()
134 initialize_backup() {
136 local backup_dir="$2"
140 "$PG_SCRIBE" --init -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null
141 SLOTS_TO_CLEANUP+=("$dbname:$slot")
148 test_basic_restore_from_base_only() {
150 log_test "Basic restore from base backup only"
152 local source_db="${TEST_DB_PREFIX}_source_basic"
153 local restore_db="${TEST_DB_PREFIX}_restore_basic"
154 local slot="test_slot_basic"
155 local backup_dir="$TEST_DIR/basic"
157 # Setup source database
158 create_test_db "$source_db"
159 create_table_with_pk "$source_db" "users"
160 query_db "$source_db" "INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Charlie');"
163 mkdir -p "$backup_dir"
164 initialize_backup "$source_db" "$backup_dir" "$slot"
166 # Restore to new database with --create
167 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null; then
168 DATABASES_TO_CLEANUP+=("$restore_db")
172 count=$(query_db "$restore_db" "SELECT COUNT(*) FROM users;")
173 if [[ "$count" -ne 3 ]]; then
174 log_fail "Expected 3 rows, got $count"
178 # Verify specific data (psql returns one value per line)
180 names=$(query_db "$restore_db" "SELECT name FROM users ORDER BY id;")
181 local expected_names=$'Alice\nBob\nCharlie'
182 if [[ "$names" != "$expected_names" ]]; then
183 log_fail "Data mismatch: expected '$expected_names', got '$names'"
187 log_pass "Basic restore successful"
190 log_fail "Restore command failed"
195 test_restore_with_differentials() {
197 log_test "Restore with base + sealed differentials"
199 local source_db="${TEST_DB_PREFIX}_source_diff"
200 local restore_db="${TEST_DB_PREFIX}_restore_diff"
201 local slot="test_slot_diff"
202 local backup_dir="$TEST_DIR/differentials"
204 # Setup source database
205 create_test_db "$source_db"
206 create_table_with_pk "$source_db" "orders"
207 query_db "$source_db" "INSERT INTO orders (name) VALUES ('Order1'), ('Order2');"
209 # Initialize backup (creates first chain)
210 mkdir -p "$backup_dir"
211 initialize_backup "$source_db" "$backup_dir" "$slot"
213 # Start streaming to latest chain
214 "$PG_SCRIBE" --start -d "$source_db" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null &
215 local pg_scribe_pid=$!
217 # Wait for streaming to start
221 query_db "$source_db" "INSERT INTO orders (name) VALUES ('Order3');"
222 query_db "$source_db" "UPDATE orders SET name = 'Order1_Updated' WHERE name = 'Order1';"
223 query_db "$source_db" "DELETE FROM orders WHERE name = 'Order2';"
225 # Wait for changes to be captured
228 # Rotate differential to seal it
229 "$PG_SCRIBE" --rotate-diff -f "$backup_dir" -U "$PGUSER" &>/dev/null || true
233 kill -TERM "$pg_scribe_pid" 2>/dev/null || true
234 wait "$pg_scribe_pid" 2>/dev/null || true
238 chain_dir=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | sort | tail -1)
240 # Verify sealed differential exists
242 diff_count=$(find "$chain_dir" -name 'diff-*.sql' 2>/dev/null | wc -l)
243 if [[ "$diff_count" -eq 0 ]]; then
244 log_fail "No sealed differentials found"
248 # Restore to new database
249 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null; then
250 DATABASES_TO_CLEANUP+=("$restore_db")
252 # Verify final state (should have Order1_Updated and Order3, not Order2)
254 count=$(query_db "$restore_db" "SELECT COUNT(*) FROM orders;")
255 if [[ "$count" -ne 2 ]]; then
256 log_fail "Expected 2 rows after differential restore, got $count"
260 # Verify Order1 was updated
262 order1_name=$(query_db "$restore_db" "SELECT name FROM orders WHERE id = 1;")
263 if [[ "$order1_name" != "Order1_Updated" ]]; then
264 log_fail "UPDATE not applied: expected 'Order1_Updated', got '$order1_name'"
268 # Verify Order3 exists
270 order3_exists=$(query_db "$restore_db" "SELECT COUNT(*) FROM orders WHERE name = 'Order3';")
271 if [[ "$order3_exists" -ne 1 ]]; then
272 log_fail "INSERT not applied: Order3 not found"
276 # Verify Order2 was deleted
278 order2_exists=$(query_db "$restore_db" "SELECT COUNT(*) FROM orders WHERE name = 'Order2';")
279 if [[ "$order2_exists" -ne 0 ]]; then
280 log_fail "DELETE not applied: Order2 still exists"
284 log_pass "Restore with differentials successful"
287 log_fail "Restore command failed"
292 test_restore_with_ddl_changes() {
294 log_test "Restore with DDL changes in differential"
296 local source_db="${TEST_DB_PREFIX}_source_ddl"
297 local restore_db="${TEST_DB_PREFIX}_restore_ddl"
298 local slot="test_slot_ddl"
299 local backup_dir="$TEST_DIR/ddl"
301 # Setup source database
302 create_test_db "$source_db"
303 create_table_with_pk "$source_db" "products"
304 query_db "$source_db" "INSERT INTO products (name) VALUES ('Widget');"
306 # Initialize backup (creates chain)
307 mkdir -p "$backup_dir"
308 initialize_backup "$source_db" "$backup_dir" "$slot"
311 "$PG_SCRIBE" --start -d "$source_db" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null &
312 local pg_scribe_pid=$!
314 # Wait for streaming to start
317 # Add a column (DDL change)
318 query_db "$source_db" "ALTER TABLE products ADD COLUMN price NUMERIC(10,2);"
320 # Insert data using new column
321 query_db "$source_db" "INSERT INTO products (name, price) VALUES ('Gadget', 19.99);"
323 # Wait for changes to be captured
326 # Rotate to seal differential
327 "$PG_SCRIBE" --rotate-diff -f "$backup_dir" -U "$PGUSER" &>/dev/null || true
331 kill -TERM "$pg_scribe_pid" 2>/dev/null || true
332 wait "$pg_scribe_pid" 2>/dev/null || true
334 # Restore to new database
335 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null; then
336 DATABASES_TO_CLEANUP+=("$restore_db")
338 # Verify new column exists
339 local has_price_column
340 has_price_column=$(query_db "$restore_db" "
341 SELECT COUNT(*) FROM information_schema.columns
342 WHERE table_name = 'products' AND column_name = 'price';
344 if [[ "$has_price_column" -ne 1 ]]; then
345 log_fail "DDL not applied: price column not found"
349 # Verify data with new column
351 gadget_price=$(query_db "$restore_db" "SELECT price FROM products WHERE name = 'Gadget';")
352 if [[ "$gadget_price" != "19.99" ]]; then
353 log_fail "Data with new column not correct: expected '19.99', got '$gadget_price'"
357 log_pass "Restore with DDL changes successful"
360 log_fail "Restore command failed"
365 test_restore_sequence_synchronization() {
367 log_test "Restore with sequence synchronization"
369 local source_db="${TEST_DB_PREFIX}_source_seq"
370 local restore_db="${TEST_DB_PREFIX}_restore_seq"
371 local slot="test_slot_seq"
372 local backup_dir="$TEST_DIR/sequence"
374 # Setup source database
375 create_test_db "$source_db"
376 create_table_with_pk "$source_db" "items"
377 query_db "$source_db" "INSERT INTO items (name) VALUES ('Item1'), ('Item2'), ('Item3');"
379 # Initialize backup (creates first chain)
380 mkdir -p "$backup_dir"
381 initialize_backup "$source_db" "$backup_dir" "$slot"
383 # Add more data to advance sequence
384 query_db "$source_db" "INSERT INTO items (name) VALUES ('Item4'), ('Item5');"
386 # Start streaming to capture incremental changes
387 "$PG_SCRIBE" --start -d "$source_db" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null &
388 local pg_scribe_pid=$!
391 # Rotate differential to seal it
392 "$PG_SCRIBE" --rotate-diff -f "$backup_dir" -U "$PGUSER" &>/dev/null || true
396 kill -TERM "$pg_scribe_pid" 2>/dev/null || true
397 wait "$pg_scribe_pid" 2>/dev/null || true
399 # Restore to new database
400 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null; then
401 DATABASES_TO_CLEANUP+=("$restore_db")
403 # Get current sequence value
405 seq_val=$(query_db "$restore_db" "SELECT last_value FROM items_id_seq;")
407 # Should be at least 5 (we inserted 5 items total)
408 if [[ "$seq_val" -lt 5 ]]; then
409 log_fail "Sequence not synchronized: expected >= 5, got $seq_val"
413 # Try inserting new row - should get ID 6
414 query_db "$restore_db" "INSERT INTO items (name) VALUES ('Item6');"
416 new_id=$(query_db "$restore_db" "SELECT id FROM items WHERE name = 'Item6';")
417 if [[ "$new_id" -ne 6 ]]; then
418 log_fail "Next sequence value incorrect: expected 6, got $new_id"
422 log_pass "Sequence synchronization successful"
425 log_fail "Restore command failed"
430 test_restore_no_sync_sequences() {
432 log_test "Restore with --no-sync-sequences flag"
434 local source_db="${TEST_DB_PREFIX}_source_noseq"
435 local restore_db="${TEST_DB_PREFIX}_restore_noseq"
436 local slot="test_slot_noseq"
437 local backup_dir="$TEST_DIR/noseq"
439 # Setup source database
440 create_test_db "$source_db"
441 create_table_with_pk "$source_db" "records"
442 query_db "$source_db" "INSERT INTO records (name) VALUES ('Rec1'), ('Rec2');"
444 # Initialize backup (creates chain)
445 mkdir -p "$backup_dir"
446 initialize_backup "$source_db" "$backup_dir" "$slot"
448 # Restore with --no-sync-sequences
449 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" --no-sync-sequences &>/dev/null; then
450 DATABASES_TO_CLEANUP+=("$restore_db")
452 # Sequence should be at the value from pg_dump (2)
454 seq_val=$(query_db "$restore_db" "SELECT last_value FROM records_id_seq;")
456 # With --no-sync-sequences, sequence value is from pg_dump
457 # It should match what's in the backup
458 if [[ "$seq_val" -lt 1 ]]; then
459 log_fail "Sequence value unexpectedly low: $seq_val"
463 log_pass "Restore with --no-sync-sequences successful"
466 log_fail "Restore command failed"
471 test_restore_to_existing_database() {
473 log_test "Restore to existing database (without --create)"
475 local source_db="${TEST_DB_PREFIX}_source_exist"
476 local restore_db="${TEST_DB_PREFIX}_restore_exist"
477 local slot="test_slot_exist"
478 local backup_dir="$TEST_DIR/existing"
480 # Setup source database
481 create_test_db "$source_db"
482 create_table_with_pk "$source_db" "data"
483 query_db "$source_db" "INSERT INTO data (name) VALUES ('Test1');"
486 mkdir -p "$backup_dir"
487 initialize_backup "$source_db" "$backup_dir" "$slot"
489 # Pre-create target database
490 create_test_db "$restore_db"
492 # Restore without --create
493 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -U "$PGUSER" &>/dev/null; then
496 count=$(query_db "$restore_db" "SELECT COUNT(*) FROM data;")
497 if [[ "$count" -ne 1 ]]; then
498 log_fail "Expected 1 row, got $count"
502 log_pass "Restore to existing database successful"
505 log_fail "Restore command failed"
510 test_restore_fails_if_db_exists_with_create() {
512 log_test "Restore fails if database exists with --create"
514 local source_db="${TEST_DB_PREFIX}_source_exists"
515 local restore_db="${TEST_DB_PREFIX}_restore_exists"
516 local slot="test_slot_exists"
517 local backup_dir="$TEST_DIR/exists"
519 # Setup source database
520 create_test_db "$source_db"
521 create_table_with_pk "$source_db" "test"
522 query_db "$source_db" "INSERT INTO test (name) VALUES ('Test');"
525 mkdir -p "$backup_dir"
526 initialize_backup "$source_db" "$backup_dir" "$slot"
528 # Pre-create target database
529 create_test_db "$restore_db"
531 # Restore with --create should fail
533 "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null || exit_code=$?
535 if [[ $exit_code -eq 4 ]]; then
536 log_pass "Correctly failed when database exists with --create"
539 log_fail "Expected exit code 4, got $exit_code"
544 test_restore_missing_backup_directory() {
546 log_test "Restore fails with missing backup directory"
548 local restore_db="${TEST_DB_PREFIX}_restore_missing"
549 local backup_dir="$TEST_DIR/nonexistent"
551 # Try to restore from non-existent directory
553 "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -U "$PGUSER" &>/dev/null || exit_code=$?
555 if [[ $exit_code -eq 4 ]]; then
556 log_pass "Correctly failed with missing backup directory"
559 log_fail "Expected exit code 4, got $exit_code"
564 test_restore_specific_chain() {
566 log_test "Restore specific chain with --base-backup (chain ID)"
568 local source_db="${TEST_DB_PREFIX}_source_specific"
569 local restore_db="${TEST_DB_PREFIX}_restore_specific"
570 local slot="test_slot_specific"
571 local backup_dir="$TEST_DIR/specific"
573 # Setup source database
574 create_test_db "$source_db"
575 create_table_with_pk "$source_db" "entries"
576 query_db "$source_db" "INSERT INTO entries (name) VALUES ('Entry1');"
578 # Initialize backup (creates first chain)
579 mkdir -p "$backup_dir"
580 initialize_backup "$source_db" "$backup_dir" "$slot"
584 chain_dir=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | sort | tail -1)
586 if [[ -z "$chain_dir" ]]; then
587 log_fail "Chain directory not found"
592 chain_id=$(basename "$chain_dir" | sed 's/^chain-//')
594 # Restore using specific chain ID
595 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" --base-backup="$chain_id" -C -U "$PGUSER" &>/dev/null; then
596 DATABASES_TO_CLEANUP+=("$restore_db")
600 count=$(query_db "$restore_db" "SELECT COUNT(*) FROM entries;")
601 if [[ "$count" -ne 1 ]]; then
602 log_fail "Expected 1 row, got $count"
606 log_pass "Restore with specific chain successful"
609 log_fail "Restore command failed"
614 test_restore_multiple_tables() {
616 log_test "Restore multiple tables with relationships"
618 local source_db="${TEST_DB_PREFIX}_source_multi"
619 local restore_db="${TEST_DB_PREFIX}_restore_multi"
620 local slot="test_slot_multi"
621 local backup_dir="$TEST_DIR/multi"
623 # Setup source database with multiple related tables
624 create_test_db "$source_db"
625 query_db "$source_db" "
626 CREATE TABLE customers (
627 id SERIAL PRIMARY KEY,
630 CREATE TABLE orders (
631 id SERIAL PRIMARY KEY,
632 customer_id INTEGER REFERENCES customers(id),
633 product TEXT NOT NULL
636 query_db "$source_db" "INSERT INTO customers (name) VALUES ('Alice'), ('Bob');"
637 query_db "$source_db" "INSERT INTO orders (customer_id, product) VALUES (1, 'Widget'), (2, 'Gadget');"
640 mkdir -p "$backup_dir"
641 initialize_backup "$source_db" "$backup_dir" "$slot"
644 if "$PG_SCRIBE" --restore -d "$restore_db" -f "$backup_dir" -C -U "$PGUSER" &>/dev/null; then
645 DATABASES_TO_CLEANUP+=("$restore_db")
649 customer_count=$(query_db "$restore_db" "SELECT COUNT(*) FROM customers;")
650 if [[ "$customer_count" -ne 2 ]]; then
651 log_fail "Expected 2 customers, got $customer_count"
657 order_count=$(query_db "$restore_db" "SELECT COUNT(*) FROM orders;")
658 if [[ "$order_count" -ne 2 ]]; then
659 log_fail "Expected 2 orders, got $order_count"
663 # Verify foreign key relationship
665 alice_orders=$(query_db "$restore_db" "
666 SELECT o.product FROM orders o
667 JOIN customers c ON c.id = o.customer_id
668 WHERE c.name = 'Alice';
670 if [[ "$alice_orders" != "Widget" ]]; then
671 log_fail "Foreign key relationship not preserved"
675 log_pass "Multiple table restore successful"
678 log_fail "Restore command failed"
687 # shellcheck disable=SC2317 # Function called via trap handler
689 log_info "Cleaning up test resources..."
691 # Stop any background pg_scribe processes
692 pkill -f "pg_scribe.*--start" 2>/dev/null || true
693 pkill -f "pg_recvlogical" 2>/dev/null || true
696 # Drop replication slots
697 for slot_info in "${SLOTS_TO_CLEANUP[@]}"; do
698 IFS=':' read -r dbname slot <<< "$slot_info"
699 drop_replication_slot "$dbname" "$slot" 2>/dev/null || true
703 for dbname in "${DATABASES_TO_CLEANUP[@]}"; do
704 drop_test_db "$dbname"
707 # Remove test directory
708 if [[ -d "$TEST_DIR" ]]; then
712 log_info "Cleanup complete"
720 echo "========================================"
721 echo "pg_scribe --restore Test Suite"
722 echo "========================================"
725 # Verify pg_scribe exists
726 if [[ ! -x "$PG_SCRIBE" ]]; then
727 echo "ERROR: pg_scribe not found or not executable: $PG_SCRIBE"
731 # Verify PostgreSQL is running
732 if ! psql -U "$PGUSER" -d postgres -c "SELECT 1;" &>/dev/null; then
733 echo "ERROR: Cannot connect to PostgreSQL"
737 # Verify wal_level is logical
739 wal_level=$(psql -U "$PGUSER" -d postgres -tAq -c "SHOW wal_level;")
740 if [[ "$wal_level" != "logical" ]]; then
741 echo "ERROR: wal_level must be 'logical', currently: $wal_level"
742 echo "Update ~/.pgenv/pgsql/data/postgresql.conf and restart PostgreSQL"
746 # Verify wal2sql extension is available
747 if ! psql -U "$PGUSER" -d postgres -c "CREATE EXTENSION IF NOT EXISTS wal2sql;" &>/dev/null; then
748 echo "ERROR: wal2sql extension not available"
749 echo "Build and install: cd wal2sql && make && make install"
753 # Create test directory
756 # Set up cleanup trap
757 trap cleanup EXIT INT TERM
759 echo "Running tests..."
762 # Run all tests (use || true to prevent set -e from exiting)
763 test_basic_restore_from_base_only || true
764 test_restore_with_differentials || true
765 test_restore_with_ddl_changes || true
766 test_restore_specific_chain || true
767 test_restore_to_existing_database || true
768 test_restore_fails_if_db_exists_with_create || true
769 test_restore_missing_backup_directory || true
770 test_restore_multiple_tables || true
771 test_restore_sequence_synchronization || true
772 test_restore_no_sync_sequences || true
776 echo "========================================"
778 echo "========================================"
779 echo "Tests run: $TESTS_RUN"
780 echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
781 echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
784 if [[ $TESTS_FAILED -eq 0 ]]; then
785 echo -e "${GREEN}All tests passed!${NC}"
788 echo -e "${RED}Some tests failed!${NC}"