Skip to content

Commit 1173097

Browse files
committed
test: add integration tests
1 parent da60931 commit 1173097

15 files changed

+1261
-41
lines changed

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ tracing-subscriber = { version = "~0.3", features = ["env-filter"] }
1212

1313
# HTTP and body handling
1414
bytes = "~1.11"
15-
http = "~1.3"
1615
http-body = "~1.0"
1716
http-body-util = "~0.1"
1817
tower-http = { version = "~0.6", features = ["trace"] }

src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Library exports for integration tests
2+
pub mod app_state;
3+
pub mod auth;
4+
pub mod config;
5+
pub mod handlers;
6+
pub mod server;
7+
pub mod storage;
8+
pub mod types;
9+
10+
// Re-export commonly used types
11+
pub use app_state::AppState;
12+
pub use auth::CredentialsStore;
13+
pub use config::{BackendConfig, Config};
14+
pub use storage::{InMemoryStorage, MultiBackend, S3Backend, StorageBackend};
15+
pub use types::Credentials;
16+
17+
// Re-export server creation function
18+
pub use server::create_app;

src/main.rs

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,19 @@ mod handlers;
55
mod storage;
66
mod types;
77

8+
mod server;
9+
810
use app_state::AppState;
9-
use auth::{CredentialsStore, auth_middleware};
11+
use auth::CredentialsStore;
1012
use config::{BackendConfig, Config};
11-
use handlers::{
12-
delete_object, get_object, head_bucket, head_object, list_objects, not_found, put_object,
13-
};
1413
use storage::{
1514
InMemoryStorage, MultiBackend, S3Backend, StorageBackend, determine_primary_by_latency,
1615
};
1716
use types::Credentials;
1817

19-
use axum::{
20-
Router,
21-
extract::Request,
22-
middleware::{self, Next},
23-
routing::get,
24-
};
2518
use clap::Parser;
2619
use std::collections::HashMap;
2720
use std::sync::Arc;
28-
use tower_http::trace::TraceLayer;
2921

3022
// Server configuration
3123
const HOST: &str = "0.0.0.0";
@@ -201,34 +193,8 @@ async fn main() {
201193
// Create shared app state
202194
let app_state = AppState::new(storage, credentials_store, bucket_name.clone());
203195

204-
// Build router with S3 API endpoints using the bucket name as a constant path
205-
let bucket_path = format!("/{}", bucket_name);
206-
let bucket_path_with_slash = format!("/{}/", bucket_name);
207-
let object_path = format!("/{}/{{*key}}", bucket_name);
208-
209-
let app = Router::new()
210-
// Object operations: /{bucket_name}/{key}
211-
.route(
212-
&object_path,
213-
get(get_object)
214-
.put(put_object)
215-
.delete(delete_object)
216-
.head(head_object),
217-
)
218-
// Bucket operations: /{bucket_name} and /{bucket_name}/
219-
.route(&bucket_path, get(list_objects).head(head_bucket))
220-
.route(&bucket_path_with_slash, get(list_objects).head(head_bucket))
221-
// Fallback for 404 Not Found
222-
.fallback(not_found)
223-
// Add shared state
224-
.with_state(app_state.clone())
225-
// Add authentication middleware (captures app_state)
226-
.layer(middleware::from_fn(move |request: Request, next: Next| {
227-
let state = app_state.clone();
228-
async move { auth_middleware(state, request, next).await }
229-
}))
230-
// Add tracing
231-
.layer(TraceLayer::new_for_http());
196+
// Create the application router using the shared create_app function
197+
let app = server::create_app(app_state, bucket_name.clone());
232198

233199
// Start server
234200
let addr = format!("{}:{}", cli.host, cli.port);

src/server.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use crate::{app_state::AppState, auth, handlers};
2+
use axum::{
3+
Router,
4+
extract::Request,
5+
middleware::{self, Next},
6+
routing::get,
7+
};
8+
use tower_http::trace::TraceLayer;
9+
10+
/// Create the application router with all routes and middleware
11+
///
12+
/// This function is used by both main.rs and integration tests to ensure
13+
/// the same server configuration is used in both production and tests.
14+
pub fn create_app(app_state: AppState, bucket_name: String) -> Router {
15+
use handlers::{
16+
delete_object, get_object, head_bucket, head_object, list_objects, not_found, put_object,
17+
};
18+
19+
let bucket_path = format!("/{}", bucket_name);
20+
let bucket_path_with_slash = format!("/{}/", bucket_name);
21+
let object_path = format!("/{}/{{*key}}", bucket_name);
22+
23+
Router::new()
24+
// Object operations: /{bucket_name}/{key}
25+
.route(
26+
&object_path,
27+
get(get_object)
28+
.put(put_object)
29+
.delete(delete_object)
30+
.head(head_object),
31+
)
32+
// Bucket operations: /{bucket_name} and /{bucket_name}/
33+
.route(&bucket_path, get(list_objects).head(head_bucket))
34+
.route(&bucket_path_with_slash, get(list_objects).head(head_bucket))
35+
// Fallback for 404 Not Found
36+
.fallback(not_found)
37+
// Add shared state
38+
.with_state(app_state.clone())
39+
// Add authentication middleware (captures app_state)
40+
.layer(middleware::from_fn(move |request: Request, next: Next| {
41+
let state = app_state.clone();
42+
async move { auth::auth_middleware(state, request, next).await }
43+
}))
44+
// Add tracing
45+
.layer(TraceLayer::new_for_http())
46+
}

src/storage/in_memory.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ struct StoredObject {
1818
metadata: ObjectMetadata,
1919
}
2020

21+
impl Default for InMemoryStorage {
22+
fn default() -> Self {
23+
Self::new()
24+
}
25+
}
26+
2127
impl InMemoryStorage {
2228
pub fn new() -> Self {
2329
Self {

tests/helpers/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
pub mod test_server;
2+
3+
pub use test_server::TestServer;
4+
5+
// Test constants
6+
pub const TEST_ACCESS_KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE";
7+
pub const TEST_SECRET_ACCESS_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
8+
pub const TEST_BUCKET: &str = "test-bucket";

tests/helpers/test_server.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
use aws_sdk_s3::Client as S3Client;
2+
use aws_sdk_s3::config::Credentials as AwsCredentials;
3+
use replicat4::{
4+
AppState, Credentials, CredentialsStore, InMemoryStorage, StorageBackend, create_app,
5+
};
6+
use std::collections::HashMap;
7+
use std::sync::Arc;
8+
use tokio::task::JoinHandle;
9+
10+
/// Test server handle that automatically shuts down on drop
11+
///
12+
/// This starts a real HTTP server on a random port for integration testing.
13+
/// The server uses the actual production code via create_app().
14+
/// It also provides an AWS S3 client configured to communicate with the test server.
15+
pub struct TestServer {
16+
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
17+
#[allow(dead_code)] // Keep handle alive to prevent task abort
18+
handle: JoinHandle<()>,
19+
pub client: S3Client,
20+
pub bucket_name: String,
21+
}
22+
23+
impl TestServer {
24+
/// Start a test server with in-memory storage and return an S3 client
25+
pub async fn start(
26+
bucket_name: String,
27+
access_key_id: String,
28+
secret_access_key: String,
29+
) -> Self {
30+
// Create in-memory storage backend
31+
let storage: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new());
32+
33+
// Create credentials store with test credentials
34+
let mut credentials_map = HashMap::new();
35+
credentials_map.insert(
36+
access_key_id.clone(),
37+
Credentials {
38+
_access_key_id: access_key_id.clone(),
39+
secret_access_key: secret_access_key.clone(),
40+
},
41+
);
42+
let credentials_store = CredentialsStore::new(credentials_map);
43+
44+
// Create app state
45+
let app_state = AppState::new(storage, credentials_store, bucket_name.clone());
46+
47+
// Use the ACTUAL production create_app function
48+
let app = create_app(app_state, bucket_name.clone());
49+
50+
// Bind to a random available port
51+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
52+
let addr = listener.local_addr().unwrap();
53+
54+
// Create shutdown channel
55+
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
56+
57+
// Spawn server task
58+
let handle = tokio::spawn(async move {
59+
axum::serve(listener, app)
60+
.with_graceful_shutdown(async {
61+
shutdown_rx.await.ok();
62+
})
63+
.await
64+
.unwrap();
65+
});
66+
67+
// Give the server a moment to start
68+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
69+
70+
// Create AWS S3 client configured to point to our test server
71+
let endpoint_url = format!("http://{}", addr);
72+
let creds = AwsCredentials::new(access_key_id, secret_access_key, None, None, "test");
73+
74+
let config = aws_sdk_s3::config::Builder::new()
75+
.behavior_version_latest()
76+
.credentials_provider(creds)
77+
.region(aws_sdk_s3::config::Region::new("us-east-1"))
78+
.endpoint_url(&endpoint_url)
79+
.force_path_style(true)
80+
.build();
81+
82+
let client = S3Client::from_conf(config);
83+
84+
TestServer {
85+
shutdown_tx: Some(shutdown_tx),
86+
handle,
87+
client,
88+
bucket_name,
89+
}
90+
}
91+
}
92+
93+
impl Drop for TestServer {
94+
fn drop(&mut self) {
95+
// Signal shutdown (ignore errors if already shut down)
96+
if let Some(tx) = self.shutdown_tx.take() {
97+
let _ = tx.send(());
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)