testcontainers_modules/neo4j/
mod.rs1use std::{
2 borrow::Cow,
3 collections::{BTreeSet, HashMap},
4 sync::RwLock,
5};
6
7use testcontainers::{
8 core::{ContainerState, IntoContainerPort, WaitFor},
9 ContainerRequest, Image, TestcontainersError,
10};
11
12#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
15#[non_exhaustive]
16pub enum Neo4jLabsPlugin {
17 Apoc,
27 ApocCore,
38 Bloom,
41 Streams,
53 GraphDataScience,
56 NeoSemantics,
62 Custom(String),
64}
65
66impl std::fmt::Display for Neo4jLabsPlugin {
67 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::Apoc => formatter.pad("apoc"),
70 Self::ApocCore => formatter.pad("apoc-core"),
71 Self::Bloom => formatter.pad("bloom"),
72 Self::Streams => formatter.pad("streams"),
73 Self::GraphDataScience => formatter.pad("graph-data-science"),
74 Self::NeoSemantics => formatter.pad("n10s"),
75 Self::Custom(plugin_name) => formatter.pad(plugin_name),
76 }
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct Neo4j {
132 version: Value,
133 user: Option<Value>,
134 pass: Option<Value>,
135 plugins: BTreeSet<Neo4jLabsPlugin>,
136}
137
138impl Neo4j {
139 const DEFAULT_USER: &'static str = "neo4j";
140 const DEFAULT_PASS: &'static str = "password";
141 const DEFAULT_VERSION_TAG: &'static str = "5";
142
143 #[must_use]
145 pub fn new() -> Self {
146 Self {
147 version: Cow::Borrowed(Self::DEFAULT_VERSION_TAG),
148 user: Some(Cow::Borrowed(Self::DEFAULT_USER)),
149 pass: Some(Cow::Borrowed(Self::DEFAULT_PASS)),
150 plugins: BTreeSet::new(),
151 }
152 }
153
154 pub fn with_version(mut self, version: impl Into<Value>) -> Self {
157 self.version = version.into();
158 self
159 }
160
161 #[must_use]
163 pub fn with_user(mut self, user: impl Into<Value>) -> Self {
164 self.user = Some(user.into());
165 self
166 }
167
168 #[must_use]
170 pub fn with_password(mut self, pass: impl Into<Value>) -> Self {
171 self.pass = Some(pass.into());
172 self
173 }
174
175 pub fn without_authentication(mut self) -> Self {
180 self.user = None;
181 self.pass = None;
182 self
183 }
184
185 #[must_use]
187 pub fn with_neo4j_labs_plugin(mut self, plugins: &[Neo4jLabsPlugin]) -> Self {
188 self.plugins.extend(plugins.iter().cloned());
189 self
190 }
191}
192
193type Value = Cow<'static, str>;
194
195impl Default for Neo4j {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201pub struct Neo4jImage {
203 version: String,
204 auth: Option<(String, String)>,
205 env_vars: HashMap<String, String>,
206 state: RwLock<Option<ContainerState>>,
207}
208
209impl Neo4jImage {
210 #[must_use]
212 pub fn version(&self) -> &str {
213 &self.version
214 }
215
216 #[must_use]
219 pub fn auth(&self) -> Option<(&str, &str)> {
220 self.auth
221 .as_ref()
222 .map(|(user, pass)| (user.as_str(), pass.as_str()))
223 }
224
225 #[must_use]
228 pub fn user(&self) -> Option<&str> {
229 self.auth().map(|(user, _)| user)
230 }
231
232 #[must_use]
235 pub fn password(&self) -> Option<&str> {
236 self.auth().map(|(_, pass)| pass)
237 }
238
239 pub fn bolt_port_ipv4(&self) -> Result<u16, TestcontainersError> {
241 self.state
242 .read()
243 .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
244 .as_ref()
245 .ok_or_else(|| {
246 TestcontainersError::other("Container must be started before port can be retrieved")
247 })?
248 .host_port_ipv4(7687.tcp())
249 }
250
251 pub fn bolt_port_ipv6(&self) -> Result<u16, TestcontainersError> {
253 self.state
254 .read()
255 .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
256 .as_ref()
257 .ok_or_else(|| {
258 TestcontainersError::other("Container must be started before port can be retrieved")
259 })?
260 .host_port_ipv6(7687.tcp())
261 }
262
263 pub fn http_port_ipv4(&self) -> Result<u16, TestcontainersError> {
265 self.state
266 .read()
267 .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
268 .as_ref()
269 .ok_or_else(|| {
270 TestcontainersError::other("Container must be started before port can be retrieved")
271 })?
272 .host_port_ipv4(7474.tcp())
273 }
274
275 pub fn http_port_ipv6(&self) -> Result<u16, TestcontainersError> {
277 self.state
278 .read()
279 .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
280 .as_ref()
281 .ok_or_else(|| {
282 TestcontainersError::other("Container must be started before port can be retrieved")
283 })?
284 .host_port_ipv6(7474.tcp())
285 }
286}
287
288impl Image for Neo4jImage {
289 fn name(&self) -> &str {
290 "neo4j"
291 }
292
293 fn tag(&self) -> &str {
294 &self.version
295 }
296
297 fn ready_conditions(&self) -> Vec<WaitFor> {
298 vec![
299 WaitFor::message_on_stdout("Bolt enabled on"),
300 WaitFor::message_on_stdout("Started."),
301 ]
302 }
303
304 fn env_vars(
305 &self,
306 ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
307 &self.env_vars
308 }
309
310 fn exec_after_start(
311 &self,
312 cs: ContainerState,
313 ) -> Result<Vec<testcontainers::core::ExecCommand>, TestcontainersError> {
314 self.state
315 .write()
316 .map_err(|_| TestcontainersError::other("failed to lock the sate of Neo4J"))?
317 .replace(cs);
318 Ok(Vec::new())
319 }
320}
321
322impl Neo4j {
323 fn auth_env(&self) -> impl IntoIterator<Item = (String, String)> {
324 let auth = self
325 .user
326 .as_ref()
327 .and_then(|user| self.pass.as_ref().map(|pass| format!("{user}/{pass}")))
328 .unwrap_or_else(|| "none".to_owned());
329 Some(("NEO4J_AUTH".to_owned(), auth))
330 }
331
332 fn plugins_env(&self) -> impl IntoIterator<Item = (String, String)> {
333 if self.plugins.is_empty() {
334 return None;
335 }
336
337 let plugin_names = self
338 .plugins
339 .iter()
340 .map(|p| format!("\"{p}\""))
341 .collect::<Vec<String>>()
342 .join(",");
343
344 let plugin_definition = format!("[{plugin_names}]");
345
346 Some(("NEO4JLABS_PLUGINS".to_owned(), plugin_definition))
347 }
348
349 fn conf_env(&self) -> impl IntoIterator<Item = (String, String)> {
350 let pass = self.pass.as_ref()?;
351
352 if pass.len() < 8 {
353 Some((
354 "NEO4J_dbms_security_auth__minimum__password__length".to_owned(),
355 pass.len().to_string(),
356 ))
357 } else {
358 None
359 }
360 }
361
362 fn build(self) -> Neo4jImage {
363 let mut env_vars = HashMap::new();
364
365 for (key, value) in self.auth_env() {
366 env_vars.insert(key, value);
367 }
368
369 for (key, value) in self.plugins_env() {
370 env_vars.insert(key, value);
371 }
372
373 for (key, value) in self.conf_env() {
374 env_vars.insert(key, value);
375 }
376
377 let auth = self
378 .user
379 .and_then(|user| self.pass.map(|pass| (user.into_owned(), pass.into_owned())));
380
381 let version = self.version.into_owned();
382
383 Neo4jImage {
384 version,
385 auth,
386 env_vars,
387 state: RwLock::new(None),
388 }
389 }
390}
391
392impl From<Neo4j> for Neo4jImage {
393 fn from(neo4j: Neo4j) -> Self {
394 neo4j.build()
395 }
396}
397
398impl From<Neo4j> for ContainerRequest<Neo4jImage> {
399 fn from(neo4j: Neo4j) -> Self {
400 Self::from(neo4j.build())
401 }
402}
403
404impl std::fmt::Debug for Neo4jImage {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 f.debug_struct("Neo4jImage")
407 .field("version", &self.version)
408 .field("auth", &self.auth())
409 .field("env_vars", &self.env_vars)
410 .finish()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use neo4rs::Graph;
417
418 use super::*;
419 use crate::testcontainers::runners::AsyncRunner;
420
421 #[test]
422 fn set_valid_version() {
423 let neo4j = Neo4j::new().with_version("4.2.0").build();
424 assert_eq!(neo4j.version, "4.2.0");
425 }
426
427 #[test]
428 fn set_partial_version() {
429 let neo4j = Neo4j::new().with_version("4.2").build();
430 assert_eq!(neo4j.version, "4.2");
431
432 let neo4j = Neo4j::new().with_version("4").build();
433 assert_eq!(neo4j.version, "4");
434 }
435
436 #[test]
437 fn set_user() {
438 let neo4j = Neo4j::new().with_user("Benutzer").build();
439 assert_eq!(neo4j.user(), Some("Benutzer"));
440 assert_eq!(neo4j.auth(), Some(("Benutzer", "password")));
441 assert_eq!(
442 neo4j.env_vars.get("NEO4J_AUTH").unwrap(),
443 "Benutzer/password"
444 );
445 }
446
447 #[test]
448 fn set_password() {
449 let neo4j = Neo4j::new().with_password("Passwort").build();
450 assert_eq!(neo4j.password(), Some("Passwort"));
451 assert_eq!(neo4j.auth(), Some(("neo4j", "Passwort")));
452 assert_eq!(neo4j.env_vars.get("NEO4J_AUTH").unwrap(), "neo4j/Passwort");
453 }
454
455 #[test]
456 fn set_short_password() {
457 let neo4j = Neo4j::new().with_password("1337").build();
458 assert_eq!(neo4j.password(), Some("1337"));
459 assert_eq!(neo4j.auth(), Some(("neo4j", "1337")));
460 assert_eq!(
461 neo4j
462 .env_vars
463 .get("NEO4J_dbms_security_auth__minimum__password__length")
464 .unwrap(),
465 "4"
466 );
467 }
468
469 #[test]
470 fn disable_auth() {
471 let neo4j = Neo4j::new().without_authentication().build();
472 assert_eq!(neo4j.password(), None);
473 assert_eq!(neo4j.user(), None);
474 assert_eq!(neo4j.auth(), None);
475 assert_eq!(neo4j.env_vars.get("NEO4J_AUTH").unwrap(), "none");
476 }
477
478 #[test]
479 fn single_plugin_definition() {
480 let neo4j = Neo4j::new()
481 .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
482 .build();
483 assert_eq!(
484 neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
485 "[\"apoc\"]"
486 );
487 }
488
489 #[test]
490 fn multiple_plugin_definition() {
491 let neo4j = Neo4j::new()
492 .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc, Neo4jLabsPlugin::Bloom])
493 .build();
494 assert_eq!(
495 neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
496 "[\"apoc\",\"bloom\"]"
497 );
498 }
499
500 #[test]
501 fn multiple_wiht_plugin_calls() {
502 let neo4j = Neo4j::new()
503 .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
504 .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Bloom])
505 .with_neo4j_labs_plugin(&[Neo4jLabsPlugin::Apoc])
506 .build();
507 assert_eq!(
508 neo4j.env_vars.get("NEO4JLABS_PLUGINS").unwrap(),
509 "[\"apoc\",\"bloom\"]"
510 );
511 }
512
513 #[tokio::test]
514 async fn it_works() -> Result<(), Box<dyn std::error::Error + 'static>> {
515 let container = Neo4j::default().start().await?;
516
517 let uri = format!(
518 "bolt://{}:{}",
519 container.get_host().await?,
520 container.image().bolt_port_ipv4()?
521 );
522
523 let auth_user = container.image().user().expect("default user");
524 let auth_pass = container.image().password().expect("default password");
525
526 let graph = Graph::new(uri, auth_user, auth_pass).await.unwrap();
527 let mut result = graph.execute(neo4rs::query("RETURN 1")).await.unwrap();
528 let row = result.next().await.unwrap().unwrap();
529 let value: i64 = row.get("1").unwrap();
530 assert_eq!(1, value);
531 Ok(())
532 }
533}