use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use testcontainers::{ContainerAsync, core::ImageExt as _, runners::AsyncRunner};
use testcontainers_modules::neo4j::{Neo4j, Neo4jImage};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::testing::must::testcontainers_enabled;
use super::MaeContainer;
pub struct Inner {
pub container: ContainerAsync<Neo4jImage>,
pub bolt_port: u16,
pub bolt_url: String
}
static SINGLETON: OnceLock<Mutex<Option<Inner>>> = OnceLock::new();
fn neo4j_mutex() -> &'static Mutex<Option<Inner>> {
SINGLETON.get_or_init(|| Mutex::new(None))
}
pub struct Neo4jScope {
pub database: String
}
impl Neo4jScope {
pub fn cleanup_query(&self) -> (String, String) {
(
"MATCH (n) WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix) DETACH DELETE n"
.to_string(),
self.database.clone()
)
}
pub fn label(&self, base: &str) -> String {
format!("{}_{base}", self.database)
}
pub async fn drop_database(&self) -> Result<()> {
Ok(())
}
}
pub struct Neo4jContainer;
impl MaeContainer for Neo4jContainer {
type Scope = Neo4jScope;
async fn start() -> Option<()> {
neo4j_singleton().await.lock().await.as_ref().map(|_| ())
}
async fn scope() -> Result<Neo4jScope> {
spawn_scoped_database().await
}
async fn teardown() {
teardown().await;
}
}
pub async fn get_neo4j_bolt() -> Result<(String, u16)> {
ensure_started().await?;
let guard = neo4j_mutex().lock().await;
let c = guard.as_ref().context("neo4j container is not running — this is a bug")?;
Ok((c.bolt_url.clone(), c.bolt_port))
}
pub async fn neo4j_singleton() -> &'static Mutex<Option<Inner>> {
let _ = ensure_started().await;
neo4j_mutex()
}
pub async fn spawn_scoped_database() -> Result<Neo4jScope> {
ensure_started().await?;
let guard = neo4j_mutex().lock().await;
let _inner = guard.as_ref().context("Neo4j container not running — this is a bug")?;
let id = Uuid::new_v4().to_string().replace('-', "")[..8].to_string();
Ok(Neo4jScope { database: format!("mae_test_{id}") })
}
pub async fn teardown() {
let mut guard = neo4j_mutex().lock().await;
if let Some(c) = guard.take() {
let _ = c.container.stop().await;
drop(c);
}
}
async fn ensure_started() -> Result<()> {
if !testcontainers_enabled() {
bail!(
"testcontainers disabled — set MAE_TESTCONTAINERS=1 to run Neo4j tests. \
In Concourse, ensure the task has Docker socket access (DinD)."
);
}
let mut guard = neo4j_mutex().lock().await;
if guard.is_none() {
let container: ContainerAsync<Neo4jImage> = Neo4j::new()
.with_password("testpassword")
.with_startup_timeout(Duration::from_secs(120))
.start()
.await
.context("failed to start Neo4j container")?;
let bolt_port = container
.image()
.bolt_port_ipv4()
.context("failed to get Neo4j bolt port from image")?;
let bolt_url = format!("bolt://localhost:{bolt_port}");
*guard = Some(Inner { container, bolt_port, bolt_url });
}
Ok(())
}