nextcloud-preview 0.1.3

Nextcloud preview and thumbnail generation client
Documentation
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};

use nextcloud_sdk_core::{
    AuthProvider, Error, Headers, HttpMethod, Request, Response, StatusCode, Transport,
};

use super::PreviewClient;
use crate::mode::PreviewMode;
use crate::options::PreviewOptions;

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = std::pin::pin!(future);
    let mut context = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut context) {
            Poll::Ready(value) => return value,
            Poll::Pending => std::thread::yield_now(),
        }
    }
}

#[derive(Clone, Debug)]
struct FakeTransport {
    result: Arc<Mutex<Result<Response, String>>>,
    seen: Arc<Mutex<Option<Request>>>,
}

impl FakeTransport {
    fn with_success(status: u16, body: Vec<u8>) -> Self {
        Self {
            result: Arc::new(Mutex::new(Ok(Response::new(
                StatusCode::new(status),
                Headers::new(),
                body,
            )))),
            seen: Arc::new(Mutex::new(None)),
        }
    }

    fn with_failure(error_msg: &str) -> Self {
        Self {
            result: Arc::new(Mutex::new(Err(error_msg.to_owned()))),
            seen: Arc::new(Mutex::new(None)),
        }
    }

    fn request(&self) -> Option<Request> {
        self.seen.lock().ok().and_then(|seen| seen.clone())
    }
}

impl Transport for FakeTransport {
    async fn execute(&self, request: Request) -> Result<Response, Error> {
        if let Ok(mut seen) = self.seen.lock() {
            *seen = Some(request);
        }
        match self.result.lock().unwrap().clone() {
            Ok(resp) => Ok(resp),
            Err(msg) => Err(Error::transport(std::io::Error::other(msg))),
        }
    }
}

#[derive(Clone, Debug)]
struct FakeAuth {
    token: &'static str,
}

impl FakeAuth {
    const fn new(token: &'static str) -> Self {
        Self { token }
    }
}

impl AuthProvider for FakeAuth {
    fn authenticate(&self, headers: &mut Headers) {
        headers.set("Authorization", format!("Bearer {}", self.token));
    }
}

const FAKE_PNG_BYTES: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01];

#[test]
fn get_by_file_id_sends_correct_url_and_headers() {
    let transport = FakeTransport::with_success(200, FAKE_PNG_BYTES.to_vec());
    let auth = FakeAuth::new("preview-secret-token");
    let client = PreviewClient::new(&transport, &auth);

    let options = PreviewOptions::new()
        .width(128)
        .height(256)
        .keep_aspect_ratio(true)
        .mode(PreviewMode::Cover);

    let result = block_on(client.get_by_file_id(42, &options));
    assert!(result.is_ok());
    assert_eq!(result.unwrap(), FAKE_PNG_BYTES);

    let req = transport
        .request()
        .expect("request must have been recorded");
    assert_eq!(req.method(), HttpMethod::Get);
    assert_eq!(
        req.path().as_str(),
        "/index.php/core/preview.png?fileId=42&x=128&y=256&a=1&mode=cover"
    );

    assert_eq!(
        req.headers().get("authorization"),
        Some("Bearer preview-secret-token")
    );
}

#[test]
fn get_by_file_id_with_fill_mode_and_no_aspect_ratio() {
    let transport = FakeTransport::with_success(200, FAKE_PNG_BYTES.to_vec());
    let auth = FakeAuth::new("token123");
    let client = PreviewClient::new(&transport, &auth);

    let options = PreviewOptions::new()
        .dimensions(500, 500)
        .keep_aspect_ratio(false)
        .mode(PreviewMode::Fill);

    let result = block_on(client.get_by_file_id(1001, &options));
    assert!(result.is_ok());

    let req = transport
        .request()
        .expect("request must have been recorded");
    assert_eq!(
        req.path().as_str(),
        "/index.php/core/preview.png?fileId=1001&x=500&y=500&a=0&mode=fill"
    );
}

#[test]
fn get_by_file_id_minimal_options() {
    let transport = FakeTransport::with_success(200, FAKE_PNG_BYTES.to_vec());
    let auth = FakeAuth::new("token123");
    let client = PreviewClient::new(&transport, &auth);

    let options = PreviewOptions::new();
    let result = block_on(client.get_by_file_id(77, &options));
    assert!(result.is_ok());

    let req = transport
        .request()
        .expect("request must have been recorded");
    assert_eq!(req.path().as_str(), "/index.php/core/preview.png?fileId=77");
}

#[test]
fn get_by_path_sends_encoded_path_and_options() {
    let transport = FakeTransport::with_success(200, FAKE_PNG_BYTES.to_vec());
    let auth = FakeAuth::new("token-abc");
    let client = PreviewClient::new(&transport, &auth);

    let options = PreviewOptions::new().width(64).height(64);

    let result = block_on(client.get_by_path("Photos/summer vacation.jpg", &options));
    assert!(result.is_ok());
    assert_eq!(result.unwrap(), FAKE_PNG_BYTES);

    let req = transport
        .request()
        .expect("request must have been recorded");
    assert_eq!(req.method(), HttpMethod::Get);
    assert_eq!(
        req.path().as_str(),
        "/index.php/core/preview.png?file=Photos%2Fsummer%20vacation.jpg&x=64&y=64"
    );

    assert_eq!(req.headers().get("authorization"), Some("Bearer token-abc"));
}

#[test]
fn get_by_path_with_leading_slash_and_unicode() {
    let transport = FakeTransport::with_success(200, FAKE_PNG_BYTES.to_vec());
    let auth = FakeAuth::new("token-abc");
    let client = PreviewClient::new(&transport, &auth);

    let options = PreviewOptions::new().mode(PreviewMode::Cover);
    let result = block_on(client.get_by_path("/Bilder/münchen.png", &options));
    assert!(result.is_ok());

    let req = transport.request().expect("request recorded");
    assert_eq!(
        req.path().as_str(),
        "/index.php/core/preview.png?file=%2FBilder%2Fm%C3%BCnchen.png&mode=cover"
    );
}

#[test]
fn returns_status_error_on_404() {
    let transport = FakeTransport::with_success(404, b"File not found".to_vec());
    let auth = FakeAuth::new("token");
    let client = PreviewClient::new(&transport, &auth);

    let result = block_on(client.get_by_file_id(999, &PreviewOptions::new()));
    match result {
        Err(Error::Status(status_err)) => {
            assert_eq!(status_err.status(), StatusCode::new(404));
            assert_eq!(status_err.method(), "GET");
            assert_eq!(status_err.body(), "File not found");
        }
        other => panic!("expected Error::Status, got {other:?}"),
    }
}

#[test]
fn returns_status_error_on_500() {
    let transport = FakeTransport::with_success(500, b"Internal server error".to_vec());
    let auth = FakeAuth::new("token");
    let client = PreviewClient::new(&transport, &auth);

    let result = block_on(client.get_by_path("test.png", &PreviewOptions::new()));
    match result {
        Err(Error::Status(status_err)) => {
            assert_eq!(status_err.status(), StatusCode::new(500));
            assert_eq!(status_err.method(), "GET");
        }
        other => panic!("expected Error::Status, got {other:?}"),
    }
}

#[test]
fn propagates_transport_error() {
    let transport = FakeTransport::with_failure("connection reset");
    let auth = FakeAuth::new("token");
    let client = PreviewClient::new(&transport, &auth);

    let result = block_on(client.get_by_file_id(1, &PreviewOptions::new()));
    assert!(matches!(result, Err(Error::Transport(_))));
}

#[test]
fn client_accessors() {
    let transport = FakeTransport::with_success(200, vec![]);
    let auth = FakeAuth::new("token");
    let client = PreviewClient::new(&transport, &auth);

    assert_eq!(client.auth().token, "token");
    assert!(client.transport().request().is_none());
}