use bytes::Bytes;
use rand::Rng;
use restic_123pan::error::AppError;
use restic_123pan::pan123::Pan123Client;
use std::env;
fn get_test_credentials() -> Option<(String, String)> {
let client_id = env::var("PAN123_USERNAME").ok()?;
let client_secret = env::var("PAN123_PASSWORD").ok()?;
Some((client_id, client_secret))
}
macro_rules! skip_if_no_credentials {
() => {
if get_test_credentials().is_none() {
eprintln!("Skipping test: PAN123_USERNAME and PAN123_PASSWORD not set");
return;
}
};
}
async fn retry_on_rate_limit<F, Fut, T>(mut f: F) -> Result<T, AppError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, AppError>>,
{
let mut delay = std::time::Duration::from_millis(200);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
match f().await {
Ok(value) => return Ok(value),
Err(AppError::Pan123Api { code: 429, .. }) => {
if std::time::Instant::now() >= deadline {
return Err(AppError::Internal(
"Rate limited for over 60s during test operation".to_string(),
));
}
tokio::time::sleep(delay).await;
delay = std::cmp::min(delay * 2, std::time::Duration::from_secs(5));
}
Err(AppError::Auth(msg)) if msg.contains("code: 429") => {
if std::time::Instant::now() >= deadline {
return Err(AppError::Internal(
"Rate limited for over 60s during test operation".to_string(),
));
}
tokio::time::sleep(delay).await;
delay = std::cmp::min(delay * 2, std::time::Duration::from_secs(5));
}
Err(e) => return Err(e),
}
}
}
#[tokio::test]
async fn test_authentication() {
skip_if_no_credentials!();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let (username, password) = get_test_credentials().unwrap();
let client = Pan123Client::new(username, password, "/auth-test".to_string(), &db_url)
.await
.expect("Failed to create client");
let root_files = retry_on_rate_limit(|| client.list_files(0))
.await
.expect("Failed to list root");
println!("Root files count: {}", root_files.len());
}
#[tokio::test]
async fn test_list_root_directory() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test path");
let files = retry_on_rate_limit(|| client.list_files(0))
.await
.expect("Failed to list root");
assert!(!files.is_empty(), "Root should contain at least one directory");
let _ = client.delete_file(0, dir_id).await;
}
#[tokio::test]
async fn test_create_and_delete_directory() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create directory");
client
.delete_file(0, dir_id)
.await
.expect("Failed to delete directory");
let found = client
.find_path_id(&repo_path)
.await
.expect("find_path_id failed");
assert!(found.is_none(), "Directory should be deleted");
}
fn unique_test_path() -> String {
let suffix: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(8)
.map(char::from)
.collect();
format!("/cache-test-{}", suffix)
}
async fn create_test_client(repo_path: &str, db_url: &str) -> Option<Pan123Client> {
let (client_id, client_secret) = get_test_credentials()?;
Pan123Client::new(client_id, client_secret, repo_path.to_string(), db_url)
.await
.ok()
}
#[tokio::test]
async fn test_cache_scenario1_basic_cache_hit() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let files1 = client
.list_files(dir_id)
.await
.expect("First list_files failed");
let files2 = client
.list_files(dir_id)
.await
.expect("Second list_files failed");
assert_eq!(
files1.len(),
files2.len(),
"Cache should return same number of files"
);
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 1 passed: Basic cache hit works correctly");
}
#[tokio::test]
async fn test_cache_scenario2_upload_new_file() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let files_before = client.list_files(dir_id).await.expect("list_files failed");
assert!(files_before.is_empty(), "Directory should start empty");
let test_data = Bytes::from("test content for scenario 2");
let file_id = client
.upload_file(dir_id, "test-file.txt", test_data.clone())
.await
.expect("upload_file failed");
let files_after = client
.list_files(dir_id)
.await
.expect("list_files after upload failed");
assert_eq!(
files_after.len(),
1,
"Should have exactly one file after upload"
);
assert_eq!(
files_after[0].filename, "test-file.txt",
"Filename should match"
);
assert_eq!(
files_after[0].size,
test_data.len() as i64,
"Size should match"
);
assert_eq!(files_after[0].file_id, file_id, "File ID should match");
let _ = client.delete_file(dir_id, file_id).await;
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 2 passed: Upload new file updates cache correctly");
}
#[tokio::test]
async fn test_cache_scenario3_overwrite_upload() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let _ = client.list_files(dir_id).await.expect("list_files failed");
let data_v1 = Bytes::from("version 1 content - 100 bytes padding xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
let file_id_v1 = client
.upload_file(dir_id, "config", data_v1.clone())
.await
.expect("first upload failed");
let files_v1 = client
.list_files(dir_id)
.await
.expect("list after v1 failed");
assert_eq!(files_v1.len(), 1, "Should have one file after first upload");
assert_eq!(
files_v1[0].size,
data_v1.len() as i64,
"Size should match v1"
);
let data_v2 = Bytes::from("version 2 - different size content with more padding xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
let file_id_v2 = client
.upload_file(dir_id, "config", data_v2.clone())
.await
.expect("second upload failed");
let files_v2 = client
.list_files(dir_id)
.await
.expect("list after v2 failed");
assert_eq!(
files_v2.len(),
1,
"Should still have exactly one file after overwrite"
);
assert_eq!(
files_v2[0].filename, "config",
"Filename should be unchanged"
);
assert_eq!(
files_v2[0].size,
data_v2.len() as i64,
"Size should be updated to v2"
);
assert_eq!(files_v2[0].file_id, file_id_v2, "File ID should be updated");
assert_ne!(
file_id_v1, file_id_v2,
"File IDs should differ between versions"
);
let _ = client.delete_file(dir_id, file_id_v2).await;
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 3 passed: Overwrite upload updates cache correctly (no duplicates)");
}
#[tokio::test]
async fn test_cache_scenario4_delete_removes_from_cache() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let _ = client.list_files(dir_id).await.expect("list_files failed");
let test_data = Bytes::from("to be deleted");
let file_id = client
.upload_file(dir_id, "to_delete.txt", test_data)
.await
.expect("upload_file failed");
let files_before = client
.list_files(dir_id)
.await
.expect("list before delete failed");
assert_eq!(files_before.len(), 1, "Should have one file before delete");
client
.delete_file(dir_id, file_id)
.await
.expect("delete_file failed");
let files_after = client
.list_files(dir_id)
.await
.expect("list after delete failed");
assert!(files_after.is_empty(), "Cache should be empty after delete");
let found = client
.find_file(dir_id, "to_delete.txt")
.await
.expect("find_file failed");
assert!(
found.is_none(),
"find_file should return None for deleted file"
);
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 4 passed: Delete removes file from cache correctly");
}
#[tokio::test]
async fn test_cache_scenario5_idempotent_delete() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let test_data = Bytes::from("existing file");
let file_id = client
.upload_file(dir_id, "existing.txt", test_data)
.await
.expect("upload_file failed");
let files_before = client.list_files(dir_id).await.expect("list_files failed");
assert_eq!(files_before.len(), 1, "Should have one file");
let non_existent_id = 999999999i64;
let _ = client.delete_file(dir_id, non_existent_id).await;
let files_after = client
.list_files(dir_id)
.await
.expect("list after failed delete");
assert_eq!(files_after.len(), 1, "Cache should still have one file");
assert_eq!(
files_after[0].filename, "existing.txt",
"Original file should be intact"
);
let _ = client.delete_file(dir_id, file_id).await;
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 5 passed: Idempotent delete doesn't corrupt cache");
}
#[tokio::test]
async fn test_cache_scenario6_multi_directory_isolation() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let path_a = format!("{}/dir_a", repo_path);
let path_b = format!("{}/dir_b", repo_path);
let dir_a_id = retry_on_rate_limit(|| client.ensure_path(&path_a))
.await
.expect("Failed to create dir_a");
let dir_b_id = retry_on_rate_limit(|| client.ensure_path(&path_b))
.await
.expect("Failed to create dir_b");
let _ = client
.list_files(dir_a_id)
.await
.expect("list dir_a failed");
let _ = client
.list_files(dir_b_id)
.await
.expect("list dir_b failed");
let data_a = Bytes::from("file in dir_a");
let file_a_id = client
.upload_file(dir_a_id, "file_a.txt", data_a)
.await
.expect("upload to dir_a failed");
let data_b = Bytes::from("file in dir_b");
let file_b_id = client
.upload_file(dir_b_id, "file_b.txt", data_b)
.await
.expect("upload to dir_b failed");
let files_a = client
.list_files(dir_a_id)
.await
.expect("list dir_a after upload");
let files_b = client
.list_files(dir_b_id)
.await
.expect("list dir_b after upload");
assert_eq!(files_a.len(), 1, "dir_a should have 1 file");
assert_eq!(files_b.len(), 1, "dir_b should have 1 file");
assert_eq!(
files_a[0].filename, "file_a.txt",
"dir_a should have file_a.txt"
);
assert_eq!(
files_b[0].filename, "file_b.txt",
"dir_b should have file_b.txt"
);
let data_a2 = Bytes::from("another file in dir_a");
let file_a2_id = client
.upload_file(dir_a_id, "file_a2.txt", data_a2)
.await
.expect("second upload to dir_a failed");
let files_a_after = client.list_files(dir_a_id).await.expect("list dir_a final");
let files_b_after = client.list_files(dir_b_id).await.expect("list dir_b final");
assert_eq!(files_a_after.len(), 2, "dir_a should have 2 files");
assert_eq!(
files_b_after.len(),
1,
"dir_b should still have 1 file (not polluted)"
);
let _ = client.delete_file(dir_a_id, file_a_id).await;
let _ = client.delete_file(dir_a_id, file_a2_id).await;
let _ = client.delete_file(dir_b_id, file_b_id).await;
let _ = client.delete_file(0, dir_a_id).await;
let _ = client.delete_file(0, dir_b_id).await;
let repo_id = client.find_path_id(&repo_path).await.ok().flatten();
if let Some(id) = repo_id {
let _ = client.delete_file(0, id).await;
}
println!("Scenario 6 passed: Multi-directory caches are properly isolated");
}
#[tokio::test]
async fn test_cache_scenario7_upload_without_cache_init() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let test_data = Bytes::from("uploaded without cache");
let file_id = client
.upload_file(dir_id, "first.txt", test_data.clone())
.await
.expect("upload_file failed");
let files = client.list_files(dir_id).await.expect("list_files failed");
assert_eq!(files.len(), 1, "Should find the uploaded file");
assert_eq!(files[0].filename, "first.txt", "Filename should match");
assert_eq!(files[0].size, test_data.len() as i64, "Size should match");
let _ = client.delete_file(dir_id, file_id).await;
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 7 passed: Upload without prior cache init works correctly");
}
#[tokio::test]
async fn test_cache_scenario8_rapid_consecutive_operations() {
skip_if_no_credentials!();
let repo_path = unique_test_path();
let db_file = tempfile::NamedTempFile::new().unwrap();
let db_url = format!("sqlite:{}?mode=rwc", db_file.path().display());
let client = create_test_client(&repo_path, &db_url).await.unwrap();
let dir_id = retry_on_rate_limit(|| client.ensure_path(&repo_path))
.await
.expect("Failed to create test directory");
let _ = client.list_files(dir_id).await.expect("list_files failed");
let data_a = Bytes::from("file a");
let file_a_id = client
.upload_file(dir_id, "a.txt", data_a)
.await
.expect("upload a failed");
let data_b = Bytes::from("file b");
let file_b_id = client
.upload_file(dir_id, "b.txt", data_b)
.await
.expect("upload b failed");
client
.delete_file(dir_id, file_a_id)
.await
.expect("delete a failed");
let data_c = Bytes::from("file c");
let file_c_id = client
.upload_file(dir_id, "c.txt", data_c)
.await
.expect("upload c failed");
let files = client.list_files(dir_id).await.expect("final list failed");
assert_eq!(files.len(), 2, "Should have exactly 2 files (b and c)");
let filenames: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
assert!(filenames.contains(&"b.txt"), "Should contain b.txt");
assert!(filenames.contains(&"c.txt"), "Should contain c.txt");
assert!(
!filenames.contains(&"a.txt"),
"Should NOT contain a.txt (deleted)"
);
let _ = client.delete_file(dir_id, file_b_id).await;
let _ = client.delete_file(dir_id, file_c_id).await;
let _ = client.delete_file(0, dir_id).await;
println!("Scenario 8 passed: Rapid consecutive operations maintain cache consistency");
}