//! Integration tests using FilesystemBackend (no PostgreSQL required). use std::net::SocketAddr; use aws_credential_types::Credentials; use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart}; use aws_sdk_s3::Client; use post3::FilesystemBackend; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; struct FsTestServer { client: Client, cancel: CancellationToken, _tmpdir: tempfile::TempDir, } impl FsTestServer { async fn start() -> Self { let tmpdir = tempfile::tempdir().unwrap(); let backend = FilesystemBackend::new(tmpdir.path()); let state = post3_server::state::State { store: backend }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr: SocketAddr = listener.local_addr().unwrap(); let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let router = post3_server::s3::router::build_router(state); tokio::spawn(async move { axum::serve(listener, router.into_make_service()) .with_graceful_shutdown(async move { cancel_clone.cancelled().await; }) .await .unwrap(); }); let creds = Credentials::new("test", "test", None, None, "test"); let config = aws_sdk_s3::Config::builder() .behavior_version_latest() .region(aws_types::region::Region::new("us-east-1")) .endpoint_url(format!("http://{}", addr)) .credentials_provider(creds) .force_path_style(true) .build(); let client = Client::from_conf(config); Self { client, cancel, _tmpdir: tmpdir, } } async fn shutdown(self) { self.cancel.cancel(); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } } // --- Tests --- #[tokio::test] async fn test_fs_bucket_crud() { let server = FsTestServer::start().await; let c = &server.client; // Create c.create_bucket().bucket("my-bucket").send().await.unwrap(); // Head c.head_bucket().bucket("my-bucket").send().await.unwrap(); // List let resp = c.list_buckets().send().await.unwrap(); let names: Vec<_> = resp .buckets() .iter() .filter_map(|b| b.name()) .collect(); assert!(names.contains(&"my-bucket")); // Delete c.delete_bucket().bucket("my-bucket").send().await.unwrap(); // Verify gone let result = c.head_bucket().bucket("my-bucket").send().await; assert!(result.is_err()); server.shutdown().await; } #[tokio::test] async fn test_fs_put_get_delete() { let server = FsTestServer::start().await; let c = &server.client; c.create_bucket().bucket("test").send().await.unwrap(); // Put c.put_object() .bucket("test") .key("hello.txt") .content_type("text/plain") .body(aws_sdk_s3::primitives::ByteStream::from_static( b"hello world", )) .send() .await .unwrap(); // Get let resp = c .get_object() .bucket("test") .key("hello.txt") .send() .await .unwrap(); let body = resp.body.collect().await.unwrap().into_bytes(); assert_eq!(body.as_ref(), b"hello world"); // Head let head = c .head_object() .bucket("test") .key("hello.txt") .send() .await .unwrap(); assert_eq!(head.content_length(), Some(11)); assert_eq!(head.content_type(), Some("text/plain")); // Delete c.delete_object() .bucket("test") .key("hello.txt") .send() .await .unwrap(); // Verify gone let result = c .get_object() .bucket("test") .key("hello.txt") .send() .await; assert!(result.is_err()); // Cleanup c.delete_bucket().bucket("test").send().await.unwrap(); server.shutdown().await; } #[tokio::test] async fn test_fs_list_objects() { let server = FsTestServer::start().await; let c = &server.client; c.create_bucket().bucket("test").send().await.unwrap(); for i in 0..5 { c.put_object() .bucket("test") .key(format!("item-{i:02}")) .body(aws_sdk_s3::primitives::ByteStream::from_static(b"data")) .send() .await .unwrap(); } // List all let resp = c .list_objects_v2() .bucket("test") .send() .await .unwrap(); assert_eq!(resp.key_count(), Some(5)); // List with prefix let resp = c .list_objects_v2() .bucket("test") .prefix("item-03") .send() .await .unwrap(); assert_eq!(resp.key_count(), Some(1)); // Cleanup for i in 0..5 { c.delete_object() .bucket("test") .key(format!("item-{i:02}")) .send() .await .unwrap(); } c.delete_bucket().bucket("test").send().await.unwrap(); server.shutdown().await; } #[tokio::test] async fn test_fs_user_metadata() { let server = FsTestServer::start().await; let c = &server.client; c.create_bucket().bucket("test").send().await.unwrap(); c.put_object() .bucket("test") .key("meta.txt") .metadata("author", "test-user") .metadata("version", "1") .body(aws_sdk_s3::primitives::ByteStream::from_static(b"data")) .send() .await .unwrap(); let head = c .head_object() .bucket("test") .key("meta.txt") .send() .await .unwrap(); let meta = head.metadata().unwrap(); assert_eq!(meta.get("author").unwrap(), "test-user"); assert_eq!(meta.get("version").unwrap(), "1"); // Cleanup c.delete_object() .bucket("test") .key("meta.txt") .send() .await .unwrap(); c.delete_bucket().bucket("test").send().await.unwrap(); server.shutdown().await; } #[tokio::test] async fn test_fs_multipart_upload() { let server = FsTestServer::start().await; let c = &server.client; c.create_bucket().bucket("test").send().await.unwrap(); // Create multipart upload let create = c .create_multipart_upload() .bucket("test") .key("big.bin") .send() .await .unwrap(); let upload_id = create.upload_id().unwrap(); // Upload parts (non-last parts must be >= 5 MB per S3 spec) let min_part = 5 * 1024 * 1024; let part1 = c .upload_part() .bucket("test") .key("big.bin") .upload_id(upload_id) .part_number(1) .body(aws_sdk_s3::primitives::ByteStream::from(vec![0xAAu8; min_part])) .send() .await .unwrap(); let part2 = c .upload_part() .bucket("test") .key("big.bin") .upload_id(upload_id) .part_number(2) .body(aws_sdk_s3::primitives::ByteStream::from(vec![0xBBu8; 1024])) .send() .await .unwrap(); // Complete let completed = CompletedMultipartUpload::builder() .parts( CompletedPart::builder() .part_number(1) .e_tag(part1.e_tag().unwrap()) .build(), ) .parts( CompletedPart::builder() .part_number(2) .e_tag(part2.e_tag().unwrap()) .build(), ) .build(); let complete_resp = c .complete_multipart_upload() .bucket("test") .key("big.bin") .upload_id(upload_id) .multipart_upload(completed) .send() .await .unwrap(); // Verify compound ETag let etag = complete_resp.e_tag().unwrap(); assert!(etag.contains("-2"), "Expected compound ETag, got: {etag}"); // Verify data let resp = c .get_object() .bucket("test") .key("big.bin") .send() .await .unwrap(); let body = resp.body.collect().await.unwrap().into_bytes(); assert_eq!(body.len(), min_part + 1024); assert!(body[..min_part].iter().all(|b| *b == 0xAA)); assert!(body[min_part..].iter().all(|b| *b == 0xBB)); // Cleanup c.delete_object() .bucket("test") .key("big.bin") .send() .await .unwrap(); c.delete_bucket().bucket("test").send().await.unwrap(); server.shutdown().await; } #[tokio::test] async fn test_fs_abort_multipart() { let server = FsTestServer::start().await; let c = &server.client; c.create_bucket().bucket("test").send().await.unwrap(); let create = c .create_multipart_upload() .bucket("test") .key("aborted.bin") .send() .await .unwrap(); let upload_id = create.upload_id().unwrap(); // Upload a part c.upload_part() .bucket("test") .key("aborted.bin") .upload_id(upload_id) .part_number(1) .body(aws_sdk_s3::primitives::ByteStream::from(vec![0u8; 100])) .send() .await .unwrap(); // Abort c.abort_multipart_upload() .bucket("test") .key("aborted.bin") .upload_id(upload_id) .send() .await .unwrap(); // Verify no object was created let result = c .get_object() .bucket("test") .key("aborted.bin") .send() .await; assert!(result.is_err()); c.delete_bucket().bucket("test").send().await.unwrap(); server.shutdown().await; }