wyrd-client 0.3.0

Shared Wyrd client: transport, authentication, and composed client capabilities.
use std::sync::Arc;
use std::sync::Mutex;

use crate::WyrdClient;
use crate::auth::AuthMiddleware;
use crate::config::ClientConfig;
use crate::error::WyrdClientError;
use crate::transport::HttpTransport;
use crate::transport::config::HttpConfig;
use crate::transport::credential::ResolvedCredential;

use super::{ProgressCallback, UploadHooks, UploadOutcome, report, validate_plan};
use crate::storage::WyrdStorageClient;
use crate::storage::error::StorageClientError;
use wyrd_spec::storage::{MultipartComplete, UploadCompleteRequest};

/// Build an [`AuthMiddleware`] with a static bearer credential and default config.
///
/// # Errors
/// Returns the client configuration error when the default config is invalid.
fn test_auth_middleware() -> Result<Arc<AuthMiddleware>, WyrdClientError> {
    AuthMiddleware::new(
        &ClientConfig::default(),
        ResolvedCredential::BearerToken("test-bearer".to_owned().into()),
    )
}

/// Build an offline [`WyrdClient`] whose transport never reaches a server in these tests.
fn client() -> WyrdClient {
    let config = ClientConfig::default();
    let auth = test_auth_middleware().expect("test_setup: auth builds");
    let http = HttpTransport::new(&HttpConfig::default(), Arc::clone(&auth)).expect("transport");
    WyrdClient::from_parts(auth, http, config.grpc)
}

/// `UploadOutcome::into_server_complete` surfaces the completion body without protocol matching.
#[test]
fn server_completion_is_exposed_without_protocol_matching_at_the_call_site() {
    let outcome =
        UploadOutcome::NeedsServerComplete(UploadCompleteRequest::Multipart(MultipartComplete {
            parts: Vec::new(),
        }));
    assert!(outcome.into_server_complete().is_some());
}

/// Zero-sized chunk, block, and part dimensions are rejected by [`validate_plan`].
#[test]
fn malformed_dimensions_fail_before_dispatch() {
    let plan = wyrd_spec::storage::UploadPlan::AzureBlockBlob {
        sas_url: "https://backend.invalid/blob".to_owned(),
        block_size_bytes: 0,
        block_count_planned: 1,
    };
    assert!(matches!(
        validate_plan(&plan),
        Err(StorageClientError::PlanInvalid(_))
    ));

    let plan = wyrd_spec::storage::UploadPlan::Multipart {
        part_count: 0,
        part_size_bytes: 1,
        part_url_ttl_secs: 60,
        required_headers: Vec::new(),
    };
    assert!(matches!(
        validate_plan(&plan),
        Err(StorageClientError::PlanInvalid(_))
    ));
}

/// [`report`] forwards both known and unknown totals to the progress callback unchanged.
#[test]
fn progress_preserves_known_and_unknown_totals() {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let captured = Arc::clone(&seen);
    let callback: ProgressCallback = Arc::new(move |uploaded, total| {
        captured
            .lock()
            .expect("test mutex is not poisoned")
            .push((uploaded, total));
    });

    report(Some(&callback), 4, Some(10));
    report(Some(&callback), 7, None);

    assert_eq!(
        *seen.lock().expect("test mutex is not poisoned"),
        vec![(4, Some(10)), (7, None)]
    );
}

/// Over-range chunk sizes fail plan validation before the facade issues any request.
#[tokio::test]
async fn over_range_dimensions_fail_before_request_dispatch() {
    let plan = wyrd_spec::storage::UploadPlan::AzureBlockBlob {
        sas_url: "https://backend.invalid/blob".to_owned(),
        block_size_bytes: u64::MAX,
        block_count_planned: 1,
    };
    let wyrd = client();
    let error = WyrdStorageClient::new(&wyrd)
        .upload(
            &plan,
            Vec::<u8>::new(),
            UploadHooks {
                idempotency_key: "key",
                progress: None,
                part_url_minter: None,
            },
        )
        .await
        .expect_err("over-range dimensions must be rejected");
    assert!(matches!(error, StorageClientError::PlanInvalid(_)));
}

/// A single PUT of a sized source declares its `Content-Length` instead of
/// streaming chunked, so S3-compatible backends accept it.
///
/// # Panics
///
/// Panics when the backend sees no matching length or the upload fails.
#[tokio::test]
async fn single_put_declares_the_known_content_length() {
    use wiremock::matchers::{header, method};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    let backend = MockServer::start().await;
    Mock::given(method("PUT"))
        .and(header("content-length", "5"))
        .respond_with(ResponseTemplate::new(200))
        .expect(1)
        .mount(&backend)
        .await;
    let plan = wyrd_spec::storage::UploadPlan::SinglePut {
        put_url: format!("{}/bucket/object", backend.uri()),
        ttl_secs: 60,
        required_headers: Vec::new(),
    };
    let wyrd = client();
    let outcome = WyrdStorageClient::new(&wyrd)
        .upload(
            &plan,
            b"bytes".to_vec(),
            UploadHooks {
                idempotency_key: "key",
                progress: None,
                part_url_minter: None,
            },
        )
        .await
        .expect("a sized single PUT is accepted");
    assert!(matches!(outcome, UploadOutcome::Uploaded));
}