]> begriffs open source - pg_scribe/blob - tests/test_start.sh
WIP: convert to chain design
[pg_scribe] / tests / test_start.sh
1 #!/usr/bin/env bash
2 #
3 # Test suite for pg_scribe --start command
4 #
5 # This test suite:
6 # - Creates temporary test databases
7 # - Tests various --start scenarios
8 # - Verifies SQL capture (DML + DDL)
9 # - Tests signal handling
10 # - Cleans up all resources
11 #
12
13 set -euo pipefail
14
15 # Colors for test output
16 RED='\033[0;31m'
17 GREEN='\033[0;32m'
18 YELLOW='\033[0;33m'
19 BLUE='\033[0;34m'
20 NC='\033[0m' # No Color
21
22 # Test configuration
23 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
24 PG_SCRIBE="$SCRIPT_DIR/scripts/pg_scribe"
25 TEST_DIR="/tmp/pg_scribe_test_start_$$"
26 TEST_DB_PREFIX="pg_scribe_test_start_$$"
27 PGUSER="${PGUSER:-postgres}"
28
29 # Test counters
30 TESTS_RUN=0
31 TESTS_PASSED=0
32 TESTS_FAILED=0
33
34 # Cleanup tracking
35 DATABASES_TO_CLEANUP=()
36 PIDS_TO_CLEANUP=()
37
38 #
39 # Logging functions
40 #
41
42 log_test() {
43     echo -e "${BLUE}TEST:${NC} $*"
44 }
45
46 log_pass() {
47     echo -e "${GREEN}PASS:${NC} $*"
48     ((TESTS_PASSED++))
49 }
50
51 log_fail() {
52     echo -e "${RED}FAIL:${NC} $*"
53     ((TESTS_FAILED++))
54 }
55
56 log_info() {
57     echo -e "${YELLOW}INFO:${NC} $*"
58 }
59
60 #
61 # Helper functions
62 #
63
64 run_psql() {
65     local dbname="$1"
66     shift
67     psql -U "$PGUSER" -d "$dbname" -tAq "$@"
68 }
69
70 query_db() {
71     local dbname="$1"
72     local query="$2"
73     run_psql "$dbname" -c "$query" 2>/dev/null || true
74 }
75
76 create_test_db() {
77     local dbname="$1"
78     log_info "Creating test database: $dbname"
79
80     # Drop if exists
81     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
82
83     # Create database
84     psql -U "$PGUSER" -d postgres -c "CREATE DATABASE $dbname;" &>/dev/null
85
86     DATABASES_TO_CLEANUP+=("$dbname")
87 }
88
89 # shellcheck disable=SC2317  # Function called from cleanup trap handler
90 drop_test_db() {
91     local dbname="$1"
92     log_info "Dropping test database: $dbname"
93
94     # Terminate connections
95     psql -U "$PGUSER" -d postgres -c "
96         SELECT pg_terminate_backend(pid)
97         FROM pg_stat_activity
98         WHERE datname = '$dbname' AND pid <> pg_backend_pid();
99     " &>/dev/null || true
100
101     # Drop database
102     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
103 }
104
105 # shellcheck disable=SC2317  # Function called from cleanup trap handler
106 drop_replication_slot() {
107     local dbname="$1"
108     local slot="$2"
109     log_info "Dropping replication slot: $slot"
110
111     # Check if slot exists
112     local exists
113     exists=$(query_db "$dbname" "
114         SELECT 1 FROM pg_replication_slots WHERE slot_name = '$slot';
115     ")
116
117     if [[ -n "$exists" ]]; then
118         # Drop slot
119         query_db "$dbname" "SELECT pg_drop_replication_slot('$slot');" || true
120     fi
121 }
122
123 check_slot_exists() {
124     local dbname="$1"
125     local slot="$2"
126     local exists
127     exists=$(query_db "$dbname" "
128         SELECT 1 FROM pg_replication_slots WHERE slot_name = '$slot';
129     ")
130     [[ -n "$exists" ]]
131 }
132
133 create_table_with_pk() {
134     local dbname="$1"
135     local table="$2"
136     query_db "$dbname" "
137         CREATE TABLE $table (
138             id SERIAL PRIMARY KEY,
139             name TEXT,
140             created_at TIMESTAMP DEFAULT now()
141         );
142     "
143 }
144
145 initialize_backup_system() {
146     local dbname="$1"
147     local slot="$2"
148     local backup_dir="$3"
149
150     # Create backup directory
151     mkdir -p "$backup_dir"
152
153     # Initialize
154     "$PG_SCRIBE" --init -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null
155 }
156
157 #
158 # Test cases
159 #
160
161 test_start_without_init() {
162     ((TESTS_RUN++))
163     log_test "Start without initialization (should fail)"
164
165     local dbname="${TEST_DB_PREFIX}_noinit"
166     local slot="test_slot_noinit"
167     local backup_dir="$TEST_DIR/noinit"
168
169     # Setup - create db and backup dir but DON'T initialize
170     create_test_db "$dbname"
171     create_table_with_pk "$dbname" "users"
172     mkdir -p "$backup_dir"
173
174     # Try to start - should fail with exit code 4 (no chains found)
175     local exit_code=0
176     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null || exit_code=$?
177
178     if [[ $exit_code -eq 4 ]]; then
179         log_pass "Correctly failed with backup error (no chains)"
180         return 0
181     else
182         log_fail "Expected exit code 4, got $exit_code"
183         return 1
184     fi
185 }
186
187 test_start_basic_streaming() {
188     ((TESTS_RUN++))
189     log_test "Basic streaming with DML capture"
190
191     local dbname="${TEST_DB_PREFIX}_basic"
192     local slot="test_slot_basic"
193     local backup_dir="$TEST_DIR/basic"
194
195     # Setup
196     create_test_db "$dbname"
197     create_table_with_pk "$dbname" "users"
198     initialize_backup_system "$dbname" "$slot" "$backup_dir"
199
200     # Start streaming in background
201     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
202     local pg_scribe_pid=$!
203     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
204
205     # Give it a moment to start
206     sleep 1
207
208     # Make some changes
209     query_db "$dbname" "INSERT INTO users (name) VALUES ('Alice');"
210     query_db "$dbname" "INSERT INTO users (name) VALUES ('Bob');"
211     query_db "$dbname" "UPDATE users SET name = 'Alice Smith' WHERE name = 'Alice';"
212     query_db "$dbname" "DELETE FROM users WHERE name = 'Bob';"
213
214     # Give it time to flush (status-interval=1, fsync-interval=1)
215     sleep 2
216
217     # Stop streaming
218     kill -INT "$pg_scribe_pid" 2>/dev/null || true
219     sleep 1
220
221     # Force kill if still running
222     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
223         kill -9 "$pg_scribe_pid" 2>/dev/null || true
224     fi
225     wait "$pg_scribe_pid" 2>/dev/null || true
226
227     # Find active.sql in chain directory
228     local chain_dirs=("$backup_dir"/chain-*)
229     local output_file="${chain_dirs[0]}/active.sql"
230
231     # Verify output file exists
232     if [[ ! -f "$output_file" ]]; then
233         log_fail "Output file not created: $output_file"
234         return 1
235     fi
236
237     # Verify SQL content
238     if ! grep -q "INSERT INTO public.users" "$output_file"; then
239         log_fail "INSERT not captured"
240         return 1
241     fi
242
243     if ! grep -q "UPDATE public.users" "$output_file"; then
244         log_fail "UPDATE not captured"
245         return 1
246     fi
247
248     if ! grep -q "DELETE FROM public.users" "$output_file"; then
249         log_fail "DELETE not captured"
250         return 1
251     fi
252
253     # Verify transaction boundaries
254     if ! grep -q "BEGIN" "$output_file"; then
255         log_fail "BEGIN not captured"
256         return 1
257     fi
258
259     if ! grep -q "COMMIT" "$output_file"; then
260         log_fail "COMMIT not captured"
261         return 1
262     fi
263
264     log_pass "DML captured successfully"
265     return 0
266 }
267
268 test_start_ddl_capture() {
269     ((TESTS_RUN++))
270     log_test "DDL capture via event triggers"
271
272     local dbname="${TEST_DB_PREFIX}_ddl"
273     local slot="test_slot_ddl"
274     local backup_dir="$TEST_DIR/ddl"
275
276     # Setup
277     create_test_db "$dbname"
278     create_table_with_pk "$dbname" "users"
279     initialize_backup_system "$dbname" "$slot" "$backup_dir"
280
281     # Start streaming in background
282     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
283     local pg_scribe_pid=$!
284     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
285
286     # Give it a moment to start
287     sleep 1
288
289     # Make DDL changes
290     query_db "$dbname" "CREATE TABLE products (id SERIAL PRIMARY KEY, name TEXT);"
291     query_db "$dbname" "ALTER TABLE products ADD COLUMN price NUMERIC(10,2);"
292     query_db "$dbname" "DROP TABLE products;"
293
294     # Give it time to flush
295     sleep 2
296
297     # Stop streaming
298     kill -INT "$pg_scribe_pid" 2>/dev/null || true
299     sleep 1
300
301     # Force kill if still running
302     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
303         kill -9 "$pg_scribe_pid" 2>/dev/null || true
304     fi
305     wait "$pg_scribe_pid" 2>/dev/null || true
306
307     # Find active.sql in chain directory
308     local chain_dirs=("$backup_dir"/chain-*)
309     local output_file="${chain_dirs[0]}/active.sql"
310
311     # Verify DDL captured
312     if ! grep -qi "CREATE TABLE products" "$output_file"; then
313         log_fail "CREATE TABLE not captured"
314         return 1
315     fi
316
317     if ! grep -qi "ALTER TABLE products" "$output_file"; then
318         log_fail "ALTER TABLE not captured"
319         return 1
320     fi
321
322     if ! grep -qi "DROP TABLE products" "$output_file"; then
323         log_fail "DROP TABLE not captured"
324         return 1
325     fi
326
327     log_pass "DDL captured successfully"
328     return 0
329 }
330
331 test_start_truncate_capture() {
332     ((TESTS_RUN++))
333     log_test "TRUNCATE capture"
334
335     local dbname="${TEST_DB_PREFIX}_truncate"
336     local slot="test_slot_truncate"
337     local backup_dir="$TEST_DIR/truncate"
338
339     # Setup
340     create_test_db "$dbname"
341     create_table_with_pk "$dbname" "users"
342     initialize_backup_system "$dbname" "$slot" "$backup_dir"
343
344     # Start streaming in background
345     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
346     local pg_scribe_pid=$!
347     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
348
349     # Give it a moment to start
350     sleep 1
351
352     # Insert data and truncate
353     query_db "$dbname" "INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Charlie');"
354     query_db "$dbname" "TRUNCATE users;"
355
356     # Give it time to flush
357     sleep 2
358
359     # Stop streaming
360     kill -INT "$pg_scribe_pid" 2>/dev/null || true
361     sleep 1
362
363     # Force kill if still running
364     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
365         kill -9 "$pg_scribe_pid" 2>/dev/null || true
366     fi
367     wait "$pg_scribe_pid" 2>/dev/null || true
368
369     # Find active.sql in chain directory
370     local chain_dirs=("$backup_dir"/chain-*)
371     local output_file="${chain_dirs[0]}/active.sql"
372
373     # Verify TRUNCATE captured
374     if ! grep -qi "TRUNCATE.*users" "$output_file"; then
375         log_fail "TRUNCATE not captured"
376         return 1
377     fi
378
379     log_pass "TRUNCATE captured successfully"
380     return 0
381 }
382
383 test_start_signal_handling() {
384     ((TESTS_RUN++))
385     log_test "Signal handling (SIGTERM)"
386
387     local dbname="${TEST_DB_PREFIX}_signal"
388     local slot="test_slot_signal"
389     local backup_dir="$TEST_DIR/signal"
390
391     # Setup
392     create_test_db "$dbname"
393     create_table_with_pk "$dbname" "users"
394     initialize_backup_system "$dbname" "$slot" "$backup_dir"
395
396     # Start streaming in background
397     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
398     local pg_scribe_pid=$!
399     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
400
401     # Give it a moment to start
402     sleep 1
403
404     # Make a change
405     query_db "$dbname" "INSERT INTO users (name) VALUES ('Test');"
406
407     # Give it time to flush
408     sleep 1
409
410     # Send SIGTERM
411     kill -TERM "$pg_scribe_pid" 2>/dev/null || true
412
413     # Wait for graceful shutdown (with timeout)
414     local timeout=3
415     local count=0
416     while kill -0 "$pg_scribe_pid" 2>/dev/null && [[ $count -lt $timeout ]]; do
417         sleep 1
418         ((count++))
419     done
420
421     # Check if process stopped
422     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
423         log_fail "Process did not stop after SIGTERM"
424         kill -9 "$pg_scribe_pid" 2>/dev/null || true
425         return 1
426     fi
427
428     # Find active.sql in chain directory
429     local chain_dirs=("$backup_dir"/chain-*)
430     local output_file="${chain_dirs[0]}/active.sql"
431
432     # Verify output file was created and flushed
433     if [[ ! -f "$output_file" ]]; then
434         log_fail "Output file not created"
435         return 1
436     fi
437
438     if ! grep -q "INSERT INTO public.users" "$output_file"; then
439         log_fail "Data not flushed before shutdown"
440         return 1
441     fi
442
443     log_pass "SIGTERM handled gracefully"
444     return 0
445 }
446
447 test_start_interleaved_ddl_dml() {
448     ((TESTS_RUN++))
449     log_test "DDL and DML interleaving (chronological order)"
450
451     local dbname="${TEST_DB_PREFIX}_interleaved"
452     local slot="test_slot_interleaved"
453     local backup_dir="$TEST_DIR/interleaved"
454
455     # Setup
456     create_test_db "$dbname"
457     create_table_with_pk "$dbname" "users"
458     initialize_backup_system "$dbname" "$slot" "$backup_dir"
459
460     # Start streaming in background
461     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
462     local pg_scribe_pid=$!
463     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
464
465     # Give it a moment to start
466     sleep 1
467
468     # Make interleaved changes
469     query_db "$dbname" "INSERT INTO users (name) VALUES ('Alice');"
470     query_db "$dbname" "ALTER TABLE users ADD COLUMN email TEXT;"
471     query_db "$dbname" "UPDATE users SET email = 'alice@example.com' WHERE name = 'Alice';"
472     query_db "$dbname" "ALTER TABLE users DROP COLUMN created_at;"
473     query_db "$dbname" "INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com');"
474
475     # Give it time to flush
476     sleep 2
477
478     # Stop streaming
479     kill -INT "$pg_scribe_pid" 2>/dev/null || true
480     sleep 1
481
482     # Force kill if still running
483     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
484         kill -9 "$pg_scribe_pid" 2>/dev/null || true
485     fi
486     wait "$pg_scribe_pid" 2>/dev/null || true
487
488     # Find active.sql in chain directory
489     local chain_dirs=("$backup_dir"/chain-*)
490     local output_file="${chain_dirs[0]}/active.sql"
491
492     # Verify all operations captured
493     if ! grep -q "INSERT INTO public.users (id, name" "$output_file"; then
494         log_fail "First INSERT not captured"
495         return 1
496     fi
497
498     if ! grep -qi "ALTER TABLE.*ADD COLUMN email" "$output_file"; then
499         log_fail "ALTER TABLE ADD COLUMN not captured"
500         return 1
501     fi
502
503     if ! grep -q "UPDATE public.users.*email" "$output_file"; then
504         log_fail "UPDATE with new column not captured"
505         return 1
506     fi
507
508     if ! grep -qi "ALTER TABLE.*DROP COLUMN" "$output_file"; then
509         log_fail "ALTER TABLE DROP COLUMN not captured"
510         return 1
511     fi
512
513     # Verify chronological order by checking line numbers
514     local insert1_line
515     insert1_line=$(grep -n "INSERT INTO public.users (id, name" "$output_file" | head -1 | cut -d: -f1)
516     local alter_add_line
517     alter_add_line=$(grep -ni "ALTER TABLE.*ADD COLUMN email" "$output_file" | cut -d: -f1)
518     local update_line
519     update_line=$(grep -n "UPDATE public.users.*email" "$output_file" | cut -d: -f1)
520
521     if [[ "$insert1_line" -gt "$alter_add_line" ]]; then
522         log_fail "DDL/DML not in chronological order (INSERT after ALTER)"
523         return 1
524     fi
525
526     if [[ "$alter_add_line" -gt "$update_line" ]]; then
527         log_fail "DDL/DML not in chronological order (ALTER after UPDATE)"
528         return 1
529     fi
530
531     log_pass "DDL/DML interleaved in correct chronological order"
532     return 0
533 }
534
535 test_rotate_diff_basic() {
536     ((TESTS_RUN++))
537     log_test "--rotate-diff basic functionality"
538
539     local dbname="${TEST_DB_PREFIX}_rotate_basic"
540     local slot="test_slot_rotate_basic"
541     local backup_dir="$TEST_DIR/rotate_basic"
542
543     # Setup
544     create_test_db "$dbname"
545     create_table_with_pk "$dbname" "users"
546     initialize_backup_system "$dbname" "$slot" "$backup_dir"
547
548     # Start streaming in background
549     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
550     local pg_scribe_pid=$!
551     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
552
553     # Give it a moment to start
554     sleep 1
555
556     # Make changes before rotation
557     query_db "$dbname" "INSERT INTO users (name) VALUES ('Before Rotation');"
558
559     # Give it time to flush
560     sleep 2
561
562     # Find chain directory
563     local chain_dirs=("$backup_dir"/chain-*)
564     local chain_dir="${chain_dirs[0]}"
565
566     # Verify active.sql exists
567     if [[ ! -f "$chain_dir/active.sql" ]]; then
568         log_fail "active.sql not found before rotation"
569         return 1
570     fi
571
572     # Perform rotation
573     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null
574
575     # Give pg_recvlogical time to reopen the file
576     sleep 2
577
578     # Verify sealed differential was created
579     local diff_files=("$chain_dir"/diff-*.sql)
580     if [[ ! -f "${diff_files[0]}" ]]; then
581         log_fail "Sealed differential not created"
582         return 1
583     fi
584
585     # Verify new active.sql was created
586     if [[ ! -f "$chain_dir/active.sql" ]]; then
587         log_fail "New active.sql not created after rotation"
588         return 1
589     fi
590
591     # Make changes after rotation
592     query_db "$dbname" "INSERT INTO users (name) VALUES ('After Rotation');"
593
594     # Give it time to flush
595     sleep 2
596
597     # Stop streaming
598     kill -INT "$pg_scribe_pid" 2>/dev/null || true
599     sleep 1
600
601     # Force kill if still running
602     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
603         kill -9 "$pg_scribe_pid" 2>/dev/null || true
604     fi
605     wait "$pg_scribe_pid" 2>/dev/null || true
606
607     # Verify sealed differential has "Before Rotation" but not "After Rotation"
608     if ! grep -q "Before Rotation" "${diff_files[0]}"; then
609         log_fail "Sealed differential missing data before rotation"
610         return 1
611     fi
612
613     if grep -q "After Rotation" "${diff_files[0]}"; then
614         log_fail "Sealed differential should not contain data after rotation"
615         return 1
616     fi
617
618     # Verify new active.sql has "After Rotation" but not "Before Rotation"
619     if ! grep -q "After Rotation" "$chain_dir/active.sql"; then
620         log_fail "New active.sql missing data after rotation"
621         return 1
622     fi
623
624     if grep -q "Before Rotation" "$chain_dir/active.sql"; then
625         log_fail "New active.sql should not contain data before rotation"
626         return 1
627     fi
628
629     log_pass "--rotate-diff works correctly"
630     return 0
631 }
632
633 test_rotate_diff_no_active_process() {
634     ((TESTS_RUN++))
635     log_test "--rotate-diff without active process (should fail)"
636
637     local dbname="${TEST_DB_PREFIX}_rotate_noactive"
638     local slot="test_slot_rotate_noactive"
639     local backup_dir="$TEST_DIR/rotate_noactive"
640
641     # Setup - initialize but don't start streaming
642     create_test_db "$dbname"
643     create_table_with_pk "$dbname" "users"
644     initialize_backup_system "$dbname" "$slot" "$backup_dir"
645
646     # Try to rotate without active process - should fail
647     local exit_code=0
648     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null || exit_code=$?
649
650     if [[ $exit_code -ne 0 ]]; then
651         log_pass "Correctly failed when no active process"
652         return 0
653     else
654         log_fail "Should have failed with no active process"
655         return 1
656     fi
657 }
658
659 test_rotate_diff_stale_pidfile() {
660     ((TESTS_RUN++))
661     log_test "--rotate-diff with stale pidfile (should fail)"
662
663     local dbname="${TEST_DB_PREFIX}_rotate_stale"
664     local slot="test_slot_rotate_stale"
665     local backup_dir="$TEST_DIR/rotate_stale"
666
667     # Setup
668     create_test_db "$dbname"
669     create_table_with_pk "$dbname" "users"
670     initialize_backup_system "$dbname" "$slot" "$backup_dir"
671
672     # Create a stale pidfile (process doesn't exist)
673     echo "99999" > "$backup_dir/.pg_scribe.pid"
674
675     # Try to rotate - should fail with stale pidfile
676     local exit_code=0
677     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null || exit_code=$?
678
679     if [[ $exit_code -ne 0 ]]; then
680         log_pass "Correctly failed with stale pidfile"
681         return 0
682     else
683         log_fail "Should have failed with stale pidfile"
684         return 1
685     fi
686 }
687
688 test_rotate_diff_multiple() {
689     ((TESTS_RUN++))
690     log_test "--rotate-diff multiple times"
691
692     local dbname="${TEST_DB_PREFIX}_rotate_multiple"
693     local slot="test_slot_rotate_multiple"
694     local backup_dir="$TEST_DIR/rotate_multiple"
695
696     # Setup
697     create_test_db "$dbname"
698     create_table_with_pk "$dbname" "users"
699     initialize_backup_system "$dbname" "$slot" "$backup_dir"
700
701     # Start streaming in background
702     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" -s 1 -F 1 &>/dev/null &
703     local pg_scribe_pid=$!
704     PIDS_TO_CLEANUP+=("$pg_scribe_pid")
705
706     # Give it a moment to start
707     sleep 1
708
709     local chain_dirs=("$backup_dir"/chain-*)
710     local chain_dir="${chain_dirs[0]}"
711
712     # First rotation
713     query_db "$dbname" "INSERT INTO users (name) VALUES ('First');"
714     sleep 2
715     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null
716     sleep 2
717
718     # Second rotation
719     query_db "$dbname" "INSERT INTO users (name) VALUES ('Second');"
720     sleep 2
721     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null
722     sleep 2
723
724     # Third rotation
725     query_db "$dbname" "INSERT INTO users (name) VALUES ('Third');"
726     sleep 2
727     "$PG_SCRIBE" --rotate-diff -f "$backup_dir" &>/dev/null
728     sleep 2
729
730     # Stop streaming
731     kill -INT "$pg_scribe_pid" 2>/dev/null || true
732     sleep 1
733
734     # Force kill if still running
735     if kill -0 "$pg_scribe_pid" 2>/dev/null; then
736         kill -9 "$pg_scribe_pid" 2>/dev/null || true
737     fi
738     wait "$pg_scribe_pid" 2>/dev/null || true
739
740     # Verify we have 3 sealed differentials
741     local diff_count
742     diff_count=$(ls -1 "$chain_dir"/diff-*.sql 2>/dev/null | wc -l)
743
744     if [[ $diff_count -ne 3 ]]; then
745         log_fail "Expected 3 differentials, found $diff_count"
746         return 1
747     fi
748
749     # Verify each differential has the correct data
750     local diff_files=("$chain_dir"/diff-*.sql)
751
752     if ! grep -q "First" "${diff_files[0]}"; then
753         log_fail "First differential missing expected data"
754         return 1
755     fi
756
757     if ! grep -q "Second" "${diff_files[1]}"; then
758         log_fail "Second differential missing expected data"
759         return 1
760     fi
761
762     if ! grep -q "Third" "${diff_files[2]}"; then
763         log_fail "Third differential missing expected data"
764         return 1
765     fi
766
767     # Verify active.sql exists (from streaming after last rotation)
768     if [[ ! -f "$chain_dir/active.sql" ]]; then
769         log_fail "active.sql not found after multiple rotations"
770         return 1
771     fi
772
773     log_pass "Multiple rotations work correctly"
774     return 0
775 }
776
777 #
778 # Cleanup
779 #
780
781 # shellcheck disable=SC2317  # Function called via trap handler
782 cleanup() {
783     log_info "Cleaning up test resources..."
784
785     # Kill any running pg_scribe processes
786     for pid in "${PIDS_TO_CLEANUP[@]}"; do
787         if kill -0 "$pid" 2>/dev/null; then
788             log_info "Stopping pg_scribe process $pid"
789             # Try graceful shutdown first (allows signal forwarding to child processes)
790             kill -TERM "$pid" 2>/dev/null || true
791
792             # Wait briefly for graceful shutdown
793             local timeout=2
794             local count=0
795             while kill -0 "$pid" 2>/dev/null && [[ $count -lt $timeout ]]; do
796                 sleep 0.5
797                 ((count++))
798             done
799
800             # Force kill if still running
801             if kill -0 "$pid" 2>/dev/null; then
802                 log_info "Force killing pg_scribe process $pid"
803                 kill -9 "$pid" 2>/dev/null || true
804             fi
805         fi
806     done
807
808     # Wait for child pg_recvlogical processes to fully terminate
809     # (They may take a moment to shut down after parent terminates)
810     sleep 1
811
812     # Drop replication slots
813     for dbname in "${DATABASES_TO_CLEANUP[@]}"; do
814         for slot in test_slot_noinit test_slot_basic test_slot_ddl test_slot_truncate test_slot_signal test_slot_interleaved test_slot_rotate_basic test_slot_rotate_noactive test_slot_rotate_stale test_slot_rotate_multiple; do
815             drop_replication_slot "$dbname" "$slot" 2>/dev/null || true
816         done
817     done
818
819     # Drop databases
820     for dbname in "${DATABASES_TO_CLEANUP[@]}"; do
821         drop_test_db "$dbname"
822     done
823
824     # Remove test directory
825     if [[ -d "$TEST_DIR" ]]; then
826         rm -rf "$TEST_DIR"
827     fi
828
829     log_info "Cleanup complete"
830 }
831
832 #
833 # Main test runner
834 #
835
836 main() {
837     echo "========================================"
838     echo "pg_scribe --start Test Suite"
839     echo "========================================"
840     echo ""
841
842     # Verify pg_scribe exists
843     if [[ ! -x "$PG_SCRIBE" ]]; then
844         echo "ERROR: pg_scribe not found or not executable: $PG_SCRIBE"
845         exit 1
846     fi
847
848     # Verify PostgreSQL is running
849     if ! psql -U "$PGUSER" -d postgres -c "SELECT 1;" &>/dev/null; then
850         echo "ERROR: Cannot connect to PostgreSQL"
851         exit 1
852     fi
853
854     # Verify wal_level is logical
855     local wal_level
856     wal_level=$(psql -U "$PGUSER" -d postgres -tAq -c "SHOW wal_level;")
857     if [[ "$wal_level" != "logical" ]]; then
858         echo "ERROR: wal_level must be 'logical', currently: $wal_level"
859         echo "Update ~/.pgenv/pgsql/data/postgresql.conf and restart PostgreSQL"
860         exit 1
861     fi
862
863     # Verify wal2sql extension is available
864     if ! pg_config --pkglibdir &>/dev/null; then
865         echo "ERROR: pg_config not found"
866         exit 1
867     fi
868
869     local wal2sql_path
870     wal2sql_path="$(pg_config --pkglibdir)/wal2sql.so"
871     if [[ ! -f "$wal2sql_path" ]]; then
872         echo "ERROR: wal2sql.so not found at $wal2sql_path"
873         echo "Build and install wal2sql: cd wal2sql && make && make install"
874         exit 1
875     fi
876
877     # Create test directory
878     mkdir -p "$TEST_DIR"
879
880     # Set up cleanup trap
881     trap cleanup EXIT INT TERM
882
883     echo "Running tests..."
884     echo ""
885
886     # Run all tests (use || true to prevent set -e from exiting)
887     test_start_without_init || true
888     test_start_basic_streaming || true
889     test_start_ddl_capture || true
890     test_start_truncate_capture || true
891     test_start_signal_handling || true
892     test_start_interleaved_ddl_dml || true
893     test_rotate_diff_basic || true
894     test_rotate_diff_no_active_process || true
895     test_rotate_diff_stale_pidfile || true
896     test_rotate_diff_multiple || true
897
898     # Summary
899     echo ""
900     echo "========================================"
901     echo "Test Results"
902     echo "========================================"
903     echo "Tests run:    $TESTS_RUN"
904     echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
905     echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
906     echo ""
907
908     if [[ $TESTS_FAILED -eq 0 ]]; then
909         echo -e "${GREEN}All tests passed!${NC}"
910         exit 0
911     else
912         echo -e "${RED}Some tests failed!${NC}"
913         exit 1
914     fi
915 }
916
917 main "$@"