use super::*;
use crate::idempotency::IDEMPOTENCY_TOKEN_HEADER;
use crate::storage::streaming_source::BytesSource;
use httptest::{Expectation, Server, matchers::*, responders::*};
use std::sync::Mutex;
#[tokio::test]
async fn unbuffered_single_shot_reuses_token() -> Result {
let (server, tokens) = single_shot_server(2);
start_single_shot(&server)
.await?
.set_if_generation_match(0)
.send_unbuffered()
.await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn unbuffered_single_shot_unconditioned_omits_token() -> Result {
let (server, tokens) = single_shot_server(1);
let err = start_single_shot(&server)
.await?
.send_unbuffered()
.await
.expect_err("unconditioned uploads are not retried");
assert_eq!(err.http_status_code(), Some(503), "{err:?}");
assert_eq!(*tokens.lock().unwrap(), vec![None]);
Ok(())
}
#[tokio::test]
async fn unbuffered_single_shot_idempotency_false_omits_token() -> Result {
let (server, tokens) = single_shot_server(1);
let err = start_single_shot(&server)
.await?
.set_if_generation_match(0)
.with_idempotency(false)
.send_unbuffered()
.await
.expect_err("with_idempotency(false) disables retries");
assert_eq!(err.http_status_code(), Some(503), "{err:?}");
assert_eq!(*tokens.lock().unwrap(), vec![None]);
Ok(())
}
#[tokio::test]
async fn buffered_resumable_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server)
.await?
.set_if_generation_match(0)
.send_buffered()
.await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn buffered_resumable_unconditioned_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server).await?.send_buffered().await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn unbuffered_resumable_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server)
.await?
.set_if_generation_match(0)
.send_unbuffered()
.await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn unbuffered_resumable_unconditioned_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server).await?.send_unbuffered().await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn buffered_resumable_idempotency_false_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server)
.await?
.set_if_generation_match(0)
.with_idempotency(false)
.send_buffered()
.await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn unbuffered_resumable_idempotency_false_reuses_token() -> Result {
let (server, tokens) = resumable_server();
start_resumable(&server)
.await?
.set_if_generation_match(0)
.with_idempotency(false)
.send_unbuffered()
.await?;
let tokens = tokens.lock().unwrap();
let [Some(first), Some(second)] = tokens.as_slice() else {
panic!("expected 2 captured tokens, got {tokens:?}");
};
assert_eq!(first, second);
Ok(())
}
async fn start_single_shot(server: &Server) -> anyhow::Result<WriteObject<BytesSource>> {
let client = test_builder()
.with_endpoint(format!("http://{}", server.addr()))
.build()
.await?;
Ok(client
.write_object("projects/_/buckets/test-bucket", "test-object", "hello")
.with_resumable_upload_threshold(1024 * 1024_usize))
}
async fn start_resumable(server: &Server) -> anyhow::Result<WriteObject<BytesSource>> {
let client = test_builder()
.with_endpoint(format!("http://{}", server.addr()))
.build()
.await?;
Ok(client
.write_object("projects/_/buckets/test-bucket", "test-object", "")
.with_resumable_upload_threshold(0_usize))
}
fn single_shot_server(attempts: usize) -> (Server, CapturedTokens) {
let server = Server::run();
let tokens = CapturedTokens::default();
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/upload/storage/v1/b/test-bucket/o"),
request::query(url_decoded(contains(("uploadType", "multipart")))),
])
.times(attempts)
.respond_with(TokenCapture::json_body(
tokens.clone(),
response_body().to_string().into(),
)),
);
(server, tokens)
}
fn resumable_server() -> (Server, CapturedTokens) {
let server = Server::run();
let session = server.url("/upload/session/test-only-001");
let tokens = CapturedTokens::default();
server.expect(
Expectation::matching(all_of![
request::method_path("POST", "/upload/storage/v1/b/test-bucket/o"),
request::query(url_decoded(contains(("uploadType", "resumable")))),
])
.times(2)
.respond_with(TokenCapture::resumable_session(
tokens.clone(),
session.to_string(),
)),
);
server.expect(
Expectation::matching(all_of![
request::method_path("PUT", session.path().to_string()),
request::headers(contains(("content-range", "bytes */0"))),
not(request::headers(contains(key(IDEMPOTENCY_TOKEN_HEADER)))),
])
.respond_with(
status_code(200)
.append_header("content-type", "application/json")
.body(response_body().to_string()),
),
);
(server, tokens)
}
type CapturedTokens = Arc<Mutex<Vec<Option<String>>>>;
enum Success {
Session(String),
Json(bytes::Bytes),
}
struct TokenCapture {
tokens: CapturedTokens,
call_count: usize,
success: Success,
}
impl TokenCapture {
fn resumable_session(tokens: CapturedTokens, session_url: String) -> Self {
Self {
tokens,
call_count: 0,
success: Success::Session(session_url),
}
}
fn json_body(tokens: CapturedTokens, body: bytes::Bytes) -> Self {
Self {
tokens,
call_count: 0,
success: Success::Json(body),
}
}
}
impl httptest::responders::Responder for TokenCapture {
fn respond<'a>(
&mut self,
req: &'a http::Request<bytes::Bytes>,
) -> std::pin::Pin<
Box<dyn futures::Future<Output = http::Response<bytes::Bytes>> + std::marker::Send + 'a>,
> {
let token = req
.headers()
.get(IDEMPOTENCY_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
self.tokens.lock().unwrap().push(token);
let count = self.call_count;
self.call_count += 1;
let res = if count == 0 {
http::Response::builder()
.status(503)
.body(bytes::Bytes::from("try-again"))
.unwrap()
} else {
match &self.success {
Success::Session(url) => http::Response::builder()
.status(200)
.header("location", url)
.body(bytes::Bytes::new())
.unwrap(),
Success::Json(body) => http::Response::builder()
.status(200)
.header("content-type", "application/json")
.body(body.clone())
.unwrap(),
}
};
Box::pin(async move { res })
}
}