use base64::{Engine as _, engine::general_purpose};
use chrono::{DateTime, Utc};
use reqwest::{RequestBuilder, header};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::time::Duration;
use crate::constants::{API_BASE_URL, API_TIMEOUT_SECS};
use crate::error::{AppError, Result};
use crate::models::{
ClientId, Project, ProjectId, Tag, TagId, TaskId, TimeEntry, TimeEntryId, User, WorkspaceId,
};
use super::wire::{
CreateClientRequest, CreateProjectRequest, CreateTagRequest, CreateTimeEntryRequest,
UpdateClientRequest, UpdateProjectRequest, UpdateTagRequest, UpdateTimeEntryRequest,
WireClient, WireProject, WireTag, WireTimeEntry, WireUser, WireWorkspace,
};
#[cfg(test)]
use mockall::automock;
pub struct CreateTimeEntryParams {
pub description: Option<String>,
pub project_id: Option<ProjectId>,
pub task_id: Option<TaskId>,
pub tags: Vec<String>,
pub billable: bool,
pub start: Option<DateTime<Utc>>,
pub stop: Option<DateTime<Utc>>,
pub duration: Option<i64>,
}
impl From<&TimeEntry> for CreateTimeEntryParams {
fn from(entry: &TimeEntry) -> Self {
Self {
description: entry.description.clone(),
project_id: entry.project_id,
task_id: entry.task_id,
tags: entry.tags.clone(),
billable: entry.billable,
start: None,
stop: None,
duration: None,
}
}
}
pub struct UpdateTimeEntryParams {
pub description: Option<String>,
pub project_id: Option<ProjectId>,
pub tags: Option<Vec<String>>,
pub billable: Option<bool>,
pub start: Option<DateTime<Utc>>,
pub stop: Option<DateTime<Utc>>,
pub duration: Option<i64>,
}
pub struct CreateProjectParams {
pub name: String,
pub client_id: Option<i64>,
}
pub struct UpdateProjectParams {
pub name: Option<String>,
pub client_id: Option<i64>,
}
#[cfg_attr(test, automock)]
pub trait ApiClient {
async fn get_me(&self) -> Result<User>;
async fn get_current_timer(&self) -> Result<Option<TimeEntry>>;
async fn get_time_entries(
&self,
since: Option<String>,
until: Option<String>,
) -> Result<Vec<TimeEntry>>;
async fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry>;
async fn create_time_entry(
&self,
workspace_id: WorkspaceId,
params: &CreateTimeEntryParams,
) -> Result<TimeEntry>;
async fn update_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
params: &UpdateTimeEntryParams,
) -> Result<TimeEntry>;
async fn delete_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
) -> Result<()>;
async fn stop_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
) -> Result<TimeEntry>;
async fn list_projects(&self, workspace_id: WorkspaceId) -> Result<Vec<Project>>;
async fn get_project(
&self,
workspace_id: WorkspaceId,
project_id: ProjectId,
) -> Result<Project>;
async fn create_project(
&self,
workspace_id: WorkspaceId,
params: &CreateProjectParams,
) -> Result<Project>;
async fn update_project(
&self,
workspace_id: WorkspaceId,
project_id: ProjectId,
params: &UpdateProjectParams,
) -> Result<Project>;
async fn delete_project(&self, workspace_id: WorkspaceId, project_id: ProjectId) -> Result<()>;
async fn list_tags(&self, workspace_id: WorkspaceId) -> Result<Vec<Tag>>;
async fn create_tag(&self, workspace_id: WorkspaceId, name: &str) -> Result<Tag>;
async fn update_tag(&self, workspace_id: WorkspaceId, tag_id: TagId, name: &str)
-> Result<Tag>;
async fn delete_tag(&self, workspace_id: WorkspaceId, tag_id: TagId) -> Result<()>;
async fn list_clients(&self, workspace_id: WorkspaceId) -> Result<Vec<crate::models::Client>>;
async fn create_client(
&self,
workspace_id: WorkspaceId,
name: &str,
) -> Result<crate::models::Client>;
async fn update_client(
&self,
workspace_id: WorkspaceId,
client_id: ClientId,
name: &str,
) -> Result<crate::models::Client>;
async fn delete_client(&self, workspace_id: WorkspaceId, client_id: ClientId) -> Result<()>;
async fn get_client(
&self,
workspace_id: WorkspaceId,
client_id: ClientId,
) -> Result<crate::models::Client>;
async fn list_workspaces(&self) -> Result<Vec<crate::models::Workspace>>;
async fn get_workspace(&self, workspace_id: WorkspaceId) -> Result<crate::models::Workspace>;
}
pub struct TogglClient {
http: reqwest::Client,
base_url: String,
}
impl TogglClient {
fn build(api_token: &str, base_url: String) -> Result<Self> {
let auth = format!("{api_token}:api_token");
let encoded = general_purpose::STANDARD.encode(auth);
let header_value = header::HeaderValue::from_str(&format!("Basic {encoded}"))
.map_err(|e| AppError::Auth(format!("Invalid token: {e}")))?;
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, header_value);
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(API_TIMEOUT_SECS))
.user_agent(format!("tgltrk/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| AppError::Api(format!("Failed to build HTTP client: {e}")))?;
Ok(Self { http, base_url })
}
pub fn new(api_token: &str) -> Result<Self> {
Self::build(api_token, API_BASE_URL.to_string())
}
pub fn new_with_base_url(api_token: &str, base_url: &str) -> Result<Self> {
Self::build(api_token, base_url.to_string())
}
async fn get<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
self.send(self.http.get(url)).await
}
async fn post<T: DeserializeOwned, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
self.send(self.http.post(url).json(body)).await
}
async fn put<T: DeserializeOwned, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
self.send(self.http.put(url).json(body)).await
}
async fn patch<T: DeserializeOwned, B: Serialize>(&self, url: &str, body: &B) -> Result<T> {
self.send(self.http.patch(url).json(body)).await
}
async fn check_response(&self, response: reqwest::Response) -> Result<reqwest::Response> {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(AppError::HttpStatus {
status: status.as_u16(),
body,
});
}
Ok(response)
}
async fn send<T: DeserializeOwned>(&self, request: RequestBuilder) -> Result<T> {
let response = self.check_response(request.send().await?).await?;
let parsed = response.json::<T>().await?;
Ok(parsed)
}
async fn delete_request(&self, url: &str) -> Result<()> {
self.check_response(self.http.delete(url).send().await?)
.await?;
Ok(())
}
}
impl ApiClient for TogglClient {
async fn get_me(&self) -> Result<User> {
let url = format!("{}/me", self.base_url);
let wire: WireUser = self.get(&url).await?;
Ok(wire.into())
}
async fn get_current_timer(&self) -> Result<Option<TimeEntry>> {
let url = format!("{}/me/time_entries/current", self.base_url);
let response = self.http.get(&url).send().await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(AppError::HttpStatus {
status: status.as_u16(),
body,
});
}
let text = response.text().await?;
let text = text.trim();
if text == "null" || text.is_empty() {
return Ok(None);
}
let wire: WireTimeEntry = serde_json::from_str(text)?;
Ok(Some(wire.into()))
}
async fn get_time_entries(
&self,
since: Option<String>,
until: Option<String>,
) -> Result<Vec<TimeEntry>> {
let url = format!("{}/me/time_entries", self.base_url);
let mut request = self.http.get(&url);
if let Some(s) = &since {
request = request.query(&[("start_date", s)]);
}
if let Some(u) = &until {
request = request.query(&[("end_date", u)]);
}
let response = self.check_response(request.send().await?).await?;
let wire: Vec<WireTimeEntry> = response.json().await?;
Ok(wire.into_iter().map(Into::into).collect())
}
async fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry> {
let url = format!("{}/me/time_entries/{entry_id}", self.base_url);
let wire: WireTimeEntry = self.get(&url).await?;
Ok(wire.into())
}
async fn create_time_entry(
&self,
workspace_id: WorkspaceId,
params: &CreateTimeEntryParams,
) -> Result<TimeEntry> {
let url = format!("{}/workspaces/{workspace_id}/time_entries", self.base_url);
let now = Utc::now();
let start = params.start.unwrap_or(now);
let duration = params.duration.unwrap_or(-start.timestamp());
let body = CreateTimeEntryRequest {
workspace_id,
description: params.description.clone(),
project_id: params.project_id,
task_id: params.task_id,
tags: params.tags.clone(),
billable: params.billable,
start,
duration,
created_with: crate::constants::CLIENT_NAME.to_string(),
stop: params.stop,
};
let wire: WireTimeEntry = self.post(&url, &body).await?;
Ok(wire.into())
}
async fn update_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
params: &UpdateTimeEntryParams,
) -> Result<TimeEntry> {
let url = format!(
"{}/workspaces/{workspace_id}/time_entries/{entry_id}",
self.base_url
);
let body = UpdateTimeEntryRequest {
description: params.description.clone(),
project_id: params.project_id,
tags: params.tags.clone(),
billable: params.billable,
start: params.start,
stop: params.stop,
duration: params.duration,
};
let wire: WireTimeEntry = self.put(&url, &body).await?;
Ok(wire.into())
}
async fn delete_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
) -> Result<()> {
let url = format!(
"{}/workspaces/{workspace_id}/time_entries/{entry_id}",
self.base_url
);
self.delete_request(&url).await
}
async fn stop_time_entry(
&self,
workspace_id: WorkspaceId,
entry_id: TimeEntryId,
) -> Result<TimeEntry> {
let url = format!(
"{}/workspaces/{workspace_id}/time_entries/{entry_id}/stop",
self.base_url
);
let wire: WireTimeEntry = self.patch(&url, &serde_json::json!({})).await?;
Ok(wire.into())
}
async fn list_projects(&self, workspace_id: WorkspaceId) -> Result<Vec<Project>> {
let url = format!("{}/workspaces/{workspace_id}/projects", self.base_url);
let wire: Vec<WireProject> = self.get(&url).await?;
Ok(wire.into_iter().map(Into::into).collect())
}
async fn get_project(
&self,
workspace_id: WorkspaceId,
project_id: ProjectId,
) -> Result<Project> {
let url = format!(
"{}/workspaces/{workspace_id}/projects/{project_id}",
self.base_url
);
let wire: WireProject = self.get(&url).await?;
Ok(wire.into())
}
async fn create_project(
&self,
workspace_id: WorkspaceId,
params: &CreateProjectParams,
) -> Result<Project> {
let url = format!("{}/workspaces/{workspace_id}/projects", self.base_url);
let body = CreateProjectRequest {
name: params.name.clone(),
active: true,
client_id: params.client_id,
};
let wire: WireProject = self.post(&url, &body).await?;
Ok(wire.into())
}
async fn update_project(
&self,
workspace_id: WorkspaceId,
project_id: ProjectId,
params: &UpdateProjectParams,
) -> Result<Project> {
let url = format!(
"{}/workspaces/{workspace_id}/projects/{project_id}",
self.base_url
);
let body = UpdateProjectRequest {
name: params.name.clone(),
client_id: params.client_id,
};
let wire: WireProject = self.put(&url, &body).await?;
Ok(wire.into())
}
async fn delete_project(&self, workspace_id: WorkspaceId, project_id: ProjectId) -> Result<()> {
let url = format!(
"{}/workspaces/{workspace_id}/projects/{project_id}",
self.base_url
);
self.delete_request(&url).await
}
async fn list_tags(&self, workspace_id: WorkspaceId) -> Result<Vec<Tag>> {
let url = format!("{}/workspaces/{workspace_id}/tags", self.base_url);
let wire: Vec<WireTag> = self.get(&url).await?;
Ok(wire.into_iter().map(Into::into).collect())
}
async fn create_tag(&self, workspace_id: WorkspaceId, name: &str) -> Result<Tag> {
let url = format!("{}/workspaces/{workspace_id}/tags", self.base_url);
let body = CreateTagRequest {
name: name.to_string(),
};
let wire: WireTag = self.post(&url, &body).await?;
Ok(wire.into())
}
async fn update_tag(
&self,
workspace_id: WorkspaceId,
tag_id: TagId,
name: &str,
) -> Result<Tag> {
let url = format!("{}/workspaces/{workspace_id}/tags/{tag_id}", self.base_url);
let body = UpdateTagRequest {
name: name.to_string(),
};
let wire: WireTag = self.put(&url, &body).await?;
Ok(wire.into())
}
async fn delete_tag(&self, workspace_id: WorkspaceId, tag_id: TagId) -> Result<()> {
let url = format!("{}/workspaces/{workspace_id}/tags/{tag_id}", self.base_url);
self.delete_request(&url).await
}
async fn list_clients(&self, workspace_id: WorkspaceId) -> Result<Vec<crate::models::Client>> {
let url = format!("{}/workspaces/{workspace_id}/clients", self.base_url);
let wire: Vec<WireClient> = self.get(&url).await?;
Ok(wire.into_iter().map(Into::into).collect())
}
async fn create_client(
&self,
workspace_id: WorkspaceId,
name: &str,
) -> Result<crate::models::Client> {
let url = format!("{}/workspaces/{workspace_id}/clients", self.base_url);
let body = CreateClientRequest {
name: name.to_string(),
};
let wire: WireClient = self.post(&url, &body).await?;
Ok(wire.into())
}
async fn update_client(
&self,
workspace_id: WorkspaceId,
client_id: ClientId,
name: &str,
) -> Result<crate::models::Client> {
let url = format!(
"{}/workspaces/{workspace_id}/clients/{client_id}",
self.base_url
);
let body = UpdateClientRequest {
name: name.to_string(),
};
let wire: WireClient = self.put(&url, &body).await?;
Ok(wire.into())
}
async fn delete_client(&self, workspace_id: WorkspaceId, client_id: ClientId) -> Result<()> {
let url = format!(
"{}/workspaces/{workspace_id}/clients/{client_id}",
self.base_url
);
self.delete_request(&url).await
}
async fn get_client(
&self,
workspace_id: WorkspaceId,
client_id: ClientId,
) -> Result<crate::models::Client> {
let url = format!(
"{}/workspaces/{workspace_id}/clients/{client_id}",
self.base_url
);
let wire: WireClient = self.get(&url).await?;
Ok(wire.into())
}
async fn list_workspaces(&self) -> Result<Vec<crate::models::Workspace>> {
let url = format!("{}/me/workspaces", self.base_url);
let wire: Vec<WireWorkspace> = self.get(&url).await?;
Ok(wire.into_iter().map(Into::into).collect())
}
async fn get_workspace(&self, workspace_id: WorkspaceId) -> Result<crate::models::Workspace> {
let url = format!("{}/workspaces/{workspace_id}", self.base_url);
let wire: WireWorkspace = self.get(&url).await?;
Ok(wire.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn setup() -> (MockServer, TogglClient) {
let server = MockServer::start().await;
let client = TogglClient::new_with_base_url("test_token", &server.uri()).unwrap();
(server, client)
}
fn wire_user_json() -> serde_json::Value {
serde_json::json!({
"email": "test@example.com",
"fullname": "Test User",
"default_workspace_id": 1,
"timezone": "UTC"
})
}
fn wire_time_entry_json(id: i64, workspace_id: i64) -> serde_json::Value {
serde_json::json!({
"id": id,
"workspace_id": workspace_id,
"description": "Test entry",
"start": "2024-01-01T00:00:00Z",
"stop": "2024-01-01T01:00:00Z",
"duration": 3600,
"project_id": null,
"task_id": null,
"tags": [],
"billable": false
})
}
fn wire_project_json(id: i64, workspace_id: i64) -> serde_json::Value {
serde_json::json!({
"id": id,
"workspace_id": workspace_id,
"name": "Test Project",
"active": true,
"color": "#06aaf5",
"billable": null
})
}
fn wire_tag_json(id: i64, workspace_id: i64) -> serde_json::Value {
serde_json::json!({
"id": id,
"workspace_id": workspace_id,
"name": "Test Tag"
})
}
#[test]
fn new_with_valid_token_succeeds() {
assert!(TogglClient::new("valid_token").is_ok());
}
#[test]
fn new_with_empty_token_succeeds() {
assert!(TogglClient::new("").is_ok());
}
#[tokio::test]
async fn get_me_returns_user() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_user_json()))
.mount(&server)
.await;
let user = client.get_me().await.unwrap();
assert_eq!(user.email, "test@example.com");
assert_eq!(user.fullname, "Test User");
assert_eq!(user.default_workspace_id, WorkspaceId(1));
}
#[tokio::test]
async fn get_current_timer_running() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries/current"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_time_entry_json(42, 1)))
.mount(&server)
.await;
let result = client.get_current_timer().await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().id, TimeEntryId(42));
}
#[tokio::test]
async fn get_current_timer_null() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries/current"))
.respond_with(ResponseTemplate::new(200).set_body_string("null"))
.mount(&server)
.await;
let result = client.get_current_timer().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn get_current_timer_empty() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries/current"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&server)
.await;
let result = client.get_current_timer().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn get_time_entries_no_params() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([wire_time_entry_json(1, 1)])),
)
.mount(&server)
.await;
let entries = client.get_time_entries(None, None).await.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, TimeEntryId(1));
}
#[tokio::test]
async fn get_time_entries_with_since_until() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries"))
.and(query_param("start_date", "2024-01-01"))
.and(query_param("end_date", "2024-01-31"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([wire_time_entry_json(1, 1)])),
)
.mount(&server)
.await;
let entries = client
.get_time_entries(
Some("2024-01-01".to_string()),
Some("2024-01-31".to_string()),
)
.await
.unwrap();
assert_eq!(entries.len(), 1);
}
#[tokio::test]
async fn get_time_entry_by_id() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_time_entry_json(42, 1)))
.mount(&server)
.await;
let entry = client.get_time_entry(TimeEntryId(42)).await.unwrap();
assert_eq!(entry.id, TimeEntryId(42));
}
#[tokio::test]
async fn create_time_entry_sends_post() {
let (server, client) = setup().await;
Mock::given(method("POST"))
.and(path("/workspaces/1/time_entries"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_time_entry_json(100, 1)))
.mount(&server)
.await;
let params = CreateTimeEntryParams {
description: Some("Test".to_string()),
project_id: None,
task_id: None,
tags: vec![],
billable: false,
start: None,
stop: None,
duration: None,
};
let entry = client
.create_time_entry(WorkspaceId(1), ¶ms)
.await
.unwrap();
assert_eq!(entry.id, TimeEntryId(100));
}
#[tokio::test]
async fn update_time_entry_sends_put() {
let (server, client) = setup().await;
Mock::given(method("PUT"))
.and(path("/workspaces/1/time_entries/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_time_entry_json(42, 1)))
.mount(&server)
.await;
let params = UpdateTimeEntryParams {
description: Some("Updated".to_string()),
project_id: None,
tags: None,
billable: None,
start: None,
stop: None,
duration: None,
};
let entry = client
.update_time_entry(WorkspaceId(1), TimeEntryId(42), ¶ms)
.await
.unwrap();
assert_eq!(entry.id, TimeEntryId(42));
}
#[tokio::test]
async fn delete_time_entry_sends_delete() {
let (server, client) = setup().await;
Mock::given(method("DELETE"))
.and(path("/workspaces/1/time_entries/42"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let result = client
.delete_time_entry(WorkspaceId(1), TimeEntryId(42))
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn stop_time_entry_sends_patch() {
let (server, client) = setup().await;
Mock::given(method("PATCH"))
.and(path("/workspaces/1/time_entries/42/stop"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_time_entry_json(42, 1)))
.mount(&server)
.await;
let entry = client
.stop_time_entry(WorkspaceId(1), TimeEntryId(42))
.await
.unwrap();
assert_eq!(entry.id, TimeEntryId(42));
}
#[tokio::test]
async fn list_projects_returns_vec() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/workspaces/1/projects"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([wire_project_json(10, 1)])),
)
.mount(&server)
.await;
let projects = client.list_projects(WorkspaceId(1)).await.unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, ProjectId(10));
}
#[tokio::test]
async fn get_project_by_id() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/workspaces/1/projects/10"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_project_json(10, 1)))
.mount(&server)
.await;
let project = client
.get_project(WorkspaceId(1), ProjectId(10))
.await
.unwrap();
assert_eq!(project.id, ProjectId(10));
}
#[tokio::test]
async fn create_project_sends_post() {
let (server, client) = setup().await;
Mock::given(method("POST"))
.and(path("/workspaces/1/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_project_json(11, 1)))
.mount(&server)
.await;
let params = CreateProjectParams {
name: "New Project".to_string(),
client_id: None,
};
let project = client
.create_project(WorkspaceId(1), ¶ms)
.await
.unwrap();
assert_eq!(project.id, ProjectId(11));
}
#[tokio::test]
async fn update_project_sends_put() {
let (server, client) = setup().await;
Mock::given(method("PUT"))
.and(path("/workspaces/1/projects/10"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_project_json(10, 1)))
.mount(&server)
.await;
let params = UpdateProjectParams {
name: Some("Renamed".to_string()),
client_id: None,
};
let project = client
.update_project(WorkspaceId(1), ProjectId(10), ¶ms)
.await
.unwrap();
assert_eq!(project.id, ProjectId(10));
}
#[tokio::test]
async fn delete_project_sends_delete() {
let (server, client) = setup().await;
Mock::given(method("DELETE"))
.and(path("/workspaces/1/projects/10"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let result = client.delete_project(WorkspaceId(1), ProjectId(10)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn list_tags_returns_vec() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/workspaces/1/tags"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([wire_tag_json(5, 1)])),
)
.mount(&server)
.await;
let tags = client.list_tags(WorkspaceId(1)).await.unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].id, TagId(5));
}
#[tokio::test]
async fn create_tag_sends_post() {
let (server, client) = setup().await;
Mock::given(method("POST"))
.and(path("/workspaces/1/tags"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_tag_json(6, 1)))
.mount(&server)
.await;
let tag = client.create_tag(WorkspaceId(1), "New Tag").await.unwrap();
assert_eq!(tag.id, TagId(6));
}
#[tokio::test]
async fn update_tag_sends_put() {
let (server, client) = setup().await;
Mock::given(method("PUT"))
.and(path("/workspaces/1/tags/5"))
.respond_with(ResponseTemplate::new(200).set_body_json(wire_tag_json(5, 1)))
.mount(&server)
.await;
let tag = client
.update_tag(WorkspaceId(1), TagId(5), "Renamed")
.await
.unwrap();
assert_eq!(tag.id, TagId(5));
}
#[tokio::test]
async fn delete_tag_sends_delete() {
let (server, client) = setup().await;
Mock::given(method("DELETE"))
.and(path("/workspaces/1/tags/5"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let result = client.delete_tag(WorkspaceId(1), TagId(5)).await;
assert!(result.is_ok());
}
#[test]
fn from_time_entry_to_create_params() {
use crate::models::TimeEntry;
use chrono::Utc;
let now = Utc::now();
let entry = TimeEntry {
id: TimeEntryId(42),
workspace_id: WorkspaceId(1),
description: Some("My task".to_string()),
start: now,
stop: Some(now),
duration: 3600,
project_id: Some(ProjectId(10)),
task_id: Some(TaskId(20)),
tags: vec!["a".to_string(), "b".to_string()],
billable: true,
};
let params = CreateTimeEntryParams::from(&entry);
assert_eq!(params.description, entry.description);
assert_eq!(params.project_id, entry.project_id);
assert_eq!(params.task_id, entry.task_id);
assert_eq!(params.tags, entry.tags);
assert_eq!(params.billable, entry.billable);
}
#[tokio::test]
async fn send_http_401_returns_error() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me"))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.mount(&server)
.await;
let result = client.get_me().await;
assert!(result.is_err());
match result.unwrap_err() {
AppError::HttpStatus { status, body } => {
assert_eq!(status, 401);
assert_eq!(body, "Unauthorized");
}
other => panic!("expected HttpStatus, got: {other:?}"),
}
}
#[tokio::test]
async fn send_http_500_returns_error() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&server)
.await;
let result = client.get_me().await;
assert!(result.is_err());
match result.unwrap_err() {
AppError::HttpStatus { status, .. } => assert_eq!(status, 500),
other => panic!("expected HttpStatus, got: {other:?}"),
}
}
#[tokio::test]
async fn get_current_timer_http_error() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/me/time_entries/current"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let result = client.get_current_timer().await;
assert!(result.is_err());
match result.unwrap_err() {
AppError::HttpStatus { status, .. } => assert_eq!(status, 403),
other => panic!("expected HttpStatus, got: {other:?}"),
}
}
#[tokio::test]
async fn delete_request_http_error() {
let (server, client) = setup().await;
Mock::given(method("DELETE"))
.and(path("/workspaces/1/tags/1"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let result = client.delete_tag(WorkspaceId(1), TagId(1)).await;
assert!(result.is_err());
match result.unwrap_err() {
AppError::HttpStatus { status, .. } => assert_eq!(status, 404),
other => panic!("expected HttpStatus, got: {other:?}"),
}
}
#[tokio::test]
async fn connection_refused_returns_api_error() {
let client = TogglClient::new_with_base_url("test_token", "http://127.0.0.1:1").unwrap();
let result = client.get_me().await;
assert!(result.is_err());
match result.unwrap_err() {
AppError::Api(_) => {}
other => panic!("expected Api error, got: {other:?}"),
}
}
}