diff --git a/crates/s3s/src/dto/range.rs b/crates/s3s/src/dto/range.rs index 059a555a..2355fd30 100644 --- a/crates/s3s/src/dto/range.rs +++ b/crates/s3s/src/dto/range.rs @@ -237,4 +237,73 @@ mod tests { } } } + + #[test] + fn to_header_string_int_inclusive() { + let range = range_int_inclusive(0, 499); + assert_eq!(range.to_header_string(), "bytes=0-499"); + } + + #[test] + fn to_header_string_int_from() { + let range = range_int_from(9500); + assert_eq!(range.to_header_string(), "bytes=9500-"); + } + + #[test] + fn to_header_string_suffix() { + let range = range_suffix(500); + assert_eq!(range.to_header_string(), "bytes=-500"); + } + + #[test] + fn to_header_string_roundtrip() { + let cases = [range_int_inclusive(0, 499), range_int_from(9500), range_suffix(500)]; + for range in &cases { + let header = range.to_header_string(); + let parsed = Range::parse(&header).unwrap(); + assert_eq!(*range, parsed); + } + } + + #[test] + fn try_from_header_value() { + use crate::http::TryFromHeaderValue; + let hv = http::HeaderValue::from_static("bytes=0-499"); + let range = Range::try_from_header_value(&hv).unwrap(); + assert_eq!(range, range_int_inclusive(0, 499)); + + let hv = http::HeaderValue::from_static("bytes=100-"); + let range = Range::try_from_header_value(&hv).unwrap(); + assert_eq!(range, range_int_from(100)); + + let hv = http::HeaderValue::from_static("bytes=-500"); + let range = Range::try_from_header_value(&hv).unwrap(); + assert_eq!(range, range_suffix(500)); + } + + #[test] + fn range_not_satisfiable_to_s3_error() { + let err = RangeNotSatisfiable { _priv: () }; + let s3_err: S3Error = err.into(); + let code = s3_err.code(); + assert_eq!(code, &S3ErrorCode::InvalidRange); + } + + #[test] + fn parse_first_exceeds_i64_max() { + let big = format!("bytes={}-", i64::MAX as u64 + 1); + assert!(Range::parse(&big).is_err()); + } + + #[test] + fn parse_last_exceeds_i64_max() { + let big = format!("bytes=0-{}", i64::MAX as u64 + 1); + assert!(Range::parse(&big).is_err()); + } + + #[test] + fn parse_first_greater_than_last() { + assert!(Range::parse("bytes=500-100").is_err()); + } } diff --git a/crates/s3s/src/dto/streaming_blob.rs b/crates/s3s/src/dto/streaming_blob.rs index 8099a2e6..f65a1761 100644 --- a/crates/s3s/src/dto/streaming_blob.rs +++ b/crates/s3s/src/dto/streaming_blob.rs @@ -125,3 +125,93 @@ where { Box::pin(StreamWrapper { inner }) } + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use http_body::Body as HttpBody; + + #[tokio::test] + async fn streaming_blob_new_and_poll() { + let body = Body::from(Bytes::from_static(b"hello world")); + let mut blob = StreamingBlob::new(body); + let mut collected = Vec::new(); + while let Some(chunk) = blob.next().await { + collected.push(chunk.unwrap()); + } + assert_eq!(collected, vec![Bytes::from_static(b"hello world")]); + } + + #[tokio::test] + async fn streaming_blob_wrap() { + let data = vec![ + Ok::<_, std::io::Error>(Bytes::from_static(b"abc")), + Ok(Bytes::from_static(b"def")), + ]; + let stream = futures::stream::iter(data); + let mut blob = StreamingBlob::wrap(stream); + let mut collected = Vec::new(); + while let Some(chunk) = blob.next().await { + collected.push(chunk.unwrap()); + } + assert_eq!(collected, vec![Bytes::from_static(b"abc"), Bytes::from_static(b"def")]); + } + + #[test] + fn streaming_blob_debug() { + let body = Body::from(Bytes::from_static(b"test")); + let blob = StreamingBlob::new(body); + let debug = format!("{blob:?}"); + assert!(debug.contains("StreamingBlob")); + assert!(debug.contains("remaining_length")); + } + + #[test] + fn streaming_blob_remaining_length() { + let body = Body::from(Bytes::from_static(b"hello")); + let blob = StreamingBlob::new(body); + let rl = blob.remaining_length(); + assert_eq!(rl.exact(), Some(5)); + } + + #[test] + fn streaming_blob_from_body_roundtrip() { + let body = Body::from(Bytes::from_static(b"data")); + let blob = StreamingBlob::from(body); + let body_back: Body = Body::from(blob); + assert!(!HttpBody::is_end_stream(&body_back)); + } + + #[test] + fn streaming_blob_into_dyn_byte_stream() { + let body = Body::from(Bytes::from_static(b"test")); + let blob = StreamingBlob::new(body); + let _dyn_stream: DynByteStream = blob.into(); + } + + #[test] + fn streaming_blob_from_dyn_byte_stream() { + let body = Body::from(Bytes::from_static(b"test")); + let dyn_stream: DynByteStream = Box::pin(body); + let _blob = StreamingBlob::from(dyn_stream); + } + + #[test] + fn streaming_blob_size_hint() { + let body = Body::from(Bytes::from_static(b"12345")); + let blob = StreamingBlob::new(body); + let (lower, upper) = blob.size_hint(); + if let Some(upper) = upper { + assert!(lower <= upper); + } + } + + #[tokio::test] + async fn streaming_blob_empty() { + let body = Body::empty(); + let mut blob = StreamingBlob::new(body); + let next = blob.next().await; + assert!(next.is_none()); + } +} diff --git a/crates/s3s/src/dto/timestamp.rs b/crates/s3s/src/dto/timestamp.rs index 7f032cac..344cb4e1 100644 --- a/crates/s3s/src/dto/timestamp.rs +++ b/crates/s3s/src/dto/timestamp.rs @@ -192,4 +192,163 @@ mod tests { assert_eq!(expected, text); } } + + #[test] + fn parse_epoch_seconds_integer() { + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "0").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt, time::OffsetDateTime::UNIX_EPOCH); + } + + #[test] + fn parse_epoch_seconds_negative() { + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "-1").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.unix_timestamp(), -1); + } + + #[test] + fn parse_epoch_seconds_fractional_lengths() { + // 1 digit fractional + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.5").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.unix_timestamp(), 100); + assert_eq!(dt.nanosecond(), 500_000_000); + + // 3 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.123").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_000_000); + + // 6 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.123456").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_456_000); + + // 9 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.123456789").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_456_789); + } + + #[test] + fn parse_epoch_seconds_overflow_fractional() { + // 10 digits should fail + let result = Timestamp::parse(TimestampFormat::EpochSeconds, "100.1234567890"); + assert!(result.is_err()); + } + + #[test] + fn parse_datetime_invalid() { + let result = Timestamp::parse(TimestampFormat::DateTime, "not-a-date"); + assert!(result.is_err()); + } + + #[test] + fn parse_http_date_invalid() { + let result = Timestamp::parse(TimestampFormat::HttpDate, "not-a-date"); + assert!(result.is_err()); + } + + #[test] + fn parse_epoch_seconds_invalid() { + let result = Timestamp::parse(TimestampFormat::EpochSeconds, "abc"); + assert!(result.is_err()); + } + + #[test] + fn from_system_time() { + let st = SystemTime::UNIX_EPOCH; + let ts = Timestamp::from(st); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt, time::OffsetDateTime::UNIX_EPOCH); + } + + #[test] + fn from_offset_datetime() { + let odt = time::OffsetDateTime::UNIX_EPOCH; + let ts = Timestamp::from(odt); + let back: time::OffsetDateTime = ts.into(); + assert_eq!(back, time::OffsetDateTime::UNIX_EPOCH); + } + + #[test] + fn serde_roundtrip() { + let ts = Timestamp::parse(TimestampFormat::DateTime, "1985-04-12T23:20:50.520Z").unwrap(); + let json = serde_json::to_string(&ts).unwrap(); + assert!(json.contains("1985-04-12")); + let parsed: Timestamp = serde_json::from_str(&json).unwrap(); + assert_eq!(ts, parsed); + } + + #[test] + fn serde_deserialize_invalid() { + let result: Result = serde_json::from_str("\"not-a-date\""); + assert!(result.is_err()); + } + + #[test] + fn format_epoch_seconds() { + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "0").unwrap(); + let mut buf = Vec::new(); + ts.format(TimestampFormat::EpochSeconds, &mut buf).unwrap(); + let text = String::from_utf8(buf).unwrap(); + assert_eq!(text, "0"); + } + + #[test] + fn timestamp_ord() { + let ts1 = Timestamp::parse(TimestampFormat::EpochSeconds, "100").unwrap(); + let ts2 = Timestamp::parse(TimestampFormat::EpochSeconds, "200").unwrap(); + assert!(ts1 < ts2); + assert_eq!(ts1, ts1.clone()); + } + + #[test] + fn timestamp_hash() { + use std::collections::HashSet; + let ts1 = Timestamp::parse(TimestampFormat::EpochSeconds, "100").unwrap(); + let ts2 = Timestamp::parse(TimestampFormat::EpochSeconds, "100").unwrap(); + let mut set = HashSet::new(); + set.insert(ts1); + set.insert(ts2); + assert_eq!(set.len(), 1); + } + + #[test] + fn parse_epoch_seconds_all_frac_lengths() { + // 2 digit fractional + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.12").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 120_000_000); + + // 4 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.1234").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_400_000); + + // 5 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.12345").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_450_000); + + // 7 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.1234567").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_456_700); + + // 8 digits + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "100.12345678").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + assert_eq!(dt.nanosecond(), 123_456_780); + } + + #[test] + fn parse_epoch_negative_with_frac() { + // "-1.5" means -1 seconds + 0.5 fractional = -0.5 seconds + let ts = Timestamp::parse(TimestampFormat::EpochSeconds, "-1.5").unwrap(); + let dt: time::OffsetDateTime = ts.into(); + let nanos = dt.unix_timestamp_nanos(); + assert_eq!(nanos, -500_000_000); + } } diff --git a/crates/s3s/src/http/body.rs b/crates/s3s/src/http/body.rs index e2c6d543..16137851 100644 --- a/crates/s3s/src/http/body.rs +++ b/crates/s3s/src/http/body.rs @@ -309,6 +309,8 @@ impl Body { mod tests { use super::*; + use futures::StreamExt; + #[tokio::test] async fn test_store_all_limited_success() { let data = b"hello world"; @@ -333,4 +335,253 @@ mod tests { assert!(result.is_ok()); assert!(result.unwrap().is_empty()); } + + #[test] + fn body_empty_is_end_stream() { + let body = Body::empty(); + assert!(http_body::Body::is_end_stream(&body)); + } + + #[test] + fn body_once_is_end_stream() { + let body = Body::from(Bytes::from_static(b"data")); + assert!(!http_body::Body::is_end_stream(&body)); + + let body = Body::from(Bytes::new()); + assert!(http_body::Body::is_end_stream(&body)); + } + + #[test] + fn body_empty_size_hint() { + let body = Body::empty(); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 0); + assert_eq!(hint.upper(), Some(0)); + } + + #[test] + fn body_once_size_hint() { + let body = Body::from(Bytes::from_static(b"hello")); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 5); + assert_eq!(hint.upper(), Some(5)); + } + + #[test] + fn body_empty_poll_frame() { + let mut body = Body::empty(); + let frame = http_body::Body::poll_frame( + Pin::new(&mut body), + &mut std::task::Context::from_waker(futures::task::noop_waker_ref()), + ); + assert!(matches!(frame, Poll::Ready(None))); + } + + #[test] + fn body_once_poll_frame() { + let mut body = Body::from(Bytes::from_static(b"hello")); + let waker = futures::task::noop_waker_ref(); + let mut cx = std::task::Context::from_waker(waker); + let frame = http_body::Body::poll_frame(Pin::new(&mut body), &mut cx); + match frame { + Poll::Ready(Some(Ok(frame))) => { + let data = frame.into_data().unwrap(); + assert_eq!(data.as_ref(), b"hello"); + } + _ => panic!("expected data frame"), + } + // Second poll should return None + let frame = http_body::Body::poll_frame(Pin::new(&mut body), &mut cx); + assert!(matches!(frame, Poll::Ready(None))); + } + + #[test] + fn body_once_empty_bytes_poll_frame() { + let mut body = Body::from(Bytes::new()); + let waker = futures::task::noop_waker_ref(); + let mut cx = std::task::Context::from_waker(waker); + let frame = http_body::Body::poll_frame(Pin::new(&mut body), &mut cx); + assert!(matches!(frame, Poll::Ready(None))); + } + + #[test] + fn body_from_vec() { + let body = Body::from(vec![1u8, 2, 3]); + assert!(!http_body::Body::is_end_stream(&body)); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 3); + } + + #[test] + fn body_from_string() { + let body = Body::from("hello".to_string()); + assert!(!http_body::Body::is_end_stream(&body)); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 5); + } + + #[test] + fn body_bytes_empty() { + let body = Body::empty(); + let bytes = body.bytes(); + assert_eq!(bytes, Some(Bytes::new())); + } + + #[test] + fn body_bytes_once() { + let body = Body::from(Bytes::from_static(b"hello")); + let bytes = body.bytes(); + assert_eq!(bytes, Some(Bytes::from_static(b"hello"))); + } + + #[test] + fn body_take_bytes_empty() { + let mut body = Body::empty(); + let bytes = body.take_bytes(); + assert_eq!(bytes, Some(Bytes::new())); + } + + #[test] + fn body_take_bytes_once() { + let mut body = Body::from(Bytes::from_static(b"hello")); + let bytes = body.take_bytes(); + assert_eq!(bytes, Some(Bytes::from_static(b"hello"))); + // After take, body should be empty + assert!(http_body::Body::is_end_stream(&body)); + } + + #[test] + fn body_debug_empty() { + let body = Body::empty(); + let debug = format!("{body:?}"); + assert!(debug.contains("Body")); + } + + #[test] + fn body_debug_once() { + let body = Body::from(Bytes::from_static(b"hi")); + let debug = format!("{body:?}"); + assert!(debug.contains("Body")); + assert!(debug.contains("once")); + } + + #[tokio::test] + async fn body_stream_impl() { + let mut body = Body::from(Bytes::from_static(b"stream")); + let mut collected = Vec::new(); + while let Some(chunk) = body.next().await { + collected.push(chunk.unwrap()); + } + assert_eq!(collected, vec![Bytes::from_static(b"stream")]); + } + + #[tokio::test] + async fn body_stream_empty() { + let mut body = Body::empty(); + let next = body.next().await; + assert!(next.is_none()); + } + + #[test] + fn body_byte_stream_remaining_length_empty() { + let body = Body::empty(); + let rl = ByteStream::remaining_length(&body); + assert_eq!(rl.exact(), Some(0)); + } + + #[test] + fn body_byte_stream_remaining_length_once() { + let body = Body::from(Bytes::from_static(b"12345")); + let rl = ByteStream::remaining_length(&body); + assert_eq!(rl.exact(), Some(5)); + } + + #[test] + fn body_http_body_boxed() { + let inner = http_body_util::Full::new(Bytes::from_static(b"boxed")); + let body = Body::http_body(inner); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 5); + assert!(!http_body::Body::is_end_stream(&body)); + } + + #[test] + fn body_http_body_unsync() { + let inner = http_body_util::Full::new(Bytes::from_static(b"unsync")); + let body = Body::http_body_unsync(inner); + let hint = http_body::Body::size_hint(&body); + assert_eq!(hint.lower(), 6); + assert!(!http_body::Body::is_end_stream(&body)); + } + + #[test] + fn body_debug_box_body() { + let inner = http_body_util::Full::new(Bytes::from_static(b"test")); + let body = Body::http_body(inner); + let debug = format!("{body:?}"); + assert!(debug.contains("Body")); + assert!(debug.contains("remaining_length")); + } + + #[test] + fn body_debug_unsync_box_body() { + let inner = http_body_util::Full::new(Bytes::from_static(b"test")); + let body = Body::http_body_unsync(inner); + let debug = format!("{body:?}"); + assert!(debug.contains("Body")); + assert!(debug.contains("remaining_length")); + } + + #[test] + fn body_from_dyn_byte_stream() { + let inner = Body::from(Bytes::from_static(b"dyn")); + let dyn_stream: DynByteStream = Box::pin(inner); + let body = Body::from(dyn_stream); + let debug = format!("{body:?}"); + assert!(debug.contains("dyn_stream")); + } + + #[test] + fn body_size_limit_exceeded_display() { + let err = BodySizeLimitExceeded { size: 100, limit: 50 }; + let msg = format!("{err}"); + assert!(msg.contains("100")); + assert!(msg.contains("50")); + } + + #[tokio::test] + async fn body_boxed_poll_frame() { + let inner = http_body_util::Full::new(Bytes::from_static(b"boxed")); + let mut body = Body::http_body(inner); + let mut collected = Vec::new(); + while let Some(chunk) = body.next().await { + collected.push(chunk.unwrap()); + } + assert_eq!(collected, vec![Bytes::from_static(b"boxed")]); + } + + #[tokio::test] + async fn body_unsync_poll_frame() { + let inner = http_body_util::Full::new(Bytes::from_static(b"unsync")); + let mut body = Body::http_body_unsync(inner); + let mut collected = Vec::new(); + while let Some(chunk) = body.next().await { + collected.push(chunk.unwrap()); + } + assert_eq!(collected, vec![Bytes::from_static(b"unsync")]); + } + + #[test] + fn body_bytes_returns_none_for_box_body() { + let inner = http_body_util::Full::new(Bytes::from_static(b"test")); + let body = Body::http_body(inner); + assert!(body.bytes().is_none()); + } + + #[test] + fn body_take_bytes_returns_none_for_box_body() { + let inner = http_body_util::Full::new(Bytes::from_static(b"test")); + let mut body = Body::http_body(inner); + assert!(body.take_bytes().is_none()); + } } diff --git a/crates/s3s/src/http/de.rs b/crates/s3s/src/http/de.rs index b0909c83..ec9156a5 100644 --- a/crates/s3s/src/http/de.rs +++ b/crates/s3s/src/http/de.rs @@ -376,3 +376,529 @@ pub fn parse_field_value_timestamp(m: &Multipart, name: &str, fmt: TimestampForm Err(source) => Err(s3_error!(source, InvalidArgument, "invalid field value: {}: {:?}", name, val)), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::TimestampFormat; + use crate::http::multipart::File; + use crate::http::{Body, OrderedQs}; + use crate::path::S3Path; + use crate::stream::ByteStream; + use bytes::Bytes; + + fn make_request() -> Request { + Request { + version: http::Version::HTTP_11, + method: hyper::Method::GET, + uri: hyper::Uri::from_static("http://example.com"), + headers: hyper::HeaderMap::new(), + extensions: hyper::http::Extensions::default(), + body: Body::empty(), + s3ext: super::super::request::S3Extensions::default(), + } + } + + // --- TryFromHeaderValue tests --- + + #[test] + fn try_from_header_value_bool() { + use crate::http::HeaderValue; + assert!(bool::try_from_header_value(&HeaderValue::from_static("true")).unwrap()); + assert!(bool::try_from_header_value(&HeaderValue::from_static("True")).unwrap()); + assert!(!bool::try_from_header_value(&HeaderValue::from_static("false")).unwrap()); + assert!(!bool::try_from_header_value(&HeaderValue::from_static("False")).unwrap()); + assert!(bool::try_from_header_value(&HeaderValue::from_static("invalid")).is_err()); + } + + #[test] + fn try_from_header_value_i32() { + use crate::http::HeaderValue; + assert_eq!(i32::try_from_header_value(&HeaderValue::from_static("42")).unwrap(), 42); + assert_eq!(i32::try_from_header_value(&HeaderValue::from_static("-1")).unwrap(), -1); + assert!(i32::try_from_header_value(&HeaderValue::from_static("abc")).is_err()); + } + + #[test] + fn try_from_header_value_i64() { + use crate::http::HeaderValue; + assert_eq!( + i64::try_from_header_value(&HeaderValue::from_static("9876543210")).unwrap(), + 9_876_543_210_i64 + ); + assert!(i64::try_from_header_value(&HeaderValue::from_static("abc")).is_err()); + } + + #[test] + fn try_from_header_value_string() { + use crate::http::HeaderValue; + assert_eq!(String::try_from_header_value(&HeaderValue::from_static("hello")).unwrap(), "hello"); + } + + // --- parse_header / parse_opt_header tests --- + + #[test] + fn parse_header_present() { + let mut req = make_request(); + req.headers.insert("x-custom", "42".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let val: i32 = parse_header(&req, &name).unwrap(); + assert_eq!(val, 42); + } + + #[test] + fn parse_header_missing() { + let req = make_request(); + let name = HeaderName::from_static("x-custom"); + let result: S3Result = parse_header(&req, &name); + assert!(result.is_err()); + } + + #[test] + fn parse_header_empty_value() { + let mut req = make_request(); + req.headers.insert("x-custom", "".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let result: S3Result = parse_header(&req, &name); + assert!(result.is_err()); + } + + #[test] + fn parse_header_duplicate() { + let mut req = make_request(); + req.headers.append("x-custom", "1".parse().unwrap()); + req.headers.append("x-custom", "2".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let result: S3Result = parse_header(&req, &name); + assert!(result.is_err()); + } + + #[test] + fn parse_opt_header_present() { + let mut req = make_request(); + req.headers.insert("x-custom", "hello".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let val: Option = parse_opt_header(&req, &name).unwrap(); + assert_eq!(val.as_deref(), Some("hello")); + } + + #[test] + fn parse_opt_header_missing() { + let req = make_request(); + let name = HeaderName::from_static("x-custom"); + let val: Option = parse_opt_header(&req, &name).unwrap(); + assert!(val.is_none()); + } + + #[test] + fn parse_opt_header_empty() { + let mut req = make_request(); + req.headers.insert("x-custom", "".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let val: Option = parse_opt_header(&req, &name).unwrap(); + assert!(val.is_none()); + } + + #[test] + fn parse_opt_header_duplicate() { + let mut req = make_request(); + req.headers.append("x-custom", "a".parse().unwrap()); + req.headers.append("x-custom", "b".parse().unwrap()); + let name = HeaderName::from_static("x-custom"); + let result: S3Result> = parse_opt_header(&req, &name); + assert!(result.is_err()); + } + + #[test] + fn parse_opt_header_timestamp_present() { + let mut req = make_request(); + req.headers + .insert("x-amz-date", "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap()); + let name = HeaderName::from_static("x-amz-date"); + let ts = parse_opt_header_timestamp(&req, &name, TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_some()); + } + + #[test] + fn parse_opt_header_timestamp_missing() { + let req = make_request(); + let name = HeaderName::from_static("x-amz-date"); + let ts = parse_opt_header_timestamp(&req, &name, TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_none()); + } + + #[test] + fn parse_opt_header_timestamp_invalid() { + let mut req = make_request(); + req.headers.insert("x-amz-date", "not-a-date".parse().unwrap()); + let name = HeaderName::from_static("x-amz-date"); + let result = parse_opt_header_timestamp(&req, &name, TimestampFormat::HttpDate); + assert!(result.is_err()); + } + + // --- parse_query / parse_opt_query tests --- + + #[test] + fn parse_query_present() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![("max-keys".into(), "100".into())])); + let val: i32 = parse_query(&req, "max-keys").unwrap(); + assert_eq!(val, 100); + } + + #[test] + fn parse_query_missing_qs() { + let req = make_request(); + let result: S3Result = parse_query(&req, "max-keys"); + assert!(result.is_err()); + } + + #[test] + fn parse_query_missing_key() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![("other".into(), "1".into())])); + let result: S3Result = parse_query(&req, "max-keys"); + assert!(result.is_err()); + } + + #[test] + fn parse_query_duplicate() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![ + ("key".into(), "1".into()), + ("key".into(), "2".into()), + ])); + let result: S3Result = parse_query(&req, "key"); + assert!(result.is_err()); + } + + #[test] + fn parse_query_invalid_value() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![("max-keys".into(), "abc".into())])); + let result: S3Result = parse_query(&req, "max-keys"); + assert!(result.is_err()); + } + + #[test] + fn parse_opt_query_present() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![("prefix".into(), "foo".into())])); + let val: Option = parse_opt_query(&req, "prefix").unwrap(); + assert_eq!(val.as_deref(), Some("foo")); + } + + #[test] + fn parse_opt_query_missing() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![])); + let val: Option = parse_opt_query(&req, "prefix").unwrap(); + assert!(val.is_none()); + } + + #[test] + fn parse_opt_query_no_qs() { + let req = make_request(); + let val: Option = parse_opt_query(&req, "prefix").unwrap(); + assert!(val.is_none()); + } + + #[test] + fn parse_opt_query_duplicate() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![ + ("key".into(), "a".into()), + ("key".into(), "b".into()), + ])); + let result: S3Result> = parse_opt_query(&req, "key"); + assert!(result.is_err()); + } + + #[test] + fn parse_opt_query_timestamp_present() { + let mut req = make_request(); + req.s3ext.qs = Some(OrderedQs::from_vec_unchecked(vec![( + "date".into(), + "Wed, 21 Oct 2015 07:28:00 GMT".into(), + )])); + let ts = parse_opt_query_timestamp(&req, "date", TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_some()); + } + + #[test] + fn parse_opt_query_timestamp_missing() { + let req = make_request(); + let ts = parse_opt_query_timestamp(&req, "date", TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_none()); + } + + // --- unwrap_bucket / unwrap_object tests --- + + #[test] + fn unwrap_bucket_test() { + let mut req = make_request(); + req.s3ext.s3_path = Some(S3Path::Bucket { + bucket: "my-bucket".into(), + }); + let bucket = unwrap_bucket(&mut req); + assert_eq!(bucket, "my-bucket"); + } + + #[test] + #[should_panic(expected = "s3 path not found")] + fn unwrap_bucket_missing() { + let mut req = make_request(); + let _ = unwrap_bucket(&mut req); + } + + #[test] + fn unwrap_object_test() { + let mut req = make_request(); + req.s3ext.s3_path = Some(S3Path::Object { + bucket: "my-bucket".into(), + key: "my-key".into(), + }); + let (bucket, key) = unwrap_object(&mut req); + assert_eq!(bucket, "my-bucket"); + assert_eq!(key, "my-key"); + } + + #[test] + #[should_panic(expected = "s3 path not found")] + fn unwrap_object_missing() { + let mut req = make_request(); + let _ = unwrap_object(&mut req); + } + + // --- take_string_body / take_xml_body / take_opt_xml_body tests --- + + #[test] + fn take_string_body_ok() { + let mut req = make_request(); + req.body = Body::from(Bytes::from_static(b"hello world")); + let s = take_string_body(&mut req).unwrap(); + assert_eq!(s, "hello world"); + } + + #[test] + fn take_string_body_empty() { + let mut req = make_request(); + req.body = Body::from(Bytes::new()); + let s = take_string_body(&mut req).unwrap(); + assert_eq!(s, ""); + } + + #[test] + fn take_string_body_invalid_utf8() { + let mut req = make_request(); + req.body = Body::from(vec![0xff, 0xfe]); + let result = take_string_body(&mut req); + assert!(result.is_err()); + } + + #[test] + fn take_stream_body_test() { + let mut req = make_request(); + req.body = Body::from(Bytes::from_static(b"stream data")); + let blob = take_stream_body(&mut req); + let rl = blob.remaining_length(); + // After taking, body should be default (empty) + assert!(http_body::Body::is_end_stream(&req.body)); + let _ = rl; + } + + #[test] + fn take_xml_body_empty_returns_error() { + let mut req = make_request(); + req.body = Body::from(Bytes::new()); + let result = take_xml_body::(&mut req); + assert!(result.is_err()); + } + + #[test] + fn take_opt_xml_body_empty_returns_none() { + let mut req = make_request(); + req.body = Body::from(Bytes::new()); + let result = take_opt_xml_body::(&mut req).unwrap(); + assert!(result.is_none()); + } + + // --- parse_opt_metadata --- + + #[test] + fn parse_opt_metadata_none() { + let req = make_request(); + let result = parse_opt_metadata(&req).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn parse_opt_metadata_some() { + let mut req = make_request(); + req.headers.insert("x-amz-meta-mykey", "myvalue".parse().unwrap()); + let result = parse_opt_metadata(&req).unwrap(); + let metadata = result.unwrap(); + assert_eq!(metadata.get("mykey").map(String::as_str), Some("myvalue")); + } + + #[test] + fn parse_opt_metadata_empty_key_ignored() { + let mut req = make_request(); + // "x-amz-meta-" with empty suffix should be ignored + req.headers.insert("x-amz-meta-", "value".parse().unwrap()); + let result = parse_opt_metadata(&req).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn parse_opt_metadata_duplicate_header() { + let mut req = make_request(); + req.headers.append("x-amz-meta-key", "val1".parse().unwrap()); + req.headers.append("x-amz-meta-key", "val2".parse().unwrap()); + let result = parse_opt_metadata(&req); + assert!(result.is_err()); + } + + // --- parse_list_header / parse_opt_list_header --- + + #[test] + fn parse_list_header_single() { + let mut req = make_request(); + req.headers.insert("x-list", "hello".parse().unwrap()); + let name = HeaderName::from_static("x-list"); + let list: List = parse_list_header(&req, &name).unwrap(); + assert_eq!(list, vec!["hello".to_string()]); + } + + #[test] + fn parse_list_header_multiple() { + let mut req = make_request(); + req.headers.append("x-list", "a".parse().unwrap()); + req.headers.append("x-list", "b".parse().unwrap()); + let name = HeaderName::from_static("x-list"); + let list: List = parse_list_header(&req, &name).unwrap(); + assert_eq!(list.len(), 2); + } + + #[test] + fn parse_list_header_missing() { + let req = make_request(); + let name = HeaderName::from_static("x-list"); + let result: S3Result> = parse_list_header(&req, &name); + assert!(result.is_err()); + } + + #[test] + fn parse_opt_list_header_present() { + let mut req = make_request(); + req.headers.insert("x-list", "item".parse().unwrap()); + let name = HeaderName::from_static("x-list"); + let list: Option> = parse_opt_list_header(&req, &name).unwrap(); + assert!(list.is_some()); + } + + #[test] + fn parse_opt_list_header_missing() { + let req = make_request(); + let name = HeaderName::from_static("x-list"); + let list: Option> = parse_opt_list_header(&req, &name).unwrap(); + assert!(list.is_none()); + } + + // --- parse_field_value / parse_field_value_timestamp --- + + #[test] + fn parse_field_value_found() { + let m = Multipart::new_for_test( + vec![("key".into(), "42".into())], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let val: Option = parse_field_value(&m, "key").unwrap(); + assert_eq!(val, Some(42)); + } + + #[test] + fn parse_field_value_not_found() { + let m = Multipart::new_for_test( + vec![], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let val: Option = parse_field_value(&m, "key").unwrap(); + assert!(val.is_none()); + } + + #[test] + fn parse_field_value_invalid() { + let m = Multipart::new_for_test( + vec![("key".into(), "abc".into())], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let result: S3Result> = parse_field_value(&m, "key"); + assert!(result.is_err()); + } + + #[test] + fn parse_field_value_timestamp_found() { + let m = Multipart::new_for_test( + vec![("date".into(), "Wed, 21 Oct 2015 07:28:00 GMT".into())], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let ts = parse_field_value_timestamp(&m, "date", TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_some()); + } + + #[test] + fn parse_field_value_timestamp_not_found() { + let m = Multipart::new_for_test( + vec![], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let ts = parse_field_value_timestamp(&m, "date", TimestampFormat::HttpDate).unwrap(); + assert!(ts.is_none()); + } + + #[test] + fn parse_field_value_timestamp_invalid() { + let m = Multipart::new_for_test( + vec![("date".into(), "not-a-date".into())], + File { + name: "file".into(), + content_type: None, + stream: None, + }, + ); + let result = parse_field_value_timestamp(&m, "date", TimestampFormat::HttpDate); + assert!(result.is_err()); + } + + // --- ParseHeaderError Display --- + + #[test] + fn parse_header_error_display() { + assert!(format!("{}", ParseHeaderError::Boolean).contains("boolean")); + assert!(format!("{}", ParseHeaderError::Integer).contains("integer")); + assert!(format!("{}", ParseHeaderError::Long).contains("long")); + assert!(format!("{}", ParseHeaderError::Enum).contains("enum")); + assert!(format!("{}", ParseHeaderError::String).contains("string")); + } +} diff --git a/crates/s3s/src/http/etag.rs b/crates/s3s/src/http/etag.rs index 6904fbcd..40d123f3 100644 --- a/crates/s3s/src/http/etag.rs +++ b/crates/s3s/src/http/etag.rs @@ -40,3 +40,70 @@ impl TryIntoHeaderValue for ETagCondition { self.to_http_header() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::{ETag, ETagCondition}; + + #[test] + fn etag_try_from_header_value() { + let hv = HeaderValue::from_static("\"abc123\""); + let etag = ETag::try_from_header_value(&hv).unwrap(); + assert_eq!(etag.as_strong(), Some("abc123")); + + let hv = HeaderValue::from_static("W/\"weak\""); + let etag = ETag::try_from_header_value(&hv).unwrap(); + assert_eq!(etag.as_weak(), Some("weak")); + + let hv = HeaderValue::from_static("abc123def"); + let etag = ETag::try_from_header_value(&hv).unwrap(); + assert_eq!(etag.as_strong(), Some("abc123def")); + } + + #[test] + fn etag_try_into_header_value() { + let etag = ETag::Strong("hello".to_owned()); + let hv = etag.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"\"hello\""); + + let etag = ETag::Weak("world".to_owned()); + let hv = etag.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"W/\"world\""); + } + + #[test] + fn etag_condition_try_from_header_value() { + let hv = HeaderValue::from_static("*"); + let cond = ETagCondition::try_from_header_value(&hv).unwrap(); + assert!(cond.is_any()); + + let hv = HeaderValue::from_static("\"abc\""); + let cond = ETagCondition::try_from_header_value(&hv).unwrap(); + assert_eq!(cond.as_etag().unwrap().as_strong(), Some("abc")); + } + + #[test] + fn etag_condition_try_into_header_value() { + let cond = ETagCondition::Any; + let hv = cond.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"*"); + + let cond = ETagCondition::ETag(ETag::Strong("test".to_owned())); + let hv = cond.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"\"test\""); + } + + #[test] + fn etag_roundtrip_via_header_value() { + let original = ETag::Strong("roundtrip".to_owned()); + let hv = original.clone().try_into_header_value().unwrap(); + let parsed = ETag::try_from_header_value(&hv).unwrap(); + assert_eq!(original, parsed); + + let original = ETagCondition::ETag(ETag::Weak("rt".to_owned())); + let hv = original.clone().try_into_header_value().unwrap(); + let parsed = ETagCondition::try_from_header_value(&hv).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/crates/s3s/src/http/ser.rs b/crates/s3s/src/http/ser.rs index 74326bc6..b1ec599e 100644 --- a/crates/s3s/src/http/ser.rs +++ b/crates/s3s/src/http/ser.rs @@ -161,3 +161,115 @@ pub fn add_opt_metadata(res: &mut Response, metadata: Option) -> S3Res } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + + fn new_response() -> Response { + Response::default() + } + + #[test] + fn try_into_header_value_bool() { + let hv = true.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"true"); + let hv = false.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"false"); + } + + #[test] + fn try_into_header_value_i32() { + let hv = 42i32.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"42"); + let hv = (-1i32).try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"-1"); + let hv = 0i32.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"0"); + } + + #[test] + fn try_into_header_value_i64() { + let hv = 123_456_789_i64.try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"123456789"); + let hv = (-99i64).try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"-99"); + } + + #[test] + fn try_into_header_value_string() { + let hv = "hello".to_string().try_into_header_value().unwrap(); + assert_eq!(hv.as_bytes(), b"hello"); + } + + #[test] + fn add_opt_header_some() { + let mut res = new_response(); + add_opt_header(&mut res, hyper::header::CONTENT_LENGTH, Some(42i64)).unwrap(); + assert_eq!(res.headers.get(hyper::header::CONTENT_LENGTH).unwrap().as_bytes(), b"42"); + } + + #[test] + fn add_opt_header_none() { + let mut res = new_response(); + add_opt_header::<_, String>(&mut res, hyper::header::CONTENT_TYPE, None).unwrap(); + assert!(res.headers.get(hyper::header::CONTENT_TYPE).is_none()); + } + + #[test] + fn add_opt_header_bool() { + let mut res = new_response(); + add_opt_header(&mut res, "x-amz-delete-marker", Some(true)).unwrap(); + assert_eq!(res.headers.get("x-amz-delete-marker").unwrap().as_bytes(), b"true"); + } + + #[test] + fn add_opt_header_timestamp_some() { + let mut res = new_response(); + let ts = Timestamp::parse(TimestampFormat::HttpDate, "Wed, 21 Oct 2015 07:28:00 GMT").unwrap(); + super::add_opt_header_timestamp(&mut res, "x-amz-date", Some(ts), TimestampFormat::HttpDate).unwrap(); + assert_eq!(res.headers.get("x-amz-date").unwrap().as_bytes(), b"Wed, 21 Oct 2015 07:28:00 GMT"); + } + + #[test] + fn add_opt_header_timestamp_is_none() { + let mut res = new_response(); + super::add_opt_header_timestamp(&mut res, "x-amz-date", None, TimestampFormat::HttpDate).unwrap(); + assert!(res.headers.get("x-amz-date").is_none()); + } + + #[test] + fn set_stream_body_test() { + let mut res = new_response(); + let blob = StreamingBlob::new(Body::from(Bytes::from_static(b"stream data"))); + set_stream_body(&mut res, blob); + assert!(!http_body::Body::is_end_stream(&res.body)); + } + + #[test] + fn add_opt_metadata_some() { + let mut res = new_response(); + let mut metadata = Metadata::default(); + metadata.insert("key1".into(), "value1".into()); + metadata.insert("key2".into(), "value2".into()); + add_opt_metadata(&mut res, Some(metadata)).unwrap(); + assert!(res.headers.get("x-amz-meta-key1").is_some()); + assert!(res.headers.get("x-amz-meta-key2").is_some()); + } + + #[test] + fn add_opt_metadata_none() { + let mut res = new_response(); + add_opt_metadata(&mut res, None).unwrap(); + assert!(res.headers.is_empty()); + } + + #[test] + fn add_opt_metadata_empty_map() { + let mut res = new_response(); + let metadata = Metadata::default(); + add_opt_metadata(&mut res, Some(metadata)).unwrap(); + assert!(res.headers.is_empty()); + } +}