//! Integration tests for user-trigger detection logic. //! //! Validates the SQL queries used by `has_user_triggers()` and the trigger //! detection plumbing against a real PostgreSQL 18.3 container. These tests //! do NOT require the pg_trickle extension — they exercise the raw SQL //! patterns used internally. //! //! Prerequisites: Testcontainers + Docker mod common; use common::TestDb; // ── Trigger detection query ──────────────────────────────────────────── // // Mirrors the query in src/cdc.rs `has_user_triggers()`: // SELECT EXISTS( // SELECT 1 FROM pg_trigger // WHERE tgrelid = $OID // AND NOT tgisinternal // AND tgname NOT LIKE 'pgt_%' // AND tgtype & 1 = 1 -- ROW-level trigger // ) const HAS_USER_TRIGGERS_SQL: &str = r#" SELECT EXISTS( SELECT 1 FROM pg_trigger WHERE tgrelid = $1::oid AND NOT tgisinternal AND tgname NOT LIKE 'pgt_%' AND tgname NOT LIKE 'pg_trickle_%' AND tgtype & 1 = 1 ) "#; #[tokio::test] async fn test_trigger_detection_no_triggers() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_empty (id INT PRIMARY KEY, val TEXT)") .await; let oid: i32 = db .query_scalar("SELECT 'detect_empty'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!( !has_triggers, "Table with no user triggers should return false" ); } #[tokio::test] async fn test_trigger_detection_with_user_trigger() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_trig (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; db.execute( "CREATE TRIGGER user_trig AFTER INSERT ON detect_trig FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_trig'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!(has_triggers, "Table with user trigger should return true"); } #[tokio::test] async fn test_trigger_detection_ignores_pgt_prefix() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_pgs (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; // Trigger with pgt_ prefix should be ignored (internal pg_trickle triggers) db.execute( "CREATE TRIGGER pgt_cdc_trigger AFTER INSERT ON detect_pgs FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_pgs'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!( !has_triggers, "pgt_-prefixed triggers should be excluded from detection" ); } #[tokio::test] async fn test_trigger_detection_ignores_statement_level() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_stmt (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_stmt_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NULL; END; $$ LANGUAGE plpgsql", ) .await; // Statement-level trigger (FOR EACH STATEMENT) should NOT be detected // because we check tgtype & 1 = 1 (ROW-level flag) db.execute( "CREATE TRIGGER stmt_trig AFTER INSERT ON detect_stmt FOR EACH STATEMENT EXECUTE FUNCTION noop_stmt_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_stmt'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!( !has_triggers, "Statement-level triggers should not be detected as user row-level triggers" ); } #[tokio::test] async fn test_trigger_detection_mixed_triggers() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_mixed (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; db.execute( "CREATE OR REPLACE FUNCTION noop_stmt_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NULL; END; $$ LANGUAGE plpgsql", ) .await; // Internal pg_trickle trigger (should be ignored) db.execute( "CREATE TRIGGER pgt_internal AFTER INSERT ON detect_mixed FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; // Statement-level trigger (should be ignored) db.execute( "CREATE TRIGGER stmt_trig AFTER INSERT ON detect_mixed FOR EACH STATEMENT EXECUTE FUNCTION noop_stmt_fn()", ) .await; // User row-level trigger (should be detected) db.execute( "CREATE TRIGGER user_row_trig AFTER UPDATE ON detect_mixed FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_mixed'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!( has_triggers, "Should detect the user row-level trigger among mixed triggers" ); } #[tokio::test] async fn test_trigger_detection_before_trigger() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_before (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; // BEFORE triggers should also be detected (they are row-level) db.execute( "CREATE TRIGGER before_trig BEFORE UPDATE ON detect_before FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_before'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!(has_triggers, "BEFORE row-level triggers should be detected"); } #[tokio::test] async fn test_trigger_detection_after_drop() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_drop (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; db.execute( "CREATE TRIGGER drop_trig AFTER INSERT ON detect_drop FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_drop'::regclass::oid::int") .await; // Should detect trigger let before: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("query failed"); assert!(before, "Should detect trigger before drop"); // Drop the trigger db.execute("DROP TRIGGER drop_trig ON detect_drop").await; // Should no longer detect trigger let after: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("query failed"); assert!(!after, "Should not detect trigger after drop"); } /// Regression: `pg_trickle_%` prefixed triggers (CDC triggers installed by /// the extension) must NOT be counted as user triggers. Before the fix, /// only `pgt_%` was excluded, so `pg_trickle_cdc_` triggers were /// incorrectly detected as user triggers, forcing the slower explicit-DML /// refresh path. #[tokio::test] async fn test_trigger_detection_ignores_pg_trickle_prefix() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_trickle (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; // Simulate the CDC trigger name pattern used by pg_trickle db.execute( "CREATE TRIGGER pg_trickle_cdc_12345 AFTER INSERT ON detect_trickle FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_trickle'::regclass::oid::int") .await; let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("trigger detection query failed"); assert!( !has_triggers, "pg_trickle_-prefixed triggers should be excluded from user trigger detection" ); } /// Regression: Both `pgt_%` AND `pg_trickle_%` triggers should be excluded, /// while a real user trigger alongside them is still detected. #[tokio::test] async fn test_trigger_detection_mixed_internal_and_user() { let db = TestDb::new().await; db.execute("CREATE TABLE detect_all (id INT PRIMARY KEY, val TEXT)") .await; db.execute( "CREATE OR REPLACE FUNCTION noop_fn() RETURNS TRIGGER AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql", ) .await; // Internal: pgt_* prefix db.execute( "CREATE TRIGGER pgt_change_buffer AFTER INSERT ON detect_all FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; // Internal: pg_trickle_* prefix (CDC) db.execute( "CREATE TRIGGER pg_trickle_cdc_99999 AFTER INSERT ON detect_all FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let oid: i32 = db .query_scalar("SELECT 'detect_all'::regclass::oid::int") .await; // With only internal triggers, should be false let has_triggers: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("query failed"); assert!( !has_triggers, "Only internal triggers present — should not detect user triggers" ); // Add a real user trigger db.execute( "CREATE TRIGGER audit_log_trigger AFTER UPDATE ON detect_all FOR EACH ROW EXECUTE FUNCTION noop_fn()", ) .await; let has_triggers_with_user: bool = sqlx::query_scalar(HAS_USER_TRIGGERS_SQL) .bind(oid) .fetch_one(&db.pool) .await .expect("query failed"); assert!( has_triggers_with_user, "Real user trigger should be detected alongside internal triggers" ); }