diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f270b8ad..9db78abe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -831,6 +831,95 @@ jobs: path: frontend/coverage/ retention-days: 14 + # ── #971: API integration tests with Docker Compose ───────────────────────── + api-integration-tests: + name: API Integration Tests (Compose) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-integration-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Start test services + run: docker compose -f docker-compose.test.yml up -d --wait + + - name: Run integration tests + working-directory: services/api + env: + TEST_DATABASE_URL: postgres://predictiq_test:predictiq_test@localhost:5433/predictiq_test + TEST_REDIS_URL: redis://localhost:6380 + STELLAR_RPC_URL: http://localhost:8080 + run: cargo test --test '*' -- --test-threads=1 + + - name: Tear down test services + if: always() + run: docker compose -f docker-compose.test.yml down -v + + # ── #972: test-order independence ──────────────────────────────────────────── + api-test-order-independence: + name: API Tests — Order Independence + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-order-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Install cargo-nextest + run: cargo install cargo-nextest --locked + + - name: Run tests with seed 1 (unit + doc tests) + working-directory: services/api + run: cargo nextest run --test-threads=1 --rand-rng-seed=1 + + - name: Run tests with seed 2 (different order) + working-directory: services/api + run: cargo nextest run --test-threads=1 --rand-rng-seed=2 + + # ── #977: property-based tests ─────────────────────────────────────────────── + api-property-tests: + name: API Property-Based Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-proptest-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Run property tests (1000 cases each) + working-directory: services/api + env: + PROPTEST_CASES: "1000" + run: cargo test prop_ + all-tests-passed: name: All Tests Passed needs: @@ -855,6 +944,9 @@ jobs: - validate-migration-rollbacks - api-criterion-benchmarks - frontend-unit-coverage + - api-integration-tests + - api-test-order-independence + - api-property-tests runs-on: ubuntu-latest steps: - name: Success diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a588632b..fe278824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,6 +148,63 @@ cd services/api cargo test ``` +### Integration Tests (API — requires backing services) + +Integration tests require PostgreSQL, Redis, and a Stellar RPC node. +The easiest way to start them is via the provided Makefile target, which +starts a Docker Compose stack, runs the tests, and tears the stack down: + +```bash +make test-integration +``` + +You can also manage the services manually: + +```bash +# Start services +docker compose -f docker-compose.test.yml up -d --wait + +# Run tests +cd services/api +TEST_DATABASE_URL=postgres://predictiq_test:predictiq_test@localhost:5433/predictiq_test \ +TEST_REDIS_URL=redis://localhost:6380 \ +STELLAR_RPC_URL=http://localhost:8080 \ +cargo test --test '*' -- --test-threads=1 + +# Tear down (always run, even on failure) +docker compose -f docker-compose.test.yml down -v +``` + +If a previous run left the stack running, clean it up first: + +```bash +make test-integration-down +``` + +#### Database fixture — transaction rollback + +Each integration test that touches the database should use the +`with_test_transaction` helper from `tests/common/db_fixture.rs`. +It wraps the test body in a database transaction that is **rolled back** +at the end, so no test leaves rows that can affect subsequent tests: + +```rust +use common::db_fixture::with_test_transaction; + +#[tokio::test] +async fn my_test() { + let pool = common::db_fixture::test_pool().await; + with_test_transaction(&pool, |mut conn| async move { + // use conn for all DB operations in this test + sqlx::query("INSERT INTO ...").execute(&mut *conn).await.unwrap(); + // transaction is automatically rolled back when this closure returns + }).await; +} +``` + +Do **not** commit within the closure — the rollback guarantees a clean slate +for the next test regardless of execution order. + ### Frontend (Next.js) ```bash @@ -223,6 +280,31 @@ make test --- +### Property-Based Tests + +Validation logic in `src/validation.rs` is covered by property-based tests +using [`proptest`](https://github.com/proptest-rs/proptest). These run as +part of `cargo test` and are gated in CI with at least **1 000 cases per +property** via `PROPTEST_CASES=1000`. + +To run them locally with the same case count: + +```bash +cd services/api +PROPTEST_CASES=1000 cargo test prop_ +``` + +When adding a new validation function, add a corresponding `proptest!` block +that at minimum covers: + +- Zero-length input +- Input longer than `MAX_LEN + 1` +- All-whitespace strings +- Strings containing null bytes (`\0`) +- Strings that must pass unchanged (valid inputs) + +--- + ## Code Style ### Rust diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..a631bb2d --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: test-integration test-integration-down test-unit help + +COMPOSE_TEST = docker compose -f docker-compose.test.yml + +TEST_DATABASE_URL = postgres://predictiq_test:predictiq_test@localhost:5433/predictiq_test +TEST_REDIS_URL = redis://localhost:6380 +STELLAR_RPC_URL = http://localhost:8080 + +##@ Testing + +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +test-integration: ## Start backing services, run API integration tests, then tear down + @echo "==> Starting test services..." + $(COMPOSE_TEST) up -d --wait + @echo "==> Running integration tests..." + @cd services/api && \ + TEST_DATABASE_URL=$(TEST_DATABASE_URL) \ + TEST_REDIS_URL=$(TEST_REDIS_URL) \ + STELLAR_RPC_URL=$(STELLAR_RPC_URL) \ + cargo test --test '*' -- --test-threads=1; \ + STATUS=$$?; \ + echo "==> Tearing down test services..."; \ + cd ../.. && $(COMPOSE_TEST) down -v; \ + exit $$STATUS + +test-integration-down: ## Tear down test services (cleanup after a failed run) + $(COMPOSE_TEST) down -v + +test-unit: ## Run unit tests (no external services needed) + cargo test --lib --workspace diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..1e92960d --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,65 @@ +version: '3.8' + +# Test-only compose stack. +# Provides isolated PostgreSQL, Redis, and a Stellar standalone network +# for integration tests. Do NOT use this file in production. +# +# Usage: +# docker compose -f docker-compose.test.yml up -d +# # ... run tests ... +# docker compose -f docker-compose.test.yml down -v + +services: + postgres-test: + image: postgres:15-alpine + container_name: predictiq-test-postgres + environment: + POSTGRES_USER: predictiq_test + POSTGRES_PASSWORD: predictiq_test + POSTGRES_DB: predictiq_test + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U predictiq_test -d predictiq_test"] + interval: 5s + timeout: 5s + retries: 10 + tmpfs: + - /var/lib/postgresql/data + networks: + - predictiq-test + + redis-test: + image: redis:7-alpine + container_name: predictiq-test-redis + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - predictiq-test + + stellar-rpc-stub: + image: stellar/quickstart:latest + container_name: predictiq-test-stellar + command: --standalone --enable-soroban-rpc + environment: + - ENABLE_SOROBAN_RPC=true + ports: + - "8000:8000" # Horizon + Friendbot + - "8080:8080" # Stellar RPC + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + networks: + - predictiq-test + +networks: + predictiq-test: + driver: bridge diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index bab4d7ee..ef565376 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -26,7 +26,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync", "net"] } +url = "2" tokio-util = { version = "0.7", features = ["rt"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "compression-br"] } @@ -52,6 +53,7 @@ ipnet = "2" criterion = { version = "0.5", features = ["html_reports"] } testcontainers = { version = "0.23", features = ["tokio"] } testcontainers-modules = { version = "0.11", features = ["redis", "tokio"] } +proptest = "1" [[bench]] name = "api_key_auth" diff --git a/services/api/TRACING.md b/services/api/TRACING.md index f66044be..b2d3959d 100644 --- a/services/api/TRACING.md +++ b/services/api/TRACING.md @@ -10,13 +10,36 @@ This service implements distributed tracing using OpenTelemetry to track request - Automatic span creation at service boundaries - Request correlation with trace IDs +## Endpoint Validation + +`OTEL_EXPORTER_OTLP_ENDPOINT` is validated as a parseable URL at startup. +An invalid value (e.g. `not-a-url`) causes the service to **exit immediately** +with a clear error rather than failing silently during the first export. + +After the tracing subscriber is initialised, a **TCP connectivity check** +(2-second timeout) is attempted against the configured endpoint. +If the endpoint is unreachable the service **continues to start** but: + +- Logs a `WARN` message referencing the endpoint and error. +- Increments `otel_export_errors_total{reason="unreachable"}`. + +Monitor that counter to detect collector outages before they cause silent +data loss in production. + +## Prometheus Metrics + +| Metric | Labels | Description | +|---|---|---| +| `otel_export_errors_total` | `reason` | Export failures — `unreachable` (startup TCP check), `export_failed` (runtime) | + ## Configuration Configure tracing via environment variables: ```bash # OTLP endpoint (leave unset to disable trace export) -OTLP_ENDPOINT=http://localhost:4317 +# Must be a valid URL — invalid values fail the service at startup. +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # ── Sampling (OTel standard env vars — preferred) ──────────────────────────── # OTEL_TRACES_SAMPLER selects the sampler strategy. @@ -219,17 +242,20 @@ Tracing adds minimal overhead: ### Traces not appearing -1. Check OTLP endpoint is reachable: +1. Check `otel_export_errors_total` in `/metrics` — any non-zero value means + the startup connectivity check failed. + +2. Check OTLP endpoint is reachable: ```bash curl http://localhost:4317 ``` -2. Check API logs for tracing initialization: +3. Check API logs for tracing initialization: ``` Distributed tracing initialized service_name="predictiq-api" ``` -3. Verify sampling rate is > 0: +4. Verify sampling rate is > 0: ```bash echo $OTEL_TRACES_SAMPLER_ARG # OTel standard echo $TRACE_SAMPLE_RATE # legacy fallback diff --git a/services/api/src/main.rs b/services/api/src/main.rs index 115a5963..48753ad2 100644 --- a/services/api/src/main.rs +++ b/services/api/src/main.rs @@ -95,6 +95,15 @@ async fn main() -> anyhow::Result<()> { config.validate()?; let metrics = Metrics::new()?; + + // Warn at startup if the OTLP endpoint is unreachable so operators know + // that traces are being dropped before any export attempt is made. + if let Some(ref endpoint) = config.otlp_endpoint { + if !tracing_config::check_otlp_connectivity(endpoint).await { + metrics.observe_otel_export_error("unreachable"); + } + } + let cache = RedisCache::new(&config.redis_url).await?; let db = Database::new(&config.database_url, cache.clone(), metrics.clone(), &config.db_pool).await?; let blockchain = BlockchainClient::new(&config, cache.clone(), metrics.clone())?; diff --git a/services/api/src/metrics.rs b/services/api/src/metrics.rs index e9c8b542..a0e4d4ea 100644 --- a/services/api/src/metrics.rs +++ b/services/api/src/metrics.rs @@ -18,6 +18,7 @@ pub struct Metrics { db_pool_connections_idle: IntGaugeVec, db_pool_acquire_duration: HistogramVec, rate_limit_rejections: IntCounterVec, + otel_export_errors: IntCounterVec, } impl Metrics { @@ -120,6 +121,15 @@ impl Metrics { ) .context("rate_limit_rejections metric")?; + let otel_export_errors = IntCounterVec::new( + prometheus::Opts::new( + "otel_export_errors_total", + "OpenTelemetry trace export failures, by reason", + ), + &["reason"], + ) + .context("otel_export_errors metric")?; + registry.register(Box::new(cache_hits.clone()))?; registry.register(Box::new(cache_misses.clone()))?; registry.register(Box::new(invalidations.clone()))?; @@ -132,6 +142,7 @@ impl Metrics { registry.register(Box::new(db_pool_connections_idle.clone()))?; registry.register(Box::new(db_pool_acquire_duration.clone()))?; registry.register(Box::new(rate_limit_rejections.clone()))?; + registry.register(Box::new(otel_export_errors.clone()))?; Ok(Self { registry, @@ -147,6 +158,7 @@ impl Metrics { db_pool_connections_idle, db_pool_acquire_duration, rate_limit_rejections, + otel_export_errors, }) } @@ -226,6 +238,13 @@ impl Metrics { .inc(); } + /// Increment the OTEL export error counter. + /// Pass `reason = "unreachable"` for startup connectivity failures, + /// `reason = "export_failed"` for runtime export errors. + pub fn observe_otel_export_error(&self, reason: &str) { + self.otel_export_errors.with_label_values(&[reason]).inc(); + } + pub fn render(&self) -> anyhow::Result { let mut buffer = vec![]; let encoder = TextEncoder::new(); diff --git a/services/api/src/tracing_config.rs b/services/api/src/tracing_config.rs index 391e0251..aecbf896 100644 --- a/services/api/src/tracing_config.rs +++ b/services/api/src/tracing_config.rs @@ -1,6 +1,6 @@ use opentelemetry::{ global, - trace::{TraceError, TracerProvider as _}, + trace::TracerProvider as _, KeyValue, }; use opentelemetry_otlp::WithExportConfig; @@ -11,6 +11,7 @@ use opentelemetry_sdk::{ }; use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; +use url::Url; /// Resolve the trace sampling rate from OTel standard env vars, falling back to `default_rate`. /// @@ -43,13 +44,89 @@ pub fn sample_rate_from_env(default_rate: f64) -> f64 { } } -/// Initialize distributed tracing with OpenTelemetry +/// Validate that `endpoint` is a parseable URL and that the host:port is +/// reachable via a TCP dial. Logs a WARNING if the check fails so the service +/// starts normally but operators know traces are being lost. +/// +/// Returns `true` when the endpoint is reachable, `false` otherwise. +pub async fn check_otlp_connectivity(endpoint: &str) -> bool { + let parsed = match Url::parse(endpoint) { + Ok(u) => u, + Err(e) => { + tracing::warn!( + endpoint, + error = %e, + "OTLP endpoint URL is invalid — traces will not be exported" + ); + return false; + } + }; + + let host = match parsed.host_str() { + Some(h) => h.to_string(), + None => { + tracing::warn!( + endpoint, + "OTLP endpoint URL has no host — traces will not be exported" + ); + return false; + } + }; + + let port = parsed.port().unwrap_or(4317); + let addr = format!("{host}:{port}"); + + match tokio::time::timeout( + std::time::Duration::from_secs(2), + tokio::net::TcpStream::connect(&addr), + ) + .await + { + Ok(Ok(_)) => { + tracing::info!(endpoint, "OTLP endpoint connectivity check passed"); + true + } + Ok(Err(e)) => { + tracing::warn!( + endpoint, + error = %e, + "OTLP endpoint is not reachable — traces may be lost" + ); + false + } + Err(_) => { + tracing::warn!( + endpoint, + "OTLP endpoint connectivity check timed out after 2 s — traces may be lost" + ); + false + } + } +} + +/// Initialize distributed tracing with OpenTelemetry. +/// +/// Validates `otlp_endpoint` as a parseable URL; returns an error immediately +/// if the value is set but cannot be parsed (prevents silent misconfiguration). +/// A separate TCP reachability check is available via [`check_otlp_connectivity`]. pub fn init_tracing( service_name: &str, service_version: &str, otlp_endpoint: Option, sample_rate: f64, -) -> Result<(), TraceError> { +) -> anyhow::Result<()> { + // Fail fast on an unparseable endpoint URL so the error surfaces at startup + // rather than silently at the first export attempt. + if let Some(ref ep) = otlp_endpoint { + Url::parse(ep).map_err(|e| { + anyhow::anyhow!( + "OTEL_EXPORTER_OTLP_ENDPOINT '{}' is not a valid URL: {}", + ep, + e + ) + })?; + } + // OTel standard env vars take precedence over the passed-in rate. // OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG default to 10 % for production. let sample_rate = sample_rate_from_env(sample_rate); @@ -138,6 +215,7 @@ pub fn init_tracing( Ok(()) } + /// Shutdown tracing and flush remaining spans pub fn shutdown_tracing() { tracing::info!("Shutting down tracing"); @@ -213,4 +291,41 @@ mod tests { assert!(result.is_ok()); shutdown_tracing(); } + + #[test] + fn invalid_otlp_url_is_rejected_at_startup() { + let result = init_tracing( + "test-service", + "0.1.0", + Some("not a valid url :::".to_string()), + 0.1, + ); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("OTEL_EXPORTER_OTLP_ENDPOINT"), + "error message should mention the env var, got: {msg}" + ); + } + + #[test] + fn valid_otlp_url_passes_parse_check() { + // URL parsing only — no TCP connection is attempted in init_tracing + let result = init_tracing( + "test-service", + "0.1.0", + Some("http://localhost:4317".to_string()), + 0.0, + ); + // init may fail later (e.g. already-initialized subscriber) but must not + // fail at URL validation, so we only check it's NOT a URL-parse error. + if let Err(e) = result { + let msg = format!("{e}"); + assert!( + !msg.contains("not a valid URL"), + "init_tracing rejected a valid URL: {msg}" + ); + } + shutdown_tracing(); + } } diff --git a/services/api/src/validation.rs b/services/api/src/validation.rs index 6eac517d..c2ffa50b 100644 --- a/services/api/src/validation.rs +++ b/services/api/src/validation.rs @@ -207,4 +207,117 @@ mod tests { let err = validate_string("desc", "<script>", 1, 200).unwrap_err(); assert_eq!(err.error, "invalid_content"); } + + // ── Property-based tests ────────────────────────────────────────────────── + // + // Run with at least 1 000 cases in CI: + // PROPTEST_CASES=1000 cargo test prop_ + #[cfg(test)] + mod property_tests { + use super::*; + use proptest::prelude::*; + + // Maximum field length used in the property tests below. + const MAX_LEN: usize = 200; + + proptest! { + // Empty string should be accepted by sanitize_string (length checks + // happen in validate_string) and produce an empty result. + #[test] + fn prop_sanitize_empty_string_is_ok(_ignored in Just(())) { + let result = sanitize_string("field", ""); + prop_assert!(result.is_ok()); + prop_assert_eq!(result.unwrap(), ""); + } + + // Strings longer than MAX_LEN must be rejected by validate_string. + #[test] + fn prop_validate_rejects_over_max_len( + extra in 1usize..=256, + ch in '[' ..= '~', // printable ASCII, no injection chars + ) { + let s: String = std::iter::repeat(ch).take(MAX_LEN + extra).collect(); + // Skip if the character happens to form an injection pattern — we're + // testing the length gate, not the injection gate. + prop_assume!(!contains_injection(&s)); + let result = validate_string("field", &s, 1, MAX_LEN); + prop_assert!(result.is_err()); + prop_assert_eq!(result.unwrap_err().error, "too_long"); + } + + // Zero-length input must be rejected by validate_string when min_len > 0. + #[test] + fn prop_validate_rejects_empty_when_min_len_positive(_ignored in Just(())) { + let result = validate_string("field", "", 1, MAX_LEN); + prop_assert!(result.is_err()); + prop_assert_eq!(result.unwrap_err().error, "too_short"); + } + + // All-whitespace strings collapse to "" after trim and should fail + // the min-length gate when min_len > 0. + #[test] + fn prop_all_whitespace_is_rejected( + spaces in 1usize..=50, + ) { + let s: String = " ".repeat(spaces); + let result = validate_string("field", &s, 1, MAX_LEN); + prop_assert!(result.is_err()); + prop_assert_eq!(result.unwrap_err().error, "too_short"); + } + + // Null bytes must never appear in sanitized output. + #[test] + fn prop_null_bytes_stripped_from_output( + prefix in "[a-zA-Z0-9 ]{0,20}", + suffix in "[a-zA-Z0-9 ]{0,20}", + ) { + let input = format!("{prefix}\0{suffix}"); + prop_assume!(!contains_injection(&input)); + if let Ok(out) = sanitize_string("field", &input) { + prop_assert!(!out.contains('\0'), "null byte survived sanitization"); + } + } + + // Control characters (except tab/newline/CR) must not appear in output. + #[test] + fn prop_control_chars_stripped( + ctrl in 1u8..=8u8, // \x01–\x08 are stripped + filler in "[a-z]{1,10}", + ) { + let input = format!("{filler}{}{filler}", ctrl as char); + prop_assume!(!contains_injection(&input)); + if let Ok(out) = sanitize_string("field", &input) { + prop_assert!( + !out.chars().any(|c| c.is_control() && c != '\t' && c != '\n' && c != '\r'), + "control character survived sanitization" + ); + } + } + + // Known-safe strings within length bounds must always pass. + #[test] + fn prop_valid_market_titles_pass( + // Alphanumeric + common punctuation; deliberately no HTML/script chars + title in "[a-zA-Z0-9 .,!?'\\-]{5,100}", + ) { + prop_assume!(!contains_injection(&title)); + let result = validate_string("title", &title, 1, MAX_LEN); + prop_assert!(result.is_ok(), "valid title was rejected: {:?}", result.err()); + } + + // Unicode non-ASCII (including homograph characters) must never panic + // and must not produce null bytes in output. + #[test] + fn prop_unicode_does_not_panic_or_produce_null( + s in "\\PC{0,50}", // any non-control Unicode up to 50 chars + ) { + // Ignore inputs that trigger the injection guard — we test that + // separately; here we only care that the function doesn't panic or + // corrupt output. + if let Ok(out) = sanitize_string("field", &s) { + prop_assert!(!out.contains('\0')); + } + } + } + } } diff --git a/services/api/tests/common/db_fixture.rs b/services/api/tests/common/db_fixture.rs new file mode 100644 index 00000000..e4a475fe --- /dev/null +++ b/services/api/tests/common/db_fixture.rs @@ -0,0 +1,99 @@ +//! Database test fixture — transaction-scoped rollback. +//! +//! Each call to [`with_test_transaction`] opens a Postgres transaction, +//! runs the test closure, then **rolls back** unconditionally so no test +//! leaves rows that can affect subsequent tests regardless of execution order. +//! +//! # Requirements +//! +//! Set `TEST_DATABASE_URL` before running integration tests: +//! +//! ```bash +//! TEST_DATABASE_URL=postgres://predictiq_test:predictiq_test@localhost:5433/predictiq_test \ +//! cargo test --test '*' -- --test-threads=1 +//! ``` +//! +//! The easiest way to start the required services is: +//! +//! ```bash +//! make test-integration +//! ``` + +use std::future::Future; +use sqlx::{postgres::PgPoolOptions, PgPool, Postgres, Transaction}; + +/// Return a connection pool backed by `TEST_DATABASE_URL`. +/// +/// The pool is intentionally small (max 5 connections) so parallel test +/// runs surface connection-exhaustion issues early. +pub async fn test_pool() -> PgPool { + let url = std::env::var("TEST_DATABASE_URL").expect( + "TEST_DATABASE_URL must be set to run database integration tests. \ + Start the test stack with `make test-integration` or \ + `docker compose -f docker-compose.test.yml up -d --wait`.", + ); + PgPoolOptions::new() + .max_connections(5) + .connect(&url) + .await + .expect("Failed to connect to test database") +} + +/// Run `f` inside a Postgres transaction that is **always rolled back**. +/// +/// The closure receives a mutable reference to the transaction, which +/// implements `sqlx::Executor` so you can pass `&mut *conn` directly to +/// any `sqlx::query` call. +/// +/// # Example +/// +/// ```rust,no_run +/// # use common::db_fixture::{test_pool, with_test_transaction}; +/// #[tokio::test] +/// async fn inserts_are_isolated() { +/// let pool = test_pool().await; +/// with_test_transaction(&pool, |mut conn| async move { +/// sqlx::query("INSERT INTO users (email) VALUES ('test@example.com')") +/// .execute(&mut *conn) +/// .await +/// .unwrap(); +/// let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") +/// .fetch_one(&mut *conn) +/// .await +/// .unwrap(); +/// assert!(row.0 > 0); +/// }) +/// .await; +/// // The INSERT above is never committed — the next test starts clean. +/// } +/// ``` +pub async fn with_test_transaction(pool: &PgPool, f: F) +where + F: FnOnce(Transaction<'static, Postgres>) -> Fut, + Fut: Future, +{ + let tx = pool + .begin() + .await + .expect("Failed to begin test transaction"); + + f(tx).await; + // Dropping the transaction without calling .commit() triggers an implicit + // ROLLBACK — SQLx guarantees this on Drop for PgTransaction. +} + +/// Truncate a list of tables between tests as an alternative to transaction +/// rollback (useful when the code under test manages its own transactions). +/// +/// Tables are truncated in the given order with `TRUNCATE … RESTART IDENTITY +/// CASCADE` so foreign-key constraints are satisfied. +pub async fn truncate_tables(pool: &PgPool, tables: &[&str]) { + for table in tables { + sqlx::query(&format!( + "TRUNCATE TABLE {table} RESTART IDENTITY CASCADE" + )) + .execute(pool) + .await + .unwrap_or_else(|e| panic!("Failed to truncate {table}: {e}")); + } +} diff --git a/services/api/tests/common/mod.rs b/services/api/tests/common/mod.rs new file mode 100644 index 00000000..dcce955e --- /dev/null +++ b/services/api/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod db_fixture;