use assert_cmd::Command;
use chrono::{Duration, Utc};
use sharepoint_cli::auth::token_cache::{Account, CacheEntry, cache_key, upsert};
use wiremock::matchers::{method, path, query_param, query_param_is_missing};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn seed_cache(dir: &std::path::Path) {
let cache_dir = dir.join("sharepoint");
std::fs::create_dir_all(&cache_dir).unwrap();
upsert(
&cache_dir.join("tokens.json"),
&cache_key("contoso", "client-1", "OID"),
CacheEntry {
account: Account {
username: "u".into(),
name: Some("n".to_string()),
tenant_id: "contoso".into(),
oid: "OID".into(),
},
access_token: "AT".into(),
access_token_expires_at: Utc::now() + Duration::minutes(30),
refresh_token: Some("RT".to_string()),
scopes: vec![],
},
)
.unwrap();
}
async fn mount_site_and_drive(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/sites/contoso.sharepoint.com:/sites/Marketing"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "S1",
"displayName": "Marketing",
"webUrl": "https://contoso.sharepoint.com/sites/Marketing",
"name": "Marketing"
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/sites/S1/drives"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"value": [{"id": "D1", "name": "Documents", "driveType": "documentLibrary", "webUrl": ""}]
})))
.mount(server)
.await;
}
async fn mount_page1(server: &MockServer) -> String {
let next_link = format!("{}/drives/D1/root/children?$skiptoken=PAGE2", server.uri());
Mock::given(method("GET"))
.and(path("/drives/D1/root/children"))
.and(query_param_is_missing("$skiptoken"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"@odata.nextLink": next_link,
"value": [
{"id": "f1", "name": "file1.txt", "size": 100, "file": {}},
{"id": "f2", "name": "file2.txt", "size": 200, "file": {}},
{"id": "f3", "name": "file3.txt", "size": 300, "file": {}},
]
})))
.mount(server)
.await;
next_link
}
async fn mount_page2(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/drives/D1/root/children"))
.and(query_param("$skiptoken", "PAGE2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"value": [
{"id": "f4", "name": "file4.txt", "size": 400, "file": {}},
{"id": "f5", "name": "file5.txt", "size": 500, "file": {}},
{"id": "f6", "name": "file6.txt", "size": 600, "file": {}},
]
})))
.mount(server)
.await;
}
fn cli(server: &MockServer, dir: &tempfile::TempDir) -> Command {
let mut c = Command::cargo_bin("sharepoint").unwrap();
c.env("XDG_CACHE_HOME", dir.path())
.env("SHAREPOINT_TENANT_ID", "contoso")
.env("SHAREPOINT_CLIENT_ID", "client-1")
.env("SHAREPOINT_ACCESS_TOKEN", "AT")
.env("MICROSOFT_GRAPH_ENDPOINT", server.uri());
c
}
const SITE_REF: &str = "https://contoso.sharepoint.com/sites/Marketing/Documents";
#[tokio::test]
async fn files_ls_limit_mid_page_emits_cursor() {
let server = MockServer::start().await;
mount_site_and_drive(&server).await;
mount_page1(&server).await;
let dir = tempfile::tempdir().unwrap();
seed_cache(dir.path());
let out = cli(&server, &dir)
.args(["--output", "json", "files", "ls", "--limit", "2", SITE_REF])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["total"], 2, "expected 2 items, got {}", v["total"]);
assert!(
v["next"].is_string(),
"expected non-null next token, got {:?}",
v["next"]
);
let items = v["items"].as_array().unwrap();
assert_eq!(items[0]["name"], "file1.txt");
assert_eq!(items[1]["name"], "file2.txt");
}
#[tokio::test]
async fn files_ls_limit_mid_page_resumes_correctly() {
let server = MockServer::start().await;
mount_site_and_drive(&server).await;
let next_link = mount_page1(&server).await;
mount_page2(&server).await;
let dir = tempfile::tempdir().unwrap();
seed_cache(dir.path());
let first_out = cli(&server, &dir)
.args(["--output", "json", "files", "ls", "--limit", "2", SITE_REF])
.assert()
.success()
.get_output()
.stdout
.clone();
let first_v: serde_json::Value = serde_json::from_slice(&first_out).unwrap();
let token = first_v["next"]
.as_str()
.expect("first response must have a next token");
let _ = next_link;
let second_out = cli(&server, &dir)
.args([
"--output", "json", "files", "ls", "--limit", "4", "--page", token, SITE_REF,
])
.assert()
.success()
.get_output()
.stdout
.clone();
let second_v: serde_json::Value = serde_json::from_slice(&second_out).unwrap();
let items = second_v["items"].as_array().expect("items array");
assert_eq!(
items.len(),
4,
"expected 4 items on resume, got {}",
items.len()
);
assert_eq!(
items[0]["name"], "file3.txt",
"first resumed item must be file3"
);
assert_eq!(items[1]["name"], "file4.txt");
assert_eq!(items[2]["name"], "file5.txt");
assert_eq!(items[3]["name"], "file6.txt");
}
#[tokio::test]
async fn files_ls_all_follows_next_link() {
let server = MockServer::start().await;
mount_site_and_drive(&server).await;
mount_page1(&server).await;
mount_page2(&server).await;
let dir = tempfile::tempdir().unwrap();
seed_cache(dir.path());
let out = cli(&server, &dir)
.args(["--output", "json", "files", "ls", "--all", SITE_REF])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
let items = v["items"].as_array().expect("items array");
assert_eq!(
items.len(),
6,
"expected 6 items with --all, got {}",
items.len()
);
assert!(
v["next"].is_null(),
"next must be null when all items fetched"
);
}