Skip to main content

cratefield_adapter_postgres/
testing.rs

1//! Shared Postgres test infrastructure: per-test throwaway databases on
2//! the server named by `FZ_TEST_POSTGRES_URL`, so tests never see each
3//! other's tables (issue #18).
4//!
5//! Exported so `cratefield-testing`'s parity kit (issue #20) reuses the
6//! exact helper this crate's own contract tests use — one
7//! implementation, not two. Tests skip with a printed reason when the
8//! variable is unset (CI provides a `postgres:16` service container).
9
10use sqlx::{Executor as _, Row as _};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13static COUNTER: AtomicU64 = AtomicU64::new(0);
14
15/// The server URL from `FZ_TEST_POSTGRES_URL`, when set (trimmed and
16/// non-empty).
17#[must_use]
18pub fn base_url() -> Option<String> {
19    std::env::var("FZ_TEST_POSTGRES_URL")
20        .ok()
21        .map(|url| url.trim().to_owned())
22        .filter(|url| !url.is_empty())
23}
24
25/// The skip reason printed when a test cannot run without a server.
26#[must_use]
27pub fn skip_reason() -> String {
28    "FZ_TEST_POSTGRES_URL is not set — start a local postgres:16 \
29     (docker run --rm -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16) \
30     and set FZ_TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5433/postgres"
31        .to_owned()
32}
33
34fn url_with_database(base: &str, database: &str) -> String {
35    let (path, query) = match base.split_once('?') {
36        Some((path, query)) => (path, Some(query)),
37        None => (base, None),
38    };
39    let (prefix, _) = path
40        .rsplit_once('/')
41        .expect("FZ_TEST_POSTGRES_URL must name a database");
42    let mut url = format!("{prefix}/{database}");
43    if let Some(query) = query {
44        url.push('?');
45        url.push_str(query);
46    }
47    url
48}
49
50/// A fresh database named `fz_<tag>_<pid>_<n>` on the given server;
51/// call [`TempDb::finish`] to drop it.
52pub struct TempDb {
53    /// The connection string of the throwaway database.
54    pub url: String,
55    admin: sqlx::PgPool,
56    database: String,
57}
58
59impl TempDb {
60    /// Creates a throwaway database on `base` (the value of
61    /// `FZ_TEST_POSTGRES_URL`). The name is unique per process and per
62    /// call, so a previous run's leaked databases (a test that panicked
63    /// before `finish`) never collide.
64    ///
65    /// # Panics
66    ///
67    /// Panics when the server refuses connections or cannot create the
68    /// database — callers gate on [`base_url`] so a missing server is a
69    /// skip, not a panic.
70    pub async fn create(base: &str, tag: &str) -> Option<Self> {
71        let admin = sqlx::PgPool::connect(base)
72            .await
73            .expect("FZ_TEST_POSTGRES_URL server accepts connections");
74        let database = format!(
75            "fz_{tag}_{}_{}",
76            std::process::id(),
77            COUNTER.fetch_add(1, Ordering::Relaxed)
78        );
79        admin
80            .execute(format!(r#"CREATE DATABASE "{database}""#).as_str())
81            .await
82            .expect("can create a throwaway test database");
83        Some(Self {
84            url: url_with_database(base, &database),
85            admin,
86            database,
87        })
88    }
89
90    /// Asserts the server is the major version the contract is about.
91    ///
92    /// # Panics
93    ///
94    /// Panics when the server is not Postgres 16.
95    pub async fn assert_postgres_16(&self) {
96        let version: String = sqlx::query("SELECT version()")
97            .fetch_one(&self.admin)
98            .await
99            .expect("server reports its version")
100            .get(0);
101        assert!(
102            version.starts_with("PostgreSQL 16"),
103            "contract is about Postgres 16, server is: {version}"
104        );
105    }
106
107    /// Drops the throwaway database.
108    ///
109    /// # Panics
110    ///
111    /// Panics when the drop fails on a server that answered `create`.
112    pub async fn finish(self) {
113        let Self {
114            admin,
115            database,
116            url: _,
117        } = self;
118        admin.close().await;
119        // A fresh pool just for the DROP (the old one may still hold
120        // connections to the database being dropped).
121        if let Ok(dropper) =
122            sqlx::PgPool::connect(&base_url().expect("create was called with a non-empty base url"))
123                .await
124        {
125            dropper
126                .execute(
127                    // WITH (FORCE): the adapter under test still holds
128                    // pool sessions; Postgres 13+ terminates them.
129                    format!(r#"DROP DATABASE IF EXISTS "{database}" WITH (FORCE)"#).as_str(),
130                )
131                .await
132                .expect("drop throwaway test database");
133            dropper.close().await;
134        }
135    }
136}