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