use tempfile::tempdir;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use todoist_api_rs::client::TodoistClient;
use todoist_cache_rs::{Cache, CacheStore, SyncManager};
fn mock_full_sync_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "new_sync_token_abc123",
"full_sync": true,
"full_sync_date_utc": "2025-01-26T10:00:00Z",
"items": [
{
"id": "item-1",
"project_id": "proj-1",
"content": "Buy groceries",
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": false
},
{
"id": "item-2",
"project_id": "proj-1",
"content": "Call dentist",
"description": "",
"priority": 2,
"child_order": 1,
"day_order": 0,
"is_collapsed": false,
"labels": ["health"],
"checked": false,
"is_deleted": false
}
],
"projects": [
{
"id": "proj-1",
"name": "Inbox",
"color": "grey",
"child_order": 0,
"is_collapsed": false,
"shared": false,
"can_assign_tasks": false,
"is_deleted": false,
"is_archived": false,
"is_favorite": false,
"inbox_project": true
}
],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
fn mock_incremental_sync_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "incremental_token_xyz789",
"full_sync": false,
"items": [
{
"id": "item-3",
"project_id": "proj-1",
"content": "New task from sync",
"description": "",
"priority": 1,
"child_order": 2,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": false
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_sync_performs_full_sync_when_no_cache() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=*")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager.cache().needs_full_sync());
let cache = manager.sync().await.expect("sync failed");
assert!(!cache.needs_full_sync());
assert_eq!(cache.sync_token, "new_sync_token_abc123");
assert_eq!(cache.items.len(), 2);
assert_eq!(cache.projects.len(), 1);
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.sync_token, "new_sync_token_abc123");
assert_eq!(loaded.items.len(), 2);
}
#[tokio::test]
async fn test_sync_performs_incremental_sync_with_existing_cache() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token_123".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "item-1".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Existing task".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=existing_token_123"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_incremental_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(!manager.cache().needs_full_sync());
let cache = manager.sync().await.expect("sync failed");
assert_eq!(cache.sync_token, "incremental_token_xyz789");
assert_eq!(cache.items.len(), 2); assert!(cache.items.iter().any(|i| i.id == "item-1"));
assert!(cache.items.iter().any(|i| i.id == "item-3"));
}
#[tokio::test]
async fn test_full_sync_forces_full_sync_even_with_existing_token() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token_123".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=*")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let cache = manager.full_sync().await.expect("full_sync failed");
assert_eq!(cache.sync_token, "new_sync_token_abc123");
assert_eq!(cache.items.len(), 2);
}
#[tokio::test]
async fn test_sync_persists_cache_to_disk() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
manager.sync().await.expect("sync failed");
assert!(cache_path.exists());
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.sync_token, "new_sync_token_abc123");
assert!(loaded.last_sync.is_some());
assert!(loaded.full_sync_date_utc.is_some());
}
#[tokio::test]
async fn test_sync_handles_api_error() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.sync().await;
assert!(result.is_err());
assert!(!manager.store().exists());
}
#[tokio::test]
async fn test_reload_refreshes_cache_from_disk() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
manager.sync().await.expect("sync failed");
assert_eq!(manager.cache().items.len(), 2);
let store2 = CacheStore::with_path(cache_path.clone());
let mut modified_cache = store2.load().expect("failed to load");
modified_cache.items.clear();
store2
.save(&modified_cache)
.expect("failed to save modified cache");
assert_eq!(manager.cache().items.len(), 2);
manager.reload().expect("reload failed");
assert_eq!(manager.cache().items.len(), 0);
}
#[tokio::test]
async fn test_is_stale_with_sync_manager() {
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut old_cache = Cache::new();
old_cache.sync_token = "old_token".to_string();
old_cache.last_sync = Some(chrono::Utc::now() - chrono::Duration::minutes(10));
store.save(&old_cache).expect("failed to save cache");
let client = TodoistClient::new("test-token").unwrap(); let store = CacheStore::with_path(cache_path);
let manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager.is_stale(chrono::Utc::now()));
assert!(manager.needs_sync(chrono::Utc::now()));
}
#[tokio::test]
async fn test_is_not_stale_when_recently_synced() {
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut fresh_cache = Cache::new();
fresh_cache.sync_token = "fresh_token".to_string();
fresh_cache.last_sync = Some(chrono::Utc::now());
store.save(&fresh_cache).expect("failed to save cache");
let client = TodoistClient::new("test-token").unwrap();
let store = CacheStore::with_path(cache_path);
let manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(!manager.is_stale(chrono::Utc::now()));
assert!(!manager.needs_sync(chrono::Utc::now()));
}
#[tokio::test]
async fn test_custom_stale_threshold() {
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut cache = Cache::new();
cache.sync_token = "token".to_string();
cache.last_sync = Some(chrono::Utc::now() - chrono::Duration::minutes(3));
store.save(&cache).expect("failed to save cache");
let client = TodoistClient::new("test-token").unwrap();
let store = CacheStore::with_path(cache_path.clone());
let manager5 = SyncManager::new(client.clone(), store).expect("failed to create manager");
assert!(!manager5.is_stale(chrono::Utc::now()));
let store2 = CacheStore::with_path(cache_path);
let manager2 =
SyncManager::with_stale_threshold(client, store2, 2).expect("failed to create manager");
assert!(manager2.is_stale(chrono::Utc::now()));
}
#[tokio::test]
async fn test_sync_updates_last_sync_timestamp() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager.cache().last_sync.is_none());
let before_sync = chrono::Utc::now();
manager.sync().await.expect("sync failed");
let after_sync = chrono::Utc::now();
let last_sync = manager.cache().last_sync.expect("last_sync should be set");
assert!(last_sync >= before_sync);
assert!(last_sync <= after_sync);
}
fn mock_command_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "post_command_token_456",
"full_sync": false,
"items": [
{
"id": "real-item-id-789",
"project_id": "proj-1",
"content": "New task from command",
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": false
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {
"test-cmd-uuid": "ok"
},
"temp_id_mapping": {
"temp-item-123": "real-item-id-789"
},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_execute_commands_adds_item_to_cache() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=existing_token"))
.and(body_string_contains("commands="))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_command_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(manager.cache().items.len(), 0);
let cmd = SyncCommand::with_temp_id(
SyncCommandType::ItemAdd,
"temp-item-123",
serde_json::json!({"content": "New task from command", "project_id": "proj-1"}),
);
let response = manager
.execute_commands(vec![cmd])
.await
.expect("execute_commands failed");
assert_eq!(
response.temp_id_mapping.get("temp-item-123"),
Some(&"real-item-id-789".to_string())
);
assert_eq!(manager.cache().items.len(), 1);
assert_eq!(manager.cache().items[0].id, "real-item-id-789");
assert_eq!(manager.cache().items[0].content, "New task from command");
assert_eq!(manager.cache().sync_token, "post_command_token_456");
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.items.len(), 1);
assert_eq!(loaded.sync_token, "post_command_token_456");
}
#[tokio::test]
async fn test_execute_commands_handles_api_error() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "original_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let cmd = SyncCommand::new(
SyncCommandType::ItemAdd,
serde_json::json!({"content": "Test"}),
);
let result = manager.execute_commands(vec![cmd]).await;
assert!(result.is_err());
assert_eq!(manager.cache().sync_token, "original_token");
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.sync_token, "original_token");
}
#[tokio::test]
async fn test_execute_commands_updates_last_sync() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "token".to_string();
existing_cache.last_sync = None; store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_command_response()))
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager.cache().last_sync.is_none());
let before = chrono::Utc::now();
let cmd = SyncCommand::new(
SyncCommandType::ItemAdd,
serde_json::json!({"content": "Test"}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("execute_commands failed");
let after = chrono::Utc::now();
let last_sync = manager.cache().last_sync.expect("last_sync should be set");
assert!(last_sync >= before);
assert!(last_sync <= after);
}
fn mock_delete_command_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "post_delete_token_789",
"full_sync": false,
"items": [
{
"id": "item-to-delete",
"project_id": "proj-1",
"content": "Task to delete",
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": true
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {
"delete-cmd-uuid": "ok"
},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_execute_commands_removes_item_on_delete() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "pre_delete_token".to_string();
existing_cache.items = vec![
todoist_api_rs::sync::Item {
id: "item-to-delete".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Task to delete".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
},
todoist_api_rs::sync::Item {
id: "item-to-keep".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Task to keep".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 1,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
},
];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("commands="))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_delete_command_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(manager.cache().items.len(), 2);
assert!(manager
.cache()
.items
.iter()
.any(|i| i.id == "item-to-delete"));
let cmd = SyncCommand::new(
SyncCommandType::ItemDelete,
serde_json::json!({"id": "item-to-delete"}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("execute_commands failed");
assert_eq!(manager.cache().items.len(), 1);
assert!(!manager
.cache()
.items
.iter()
.any(|i| i.id == "item-to-delete"));
assert!(manager.cache().items.iter().any(|i| i.id == "item-to-keep"));
assert_eq!(manager.cache().sync_token, "post_delete_token_789");
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.items.len(), 1);
assert!(!loaded.items.iter().any(|i| i.id == "item-to-delete"));
}
fn mock_update_command_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "post_update_token_abc",
"full_sync": false,
"items": [
{
"id": "item-to-update",
"project_id": "proj-1",
"content": "Updated task content",
"description": "New description",
"priority": 4,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": ["work"],
"checked": false,
"is_deleted": false
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {
"update-cmd-uuid": "ok"
},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_execute_commands_updates_item_on_edit() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "pre_update_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "item-to-update".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Original task content".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("commands="))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_update_command_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(manager.cache().items.len(), 1);
assert_eq!(manager.cache().items[0].content, "Original task content");
assert_eq!(manager.cache().items[0].description, "");
assert_eq!(manager.cache().items[0].priority, 1);
assert!(manager.cache().items[0].labels.is_empty());
let cmd = SyncCommand::new(
SyncCommandType::ItemUpdate,
serde_json::json!({
"id": "item-to-update",
"content": "Updated task content",
"description": "New description",
"priority": 4,
"labels": ["work"]
}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("execute_commands failed");
assert_eq!(manager.cache().items.len(), 1);
let updated_item = &manager.cache().items[0];
assert_eq!(updated_item.id, "item-to-update");
assert_eq!(updated_item.content, "Updated task content");
assert_eq!(updated_item.description, "New description");
assert_eq!(updated_item.priority, 4);
assert_eq!(updated_item.labels, vec!["work"]);
assert_eq!(manager.cache().sync_token, "post_update_token_abc");
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.items.len(), 1);
assert_eq!(loaded.items[0].content, "Updated task content");
assert_eq!(loaded.items[0].priority, 4);
}
fn mock_sync_response_with_project(project_id: &str, project_name: &str) -> serde_json::Value {
serde_json::json!({
"sync_token": "sync_after_resolve_token",
"full_sync": false,
"items": [],
"projects": [
{
"id": project_id,
"name": project_name,
"color": "blue",
"child_order": 0,
"is_collapsed": false,
"shared": false,
"can_assign_tasks": false,
"is_deleted": false,
"is_archived": false,
"is_favorite": false,
"inbox_project": false
}
],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
fn mock_empty_sync_response() -> serde_json::Value {
serde_json::json!({
"sync_token": "sync_empty_token",
"full_sync": false,
"items": [],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_resolve_project_succeeds_from_cache_no_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
existing_cache.projects = vec![todoist_api_rs::sync::Project {
id: "proj-in-cache".to_string(),
name: "Work".to_string(),
color: Some("red".to_string()),
parent_id: None,
child_order: 0,
is_collapsed: false,
shared: false,
can_assign_tasks: false,
is_deleted: false,
is_archived: false,
is_favorite: false,
inbox_project: false,
view_style: None,
folder_id: None,
created_at: None,
updated_at: None,
}];
store.save(&existing_cache).expect("failed to save cache");
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let project = manager
.resolve_project("work")
.await
.expect("resolve_project failed");
assert_eq!(project.id, "proj-in-cache");
assert_eq!(project.name, "Work");
let project = manager
.resolve_project("proj-in-cache")
.await
.expect("resolve_project failed");
assert_eq!(project.id, "proj-in-cache");
}
#[tokio::test]
async fn test_resolve_project_syncs_on_cache_miss_then_succeeds() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_sync_response_with_project(
"proj-from-sync",
"New Project",
)),
)
.expect(1) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager
.cache()
.projects
.iter()
.all(|p| p.name != "New Project"));
let project = manager
.resolve_project("New Project")
.await
.expect("resolve_project failed");
assert_eq!(project.id, "proj-from-sync");
assert_eq!(project.name, "New Project");
assert!(manager
.cache()
.projects
.iter()
.any(|p| p.id == "proj-from-sync"));
}
#[tokio::test]
async fn test_resolve_project_returns_not_found_after_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_empty_sync_response()))
.expect(1) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.resolve_project("NonexistentProject").await;
assert!(result.is_err());
let err = result.unwrap_err();
match err {
todoist_cache_rs::SyncError::NotFound {
resource_type,
identifier,
..
} => {
assert_eq!(resource_type, "Project");
assert_eq!(identifier, "NonexistentProject");
}
other => panic!("Expected NotFound error, got: {:?}", other),
}
}
fn mock_sync_response_with_section(
section_id: &str,
section_name: &str,
project_id: &str,
) -> serde_json::Value {
serde_json::json!({
"sync_token": "sync_section_token",
"full_sync": false,
"items": [],
"projects": [],
"labels": [],
"sections": [
{
"id": section_id,
"name": section_name,
"project_id": project_id,
"section_order": 0,
"collapsed": false,
"is_deleted": false,
"is_archived": false
}
],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_resolve_section_succeeds_from_cache_no_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
existing_cache.sections = vec![todoist_api_rs::sync::Section {
id: "sec-in-cache".to_string(),
name: "To Do".to_string(),
project_id: "proj-1".to_string(),
section_order: 0,
is_collapsed: false,
is_deleted: false,
is_archived: false,
added_at: None,
archived_at: None,
updated_at: None,
}];
store.save(&existing_cache).expect("failed to save cache");
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let section = manager
.resolve_section("to do", None)
.await
.expect("resolve_section failed");
assert_eq!(section.id, "sec-in-cache");
}
#[tokio::test]
async fn test_resolve_section_syncs_on_cache_miss_then_succeeds() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_sync_response_with_section(
"sec-from-sync",
"Done",
"proj-1",
)),
)
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let section = manager
.resolve_section("Done", None)
.await
.expect("resolve_section failed");
assert_eq!(section.id, "sec-from-sync");
assert_eq!(section.name, "Done");
}
#[tokio::test]
async fn test_resolve_section_returns_not_found_after_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_empty_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.resolve_section("Nonexistent", None).await;
assert!(result.is_err());
match result.unwrap_err() {
todoist_cache_rs::SyncError::NotFound {
resource_type,
identifier,
..
} => {
assert_eq!(resource_type, "Section");
assert_eq!(identifier, "Nonexistent");
}
other => panic!("Expected NotFound error, got: {:?}", other),
}
}
fn mock_sync_response_with_item(item_id: &str, content: &str, checked: bool) -> serde_json::Value {
serde_json::json!({
"sync_token": "sync_item_token",
"full_sync": false,
"items": [
{
"id": item_id,
"project_id": "proj-1",
"content": content,
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": checked,
"is_deleted": false
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": {},
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_resolve_item_succeeds_from_cache_no_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "item-in-cache".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Buy milk".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let item = manager
.resolve_item("item-in-cache")
.await
.expect("resolve_item failed");
assert_eq!(item.id, "item-in-cache");
assert_eq!(item.content, "Buy milk");
}
#[tokio::test]
async fn test_resolve_item_syncs_on_cache_miss_then_succeeds() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_sync_response_with_item(
"item-from-sync",
"New task",
false,
)),
)
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let item = manager
.resolve_item("item-from-sync")
.await
.expect("resolve_item failed");
assert_eq!(item.id, "item-from-sync");
assert_eq!(item.content, "New task");
}
#[tokio::test]
async fn test_resolve_item_returns_not_found_after_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_empty_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.resolve_item("nonexistent-id").await;
assert!(result.is_err());
match result.unwrap_err() {
todoist_cache_rs::SyncError::NotFound {
resource_type,
identifier,
..
} => {
assert_eq!(resource_type, "Item");
assert_eq!(identifier, "nonexistent-id");
}
other => panic!("Expected NotFound error, got: {:?}", other),
}
}
#[tokio::test]
async fn test_resolve_item_by_prefix_succeeds_from_cache_no_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "abcdef123456".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Task with long ID".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let item = manager
.resolve_item_by_prefix("abcdef", None)
.await
.expect("resolve_item_by_prefix failed");
assert_eq!(item.id, "abcdef123456");
}
#[tokio::test]
async fn test_resolve_item_by_prefix_syncs_on_cache_miss_then_succeeds() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_sync_response_with_item(
"xyz789abcdef",
"Synced task",
false,
)),
)
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let item = manager
.resolve_item_by_prefix("xyz789", None)
.await
.expect("resolve_item_by_prefix failed");
assert_eq!(item.id, "xyz789abcdef");
}
#[tokio::test]
async fn test_resolve_item_by_prefix_returns_not_found_after_sync() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_empty_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.resolve_item_by_prefix("nonexistent", None).await;
assert!(result.is_err());
match result.unwrap_err() {
todoist_cache_rs::SyncError::NotFound {
resource_type,
identifier,
..
} => {
assert_eq!(resource_type, "Item");
assert_eq!(identifier, "nonexistent");
}
other => panic!("Expected NotFound error, got: {:?}", other),
}
}
#[tokio::test]
async fn test_resolve_item_by_prefix_with_require_checked_filter() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "existing_token".to_string();
existing_cache.items = vec![
todoist_api_rs::sync::Item {
id: "completed-task-123".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Completed task".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: true, is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
},
todoist_api_rs::sync::Item {
id: "active-task-456".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Active task".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 1,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false, is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
},
];
store.save(&existing_cache).expect("failed to save cache");
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let item = manager
.resolve_item_by_prefix("completed", Some(true))
.await
.expect("should find completed task");
assert_eq!(item.id, "completed-task-123");
assert!(item.checked);
let item = manager
.resolve_item_by_prefix("active", Some(false))
.await
.expect("should find active task");
assert_eq!(item.id, "active-task-456");
assert!(!item.checked);
}
fn mock_invalid_sync_token_response() -> ResponseTemplate {
ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "Validation error",
"error_code": 34,
"error_extra": {},
"error_tag": "SYNC_TOKEN_INVALID",
"http_code": 400
}))
}
#[tokio::test]
async fn test_sync_falls_back_to_full_sync_on_invalid_token() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "old_invalid_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "old-item".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Old task".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=old_invalid_token"))
.respond_with(mock_invalid_sync_token_response())
.expect(1)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/sync"))
.and(body_string_contains("sync_token=*")) .respond_with(ResponseTemplate::new(200).set_body_json(mock_full_sync_response()))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(manager.cache().sync_token, "old_invalid_token");
assert_eq!(manager.cache().items.len(), 1);
assert_eq!(manager.cache().items[0].id, "old-item");
let cache = manager
.sync()
.await
.expect("sync should recover via full sync");
assert_eq!(cache.sync_token, "new_sync_token_abc123");
assert_eq!(cache.items.len(), 2);
assert!(cache.items.iter().any(|i| i.id == "item-1"));
assert!(cache.items.iter().any(|i| i.id == "item-2"));
assert!(!cache.items.iter().any(|i| i.id == "old-item"));
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load cache");
assert_eq!(loaded.sync_token, "new_sync_token_abc123");
}
#[tokio::test]
async fn test_sync_full_sync_does_not_trigger_fallback() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let empty_cache = Cache::new(); store.save(&empty_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
.expect(1) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(manager.cache().needs_full_sync());
let result = manager.sync().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_sync_non_token_errors_propagate() {
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "some_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.expect(1) .mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path);
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
let result = manager.sync().await;
assert!(result.is_err());
assert_eq!(manager.cache().sync_token, "some_token");
}
fn mock_item_add_response(item_id: &str, content: &str, project_id: &str) -> serde_json::Value {
serde_json::json!({
"sync_token": "token_after_add",
"full_sync": false,
"items": [
{
"id": item_id,
"project_id": project_id,
"content": content,
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": false
}
],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": { "add-uuid": "ok" },
"temp_id_mapping": { "temp-add-id": item_id },
"completed_info": [],
"locations": []
})
}
#[tokio::test]
async fn test_add_item_is_visible_immediately_without_sync() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "initial_token".to_string();
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(
ResponseTemplate::new(200).set_body_json(mock_item_add_response(
"item-abc123",
"Buy groceries",
"proj-1",
)),
)
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert!(
manager.cache().items.is_empty(),
"cache should be empty before add"
);
let cmd = SyncCommand::with_temp_id(
SyncCommandType::ItemAdd,
"temp-add-id",
serde_json::json!({"content": "Buy groceries", "project_id": "proj-1"}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("add failed");
assert_eq!(manager.cache().items.len(), 1, "item should be in cache");
assert_eq!(
manager.cache().items[0].content,
"Buy groceries",
"item content should match"
);
assert_eq!(
manager.cache().items[0].id,
"item-abc123",
"item should have real ID from response"
);
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load");
assert_eq!(loaded.items.len(), 1, "item should persist on disk");
assert_eq!(loaded.items[0].content, "Buy groceries");
}
#[tokio::test]
async fn test_deleted_item_not_visible_without_sync() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "initial_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "item-to-delete".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Task to delete".to_string(),
description: String::new(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sync_token": "token_after_delete",
"full_sync": false,
"items": [{
"id": "item-to-delete",
"project_id": "proj-1",
"content": "Task to delete",
"description": "",
"priority": 1,
"child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": [],
"checked": false,
"is_deleted": true }],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": { "delete-uuid": "ok" },
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(
manager.cache().items.len(),
1,
"should have one item before"
);
let cmd = SyncCommand::new(
SyncCommandType::ItemDelete,
serde_json::json!({"id": "item-to-delete"}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("delete failed");
assert!(
manager.cache().items.is_empty(),
"deleted item should not be in cache"
);
assert!(
!manager
.cache()
.items
.iter()
.any(|i| i.id == "item-to-delete"),
"deleted item should not be findable"
);
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load");
assert!(loaded.items.is_empty(), "deletion should persist on disk");
}
#[tokio::test]
async fn test_edited_item_shows_updated_content_without_sync() {
use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
let mock_server = MockServer::start().await;
let temp_dir = tempdir().expect("failed to create temp dir");
let cache_path = temp_dir.path().join("cache.json");
let store = CacheStore::with_path(cache_path.clone());
let mut existing_cache = Cache::new();
existing_cache.sync_token = "initial_token".to_string();
existing_cache.items = vec![todoist_api_rs::sync::Item {
id: "item-to-edit".to_string(),
user_id: None,
project_id: "proj-1".to_string(),
content: "Original content".to_string(),
description: "".to_string(),
priority: 1,
due: None,
deadline: None,
parent_id: None,
child_order: 0,
section_id: None,
day_order: 0,
is_collapsed: false,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
updated_at: None,
completed_at: None,
duration: None,
}];
store.save(&existing_cache).expect("failed to save cache");
Mock::given(method("POST"))
.and(path("/sync"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sync_token": "token_after_edit",
"full_sync": false,
"items": [{
"id": "item-to-edit",
"project_id": "proj-1",
"content": "Updated content", "description": "New description", "priority": 4, "child_order": 0,
"day_order": 0,
"is_collapsed": false,
"labels": ["work"], "checked": false,
"is_deleted": false
}],
"projects": [],
"labels": [],
"sections": [],
"notes": [],
"project_notes": [],
"reminders": [],
"filters": [],
"collaborators": [],
"collaborator_states": [],
"live_notifications": [],
"sync_status": { "edit-uuid": "ok" },
"temp_id_mapping": {},
"completed_info": [],
"locations": []
})))
.expect(1)
.mount(&mock_server)
.await;
let client = TodoistClient::with_base_url("test-token", mock_server.uri()).unwrap();
let store = CacheStore::with_path(cache_path.clone());
let mut manager = SyncManager::new(client, store).expect("failed to create manager");
assert_eq!(manager.cache().items.len(), 1);
assert_eq!(manager.cache().items[0].content, "Original content");
assert_eq!(manager.cache().items[0].priority, 1);
let cmd = SyncCommand::new(
SyncCommandType::ItemUpdate,
serde_json::json!({
"id": "item-to-edit",
"content": "Updated content",
"description": "New description",
"priority": 4,
"labels": ["work"]
}),
);
manager
.execute_commands(vec![cmd])
.await
.expect("edit failed");
assert_eq!(manager.cache().items.len(), 1);
let item = &manager.cache().items[0];
assert_eq!(item.id, "item-to-edit");
assert_eq!(item.content, "Updated content", "content should be updated");
assert_eq!(
item.description, "New description",
"description should be updated"
);
assert_eq!(item.priority, 4, "priority should be updated");
assert_eq!(item.labels, vec!["work"], "labels should be updated");
let store2 = CacheStore::with_path(cache_path);
let loaded = store2.load().expect("failed to load");
assert_eq!(loaded.items.len(), 1);
assert_eq!(loaded.items[0].content, "Updated content");
assert_eq!(loaded.items[0].priority, 4);
}