aster_forge_test/
redis.rs

1//! Shared reusable Redis container for integration tests.
2//!
3//! The container is shared by suite name and reused across runs, so data persists between test
4//! processes. Tests should use unique key prefixes or clean up after themselves.
5
6use crate::state::{ContainerLease, ContainerStateLock};
7use crate::suite::TestContainerSuite;
8use crate::wait::wait_until;
9use aster_forge_utils::url::url_with_credentials;
10use std::net::TcpListener;
11use std::time::Duration;
12use testcontainers::core::{ContainerAsync, IntoContainerPort};
13use testcontainers::{GenericImage, ImageExt, ReuseDirective, runners::AsyncRunner};
14
15/// Handle to the suite's shared Redis container.
16pub struct RedisTestContainer {
17    url: String,
18    container: ContainerAsync<GenericImage>,
19    _lease: ContainerLease,
20}
21
22/// Isolated Redis container configured with a caller-provided raw password.
23///
24/// Unlike [`RedisTestContainer`], this fixture is not shared or reused because authentication is
25/// process-wide Redis state. The exposed base URL never contains the password.
26pub struct AuthenticatedRedisTestContainer {
27    base_url: String,
28    _container: ContainerAsync<GenericImage>,
29}
30
31impl AuthenticatedRedisTestContainer {
32    /// Starts an isolated Redis server that requires `password`.
33    ///
34    /// # Panics
35    ///
36    /// Panics when the container or port cannot be created, the credential URL is invalid, or
37    /// Redis does not become ready before the timeout.
38    pub async fn start(password: &str) -> Self {
39        let container = GenericImage::new("redis", "7-alpine")
40            .with_exposed_port(IntoContainerPort::tcp(6379))
41            .with_cmd(["redis-server", "--requirepass", password])
42            .start()
43            .await
44            .expect("failed to start authenticated Redis test container");
45        let port = container
46            .get_host_port_ipv4(IntoContainerPort::tcp(6379))
47            .await
48            .expect("authenticated Redis test port should be exposed");
49        let base_url = format!("redis://127.0.0.1:{port}/0");
50        let credential_url = url_with_credentials(
51            &base_url,
52            None,
53            Some(password),
54            "authenticated Redis test container base URL",
55        )
56        .expect("authenticated Redis test URL should accept credentials");
57        let client = redis::Client::open(credential_url)
58            .unwrap_or_else(|_| panic!("failed to build authenticated Redis readiness client"));
59        wait_for_redis(&client, "authenticated Redis test container").await;
60
61        Self {
62            base_url,
63            _container: container,
64        }
65    }
66
67    /// Returns the Redis base URL without userinfo.
68    #[must_use]
69    pub fn base_url(&self) -> &str {
70        &self.base_url
71    }
72}
73
74impl RedisTestContainer {
75    /// Starts (or reuses) the shared Redis container and waits for it to accept connections.
76    ///
77    /// # Panics
78    ///
79    /// Panics when shared state, port reservation, container startup, endpoint construction, or
80    /// Redis readiness fails.
81    pub async fn start(suite: &TestContainerSuite) -> Self {
82        // Keep the host port fixed across stop/start. Docker assigns a new ephemeral port to a
83        // container whose mapping leaves HostPort empty, stranding already-running processes on
84        // the old Redis endpoint after a restart.
85        let lock = ContainerStateLock::acquire(suite, "redis-fixed");
86        let mut state = lock.load();
87        let _ = state.prune_stale_before_current_execution();
88        state.register_current_process();
89        lock.save(&state);
90        let host_port = TcpListener::bind(("127.0.0.1", 0))
91            .expect("reserve Redis test host port")
92            .local_addr()
93            .expect("resolve Redis test host port")
94            .port();
95
96        let container = GenericImage::new("redis", "7-alpine")
97            .with_mapped_port(host_port, IntoContainerPort::tcp(6379))
98            .with_container_name(suite.container_name("redis-fixed"))
99            .with_reuse(ReuseDirective::Always)
100            .start()
101            .await
102            .expect("failed to start Redis test container");
103        let port = container
104            .get_host_port_ipv4(IntoContainerPort::tcp(6379))
105            .await
106            .expect("Redis test port should be exposed");
107
108        let url = format!("redis://127.0.0.1:{port}/0");
109        let client = redis::Client::open(url.as_str())
110            .unwrap_or_else(|_| panic!("failed to build Redis readiness client"));
111        wait_for_redis(&client, "Redis test container").await;
112        drop(lock);
113
114        Self {
115            url,
116            container,
117            _lease: ContainerLease::new(suite.clone(), "redis-fixed"),
118        }
119    }
120
121    /// Returns the Redis URL, for example `redis://127.0.0.1:6379/0`.
122    #[must_use]
123    pub fn url(&self) -> &str {
124        &self.url
125    }
126
127    /// Stops Redis immediately to simulate a broker outage.
128    ///
129    /// # Panics
130    ///
131    /// Panics when the container runtime fails to stop Redis.
132    pub async fn stop(&self) {
133        self.container
134            .stop_with_timeout(Some(0))
135            .await
136            .expect("failed to stop Redis test container");
137    }
138
139    /// Restarts a previously stopped Redis container.
140    ///
141    /// # Panics
142    ///
143    /// Panics when restart, client construction, or the Redis readiness check fails.
144    pub async fn restart(&self) {
145        self.container
146            .start()
147            .await
148            .expect("failed to restart Redis test container");
149        let client = redis::Client::open(self.url.as_str())
150            .unwrap_or_else(|_| panic!("failed to build restarted Redis readiness client"));
151        wait_for_redis(&client, "restarted Redis test container").await;
152    }
153}
154
155async fn wait_for_redis(client: &redis::Client, context: &str) {
156    let ready = wait_until(
157        Duration::from_secs(90),
158        Duration::from_millis(250),
159        || async {
160            let Ok(mut connection) = client.get_multiplexed_async_connection().await else {
161                return false;
162            };
163            redis::cmd("PING")
164                .query_async::<String>(&mut connection)
165                .await
166                .is_ok_and(|response| response == "PONG")
167        },
168    )
169    .await;
170    assert!(ready, "{context} did not answer PING before timeout");
171}