supabase-testcontainers-modules 1.0.3

Testcontainers modules for Supabase services used in integration testing.
Documentation
//! Integration tests for Supabase Analytics (Logflare) container setup patterns
//!
//! These tests verify the Analytics container configuration and startup.
//!
//! Note: Full analytics functionality (HTTP endpoints, log ingestion) requires
//! successful database migrations. The Logflare container's HTTP server only
//! starts after migrations complete successfully.
//!
//! Run with: `cargo test --features analytics,const --test analytics_integration`

use anyhow::Result;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use supabase_testcontainers_modules::{Analytics, ANALYTICS_PORT, LOCAL_HOST};
use testcontainers::runners::AsyncRunner;
use testcontainers::ImageExt;
use testcontainers_modules::postgres::Postgres;
use tokio_postgres::NoTls;

/// PostgreSQL port constant
const POSTGRES_PORT: u16 = 5432;
/// Network name for container-to-container communication
const TEST_NETWORK: &str = "analytics-test-network";
/// PostgreSQL container alias on the shared network
const POSTGRES_ALIAS: &str = "analytics-db";

/// Public access token for API operations
const PUBLIC_ACCESS_TOKEN: &str = "test-public-access-token";
/// Private access token for management operations
const PRIVATE_ACCESS_TOKEN: &str = "test-private-access-token";
/// Base64 encryption key (32 bytes = 256 bits, base64 encoded)
const ENCRYPTION_KEY: &str = "dGhpcy1pcy1hLTMyLWJ5dGUtZW5jcnlwdGlvbi1rZXk=";

/// Atomic counter for generating unique test IDs
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Generates a unique test ID combining timestamp and atomic counter
fn unique_test_id() -> String {
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis();
    let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
    format!("{}-{}", timestamp, counter)
}

/// Returns the base URL for PostgreSQL connection
fn postgres_url(port: u16) -> String {
    format!(
        "postgres://postgres:postgres@{}:{}/postgres",
        LOCAL_HOST, port
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use testcontainers::ContainerAsync;

    /// Helper struct for PostgreSQL + Analytics setup
    struct AnalyticsContext {
        #[allow(dead_code)]
        postgres: ContainerAsync<Postgres>,
        #[allow(dead_code)]
        analytics: ContainerAsync<Analytics>,
        postgres_port: u16,
        #[allow(dead_code)]
        analytics_port: u16,
    }

    /// Sets up PostgreSQL and Analytics for testing
    async fn setup_analytics() -> Result<AnalyticsContext> {
        let test_id = unique_test_id();
        let network_name = format!("{}-{}", TEST_NETWORK, test_id);
        let postgres_name = format!("{}-{}", POSTGRES_ALIAS, test_id);

        // Start PostgreSQL on shared network
        let postgres = Postgres::default()
            .with_tag("15-alpine")
            .with_network(&network_name)
            .with_container_name(&postgres_name)
            .start()
            .await?;
        let postgres_port = postgres.get_host_port_ipv4(POSTGRES_PORT).await?;

        // Set up analytics prerequisites
        let db_url = postgres_url(postgres_port);
        setup_analytics_schema(&db_url).await?;

        // Connection string for Analytics (uses container name on shared network)
        let analytics_db_url = format!(
            "postgresql://postgres:postgres@{}:{}/postgres",
            postgres_name, POSTGRES_PORT
        );

        // Build Analytics with configuration
        let analytics = Analytics::default()
            .with_postgres_backend_url(&analytics_db_url)
            .with_db_hostname(&postgres_name)
            .with_db_port(POSTGRES_PORT)
            .with_db_username("postgres")
            .with_db_password("postgres")
            .with_db_database("postgres")
            .with_public_access_token(PUBLIC_ACCESS_TOKEN)
            .with_private_access_token(PRIVATE_ACCESS_TOKEN)
            .with_encryption_key(ENCRYPTION_KEY)
            .with_startup_timeout(Duration::from_secs(120))
            .with_network(&network_name)
            .start()
            .await?;
        let analytics_port = analytics.get_host_port_ipv4(ANALYTICS_PORT).await?;

        // Wait for Analytics container to initialize
        tokio::time::sleep(Duration::from_secs(5)).await;

        Ok(AnalyticsContext {
            postgres,
            analytics,
            postgres_port,
            analytics_port,
        })
    }

    /// Sets up the PostgreSQL prerequisites for analytics
    async fn setup_analytics_schema(db_url: &str) -> Result<()> {
        let (client, connection) = tokio_postgres::connect(db_url, NoTls).await?;

        tokio::spawn(async move {
            if let Err(e) = connection.await {
                eprintln!("connection error: {}", e);
            }
        });

        // Create roles and extensions that Logflare might need
        // Logflare will create its own _analytics schema
        client
            .batch_execute(
                r#"
            -- Create roles that analytics might expect
            DO $$
            BEGIN
                IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
                    CREATE ROLE anon NOLOGIN;
                END IF;
                IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
                    CREATE ROLE authenticated NOLOGIN;
                END IF;
                IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN
                    CREATE ROLE service_role NOLOGIN;
                END IF;
            END
            $$;

            -- Create extensions that Logflare needs
            CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
            CREATE EXTENSION IF NOT EXISTS "pg_trgm";
            "#,
            )
            .await?;

        Ok(())
    }

    /// Test that PostgreSQL and Analytics containers start successfully
    #[tokio::test]
    async fn test_containers_start() -> Result<()> {
        let ctx = setup_analytics().await?;

        assert!(ctx.postgres_port > 0, "PostgreSQL port should be assigned");
        assert!(ctx.analytics_port > 0, "Analytics port should be assigned");

        Ok(())
    }

    /// Test that we can connect to PostgreSQL alongside Analytics
    #[tokio::test]
    async fn test_postgres_connection_works() -> Result<()> {
        let ctx = setup_analytics().await?;

        // Connect directly to PostgreSQL
        let db_url = postgres_url(ctx.postgres_port);
        let (client, connection) = tokio_postgres::connect(&db_url, NoTls).await?;

        tokio::spawn(async move {
            if let Err(e) = connection.await {
                eprintln!("connection error: {}", e);
            }
        });

        // Verify we can query
        let rows = client.query("SELECT 1 as value", &[]).await?;
        assert_eq!(rows.len(), 1);

        Ok(())
    }

    /// Test that Logflare creates the _analytics schema (if migrations succeed)
    #[tokio::test]
    async fn test_analytics_schema_check() -> Result<()> {
        let ctx = setup_analytics().await?;

        // Connect directly to PostgreSQL
        let db_url = postgres_url(ctx.postgres_port);
        let (client, connection) = tokio_postgres::connect(&db_url, NoTls).await?;

        tokio::spawn(async move {
            if let Err(e) = connection.await {
                eprintln!("connection error: {}", e);
            }
        });

        // Check if _analytics schema exists
        // Note: Schema may not exist if Logflare migrations haven't run yet
        let rows = client
            .query(
                "SELECT schema_name FROM information_schema.schemata WHERE schema_name = '_analytics'",
                &[],
            )
            .await?;

        // Log result - schema may or may not exist depending on migration timing
        if rows.is_empty() {
            println!("Note: _analytics schema not found - this is expected if migrations are still running");
        } else {
            println!("_analytics schema found - Logflare migrations completed");
        }

        // Test passes either way - we're verifying we can check
        Ok(())
    }
}