use chrono::{DateTime, NaiveDate};
use kasl::api::kasl_server::{DayResult, DayUpload, KaslServer};
use kasl::libs::config::KaslServerConfig;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client_for(server: &MockServer) -> KaslServer {
KaslServer::new(&KaslServerConfig {
url: server.uri(),
ca_certificate: None,
})
.expect("the client should build for a plain http url")
}
#[tokio::test]
async fn health_reports_the_server_version() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "ok",
"version": "0.14.1",
"database": "ok"
})))
.mount(&server)
.await;
let health = client_for(&server).health().await.unwrap();
assert_eq!(health.status, "ok");
assert_eq!(health.version, "0.14.1");
assert_eq!(health.database, "ok");
}
#[tokio::test]
async fn a_server_that_answers_but_is_not_kasl_server_is_named_as_such() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html><body>It works!</body></html>"))
.mount(&server)
.await;
let error = client_for(&server).health().await.unwrap_err().to_string();
assert!(error.contains("not like a kasl-server"), "unexpected error: {}", error);
}
#[tokio::test]
async fn an_unhealthy_database_is_reported_rather_than_hidden() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
"status": "degraded",
"version": "0.14.1",
"database": "unreachable"
})))
.mount(&server)
.await;
let error = client_for(&server).health().await.unwrap_err().to_string();
assert!(error.contains("503"), "the error should carry the status: {}", error);
}
#[tokio::test]
async fn whoami_names_the_employee_behind_the_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/agent/whoami"))
.and(header("authorization", "Bearer token-kirill"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_name": "kirill",
"agent_name": "laptop",
"api_version": "v1",
"server_version": "0.14.1"
})))
.mount(&server)
.await;
let identity = client_for(&server).identify("token-kirill").await.unwrap();
assert_eq!(identity.user_name, "kirill");
assert_eq!(identity.agent_name, "laptop");
assert_eq!(identity.api_version, "v1");
assert_eq!(identity.server_version, "0.14.1");
}
#[tokio::test]
async fn the_token_travels_as_a_bearer_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/agent/whoami"))
.and(header("authorization", "Bearer token-kirill"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_name": "kirill",
"agent_name": "laptop",
"api_version": "v1",
"server_version": "0.14.1"
})))
.mount(&server)
.await;
assert!(client_for(&server).identify("token-kirill").await.is_ok());
assert!(client_for(&server).identify("someone-elses-token").await.is_err());
}
#[tokio::test]
async fn a_refused_token_is_reported_as_a_token_problem() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/agent/whoami"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "the token is not recognized, has been revoked, or its user is deactivated"
})))
.mount(&server)
.await;
let error = client_for(&server).identify("stale-token").await.unwrap_err().to_string();
assert!(error.contains("rejected this token"), "unexpected error: {}", error);
assert!(error.contains("revoked"), "the message should suggest what went wrong: {}", error);
}
#[tokio::test]
async fn an_unexpected_status_is_not_read_as_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/agent/whoami"))
.respond_with(ResponseTemplate::new(502))
.mount(&server)
.await;
let error = client_for(&server).identify("token-kirill").await.unwrap_err().to_string();
assert!(error.contains("502"), "the error should carry the status: {}", error);
}
#[tokio::test]
async fn a_server_that_is_not_listening_is_reported_by_url() {
let config = KaslServerConfig {
url: "http://127.0.0.1:1".to_string(),
ca_certificate: None,
};
let error = KaslServer::new(&config).unwrap().health().await.unwrap_err().to_string();
assert!(error.contains("cannot reach"), "unexpected error: {}", error);
assert!(error.contains("127.0.0.1:1"), "the error should name the url: {}", error);
}
#[tokio::test]
async fn a_url_with_a_trailing_slash_still_addresses_the_endpoints() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "ok",
"version": "0.14.1",
"database": "ok"
})))
.mount(&server)
.await;
let client = KaslServer::new(&KaslServerConfig {
url: format!("{}/", server.uri()),
ca_certificate: None,
})
.unwrap();
assert!(client.health().await.is_ok());
}
fn a_day() -> DayUpload {
DayUpload {
date: NaiveDate::from_ymd_opt(2026, 8, 31).unwrap(),
started_at: DateTime::parse_from_rfc3339("2026-08-31T09:00:00-03:00").unwrap(),
ended_at: Some(DateTime::parse_from_rfc3339("2026-08-31T18:00:00-03:00").unwrap()),
pauses: vec![],
tasks: vec![],
tasks_are_complete: true,
}
}
fn accepted_body(deleted_tasks: u64) -> serde_json::Value {
serde_json::json!({
"workday_id": "0f7b6f0e-4f2f-4a3e-9a2c-6a2b1c3d4e5f",
"date": "2026-08-31",
"pauses": 2,
"tasks": 3,
"deleted_tasks": deleted_tasks,
"privacy_level": "full"
})
}
#[tokio::test]
async fn an_accepted_day_is_reported_with_what_the_server_stored() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.and(header("authorization", "Bearer token-kirill"))
.respond_with(ResponseTemplate::new(200).set_body_json(accepted_body(1)))
.mount(&server)
.await;
let accepted = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap();
assert_eq!(accepted.pauses, 2);
assert_eq!(accepted.tasks, 3);
assert_eq!(accepted.deleted_tasks, 1);
}
#[tokio::test]
async fn the_payload_carries_offsets_and_the_authoritative_flag() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(200).set_body_json(accepted_body(0)))
.mount(&server)
.await;
client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap();
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["date"], "2026-08-31", "the agent's own date, not one derived from the instant");
assert_eq!(body["started_at"], "2026-08-31T09:00:00-03:00");
assert_eq!(body["ended_at"], "2026-08-31T18:00:00-03:00");
assert_eq!(body["tasks_are_complete"], true);
}
#[tokio::test]
async fn an_open_day_omits_the_end_rather_than_sending_null() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(200).set_body_json(accepted_body(0)))
.mount(&server)
.await;
let mut day = a_day();
day.ended_at = None;
client_for(&server).upload_day("token-kirill", &day).await.unwrap();
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert!(body.get("ended_at").is_none(), "an unfinished day carries no end: {}", body);
}
#[tokio::test]
async fn a_refused_payload_is_not_worth_retrying() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "tasks[0]: name is empty"})))
.mount(&server)
.await;
let error = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap_err();
assert!(!error.is_retryable(), "a 400 must not be queued for a retry");
assert!(error.to_string().contains("tasks[0]: name is empty"), "unexpected error: {}", error);
}
#[tokio::test]
async fn a_failure_states_the_status_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "401 Unauthorized: the token is not recognized"})))
.mount(&server)
.await;
let error = client_for(&server).upload_day("stale-token", &a_day()).await.unwrap_err().to_string();
assert_eq!(
error.matches("401").count(),
2,
"the status belongs to the wrapper and the server's own sentence, nowhere else: {}",
error
);
}
#[tokio::test]
async fn a_failure_without_a_body_still_says_what_happened() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(418))
.mount(&server)
.await;
let error = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap_err().to_string();
assert!(error.contains("418"), "the status is all there is to report: {}", error);
}
#[tokio::test]
async fn a_rejected_token_is_not_worth_retrying() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let error = client_for(&server).upload_day("stale-token", &a_day()).await.unwrap_err();
assert!(!error.is_retryable(), "a revoked token is fixed by reconnecting, not by waiting");
assert!(error.to_string().contains("401"), "the error should carry the status: {}", error);
}
#[tokio::test]
async fn a_server_that_could_not_answer_keeps_the_day() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let error = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap_err();
assert!(error.is_retryable(), "a 5xx means the server could not answer this time");
}
#[tokio::test]
async fn being_rate_limited_is_worth_retrying_despite_being_a_4xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let error = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap_err();
assert!(error.is_retryable(), "429 asks for a later attempt, not for the day to be dropped");
}
#[tokio::test]
async fn an_unreachable_server_keeps_the_day() {
let client = KaslServer::new(&KaslServerConfig {
url: "http://127.0.0.1:1".to_string(),
ca_certificate: None,
})
.unwrap();
let error = client.upload_day("token-kirill", &a_day()).await.unwrap_err();
assert!(error.is_retryable(), "nothing was answered, so nothing was written there");
assert!(error.to_string().contains("127.0.0.1:1"), "the error should name the url: {}", error);
}
#[tokio::test]
async fn a_success_that_is_not_a_day_report_is_not_read_as_stored() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>OK</html>"))
.mount(&server)
.await;
let error = client_for(&server).upload_day("token-kirill", &a_day()).await.unwrap_err();
assert!(!error.is_retryable(), "an address answering for something else will not come good on a retry");
assert!(error.to_string().contains("unreadably"), "unexpected error: {}", error);
}
fn a_day_on(date: &str) -> DayUpload {
DayUpload {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(),
started_at: DateTime::parse_from_rfc3339(&format!("{}T09:00:00-03:00", date)).unwrap(),
ended_at: Some(DateTime::parse_from_rfc3339(&format!("{}T18:00:00-03:00", date)).unwrap()),
pauses: vec![],
tasks: vec![],
tasks_are_complete: true,
}
}
#[tokio::test]
async fn a_batch_reports_each_day_separately() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.and(header("authorization", "Bearer token-kirill"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accepted": 1,
"rejected": 1,
"results": [
{
"status": "accepted",
"workday_id": "0f7b6f0e-4f2f-4a3e-9a2c-6a2b1c3d4e5f",
"date": "2026-08-30",
"pauses": 2,
"tasks": 3,
"deleted_tasks": 0,
"privacy_level": "full"
},
{
"status": "rejected",
"date": "2026-08-31",
"error": "tasks[0]: name is empty"
}
]
})))
.mount(&server)
.await;
let result = client_for(&server)
.upload_batch("token-kirill", &[a_day_on("2026-08-30"), a_day_on("2026-08-31")])
.await
.unwrap();
assert_eq!(result.accepted, 1);
assert_eq!(result.rejected, 1);
assert_eq!(result.results.len(), 2);
match &result.results[0] {
DayResult::Accepted { day } => {
assert_eq!(day.date, NaiveDate::from_ymd_opt(2026, 8, 30).unwrap());
assert_eq!(day.tasks, 3);
}
other => panic!("the first day was accepted, not {:?}", other),
}
match &result.results[1] {
DayResult::Rejected { date, error } => {
assert_eq!(*date, NaiveDate::from_ymd_opt(2026, 8, 31).unwrap());
assert!(error.contains("name is empty"), "the reason should survive: {}", error);
}
other => panic!("the second day was rejected, not {:?}", other),
}
}
#[tokio::test]
async fn a_batch_that_answers_200_with_rejections_is_not_read_as_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accepted": 0,
"rejected": 2,
"results": [
{"status": "rejected", "date": "2026-08-30", "error": "started_at has no offset"},
{"status": "rejected", "date": "2026-08-31", "error": "started_at has no offset"}
]
})))
.mount(&server)
.await;
let result = client_for(&server)
.upload_batch("token-kirill", &[a_day_on("2026-08-30"), a_day_on("2026-08-31")])
.await
.expect("the request itself succeeded");
assert_eq!(result.accepted, 0, "the request was fine; the days were not");
assert!(
result.results.iter().all(|day| matches!(day, DayResult::Rejected { .. })),
"both days should read as rejected"
);
}
#[tokio::test]
async fn a_batch_too_large_is_refused_without_a_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(413).set_body_json(serde_json::json!({
"error": "a batch carries at most 30 days; split the backlog"
})))
.mount(&server)
.await;
let error = client_for(&server).upload_batch("token-kirill", &[a_day()]).await.unwrap_err();
assert!(!error.is_retryable(), "the same oversized request will never be accepted");
assert!(error.to_string().contains("split the backlog"), "the fix should survive: {}", error);
}
#[tokio::test]
async fn a_batch_meeting_a_server_error_keeps_every_day() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({"error": "database is unavailable"})))
.mount(&server)
.await;
let error = client_for(&server)
.upload_batch("token-kirill", &[a_day_on("2026-08-30"), a_day_on("2026-08-31")])
.await
.unwrap_err();
assert!(error.is_retryable(), "a server that failed on the batch is worth asking again");
}
#[tokio::test]
async fn a_rate_limited_batch_is_worth_repeating() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({"error": "too many requests"})))
.mount(&server)
.await;
let error = client_for(&server).upload_batch("token-kirill", &[a_day()]).await.unwrap_err();
assert!(error.is_retryable(), "a rate limit is a wait, not a refusal of the data");
}