#[cfg(test)]
mod tests {
use chrono::{Duration, Local, NaiveDate};
use kasl::api::kasl_server::KaslServer;
use kasl::api::kasl_server::UploadError;
use kasl::db::server_outbox::ServerOutbox;
use kasl::db::tasks::Tasks;
use kasl::db::workdays::Workdays;
use kasl::libs::config::KaslServerConfig;
use kasl::libs::day_delivery::{BATCH_SIZE, Delivered, deliver, record_single};
use kasl::libs::task::Task;
use reqwest::StatusCode;
use serial_test::serial;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[must_use]
fn sandbox() -> TempDir {
let temp_dir = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("HOME", temp_dir.path());
std::env::set_var("LOCALAPPDATA", temp_dir.path());
}
temp_dir
}
fn client_for(server: &MockServer) -> KaslServer {
KaslServer::new(&KaslServerConfig {
url: server.uri(),
ca_certificate: None,
})
.unwrap()
}
fn record_a_workday() -> NaiveDate {
let today = Local::now().date_naive();
let mut workdays = Workdays::new().unwrap();
workdays.insert_start(today).unwrap();
let mut tasks = Tasks::new().unwrap();
tasks.insert(&Task::new("Write the queue", "", Some(50))).unwrap();
today
}
#[serial]
#[tokio::test]
async fn an_accepted_day_leaves_the_queue() {
let _sandbox = sandbox();
let date = record_a_workday();
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": 1,
"rejected": 0,
"results": [{
"status": "accepted",
"workday_id": "0f7b6f0e-4f2f-4a3e-9a2c-6a2b1c3d4e5f",
"date": date.to_string(),
"pauses": 0,
"tasks": 1,
"deleted_tasks": 0,
"privacy_level": "full"
}]
})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
outbox.enqueue(date, "offline").unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
assert!(matches!(outcomes[0], Delivered::Accepted { .. }), "got {:?}", outcomes[0]);
assert_eq!(outbox.count().unwrap(), 0, "a delivered day stops being owed");
}
#[serial]
#[tokio::test]
async fn a_day_the_server_refuses_is_dropped_rather_than_retried_forever() {
let _sandbox = sandbox();
let date = record_a_workday();
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": 1,
"results": [{
"status": "rejected",
"date": date.to_string(),
"error": "tasks[0]: name is empty"
}]
})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
outbox.enqueue(date, "offline").unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
match &outcomes[0] {
Delivered::Refused { reason, .. } => assert!(reason.contains("name is empty"), "the reason should reach the user: {}", reason),
other => panic!("a validated refusal is not worth retrying, got {:?}", other),
}
assert_eq!(outbox.count().unwrap(), 0, "it must not sit in the queue retrying");
}
#[serial]
#[tokio::test]
async fn a_server_that_could_not_answer_keeps_the_day() {
let _sandbox = sandbox();
let date = record_a_workday();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({"error": "database is unavailable"})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
assert!(matches!(outcomes[0], Delivered::Deferred { .. }), "got {:?}", outcomes[0]);
assert_eq!(outbox.count().unwrap(), 1, "the day is still owed");
assert_eq!(outbox.pending().unwrap()[0].date, date);
}
#[serial]
#[tokio::test]
async fn a_day_the_server_did_not_report_on_stays_owed() {
let _sandbox = sandbox();
let date = record_a_workday();
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": 0,
"results": []
})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
outbox.enqueue(date, "offline").unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
match &outcomes[0] {
Delivered::Deferred { reason, .. } => assert!(reason.contains("did not report"), "unexpected reason: {}", reason),
other => panic!("an unanswered day must stay owed, got {:?}", other),
}
assert_eq!(outbox.count().unwrap(), 1, "silence is not delivery");
}
#[serial]
#[tokio::test]
async fn a_date_whose_day_is_gone_stops_being_owed() {
let _sandbox = sandbox();
let server = MockServer::start().await;
let missing = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let mut outbox = ServerOutbox::new().unwrap();
outbox.enqueue(missing, "offline").unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[missing]).await.unwrap();
assert!(outcomes.is_empty(), "there was nothing to report on");
assert_eq!(outbox.count().unwrap(), 0, "a day that no longer exists is not owed");
}
#[serial]
#[tokio::test]
async fn nothing_is_sent_when_nothing_is_owed() {
let _sandbox = sandbox();
let server = MockServer::start().await;
let mut outbox = ServerOutbox::new().unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[]).await.unwrap();
assert!(outcomes.is_empty());
assert!(server.received_requests().await.unwrap().is_empty(), "an empty queue must not call the server");
}
#[serial]
#[tokio::test]
async fn a_backlog_is_one_request_rather_than_one_per_day() {
let _sandbox = sandbox();
let date = record_a_workday();
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": 1,
"rejected": 0,
"results": [{
"status": "accepted",
"workday_id": "0f7b6f0e-4f2f-4a3e-9a2c-6a2b1c3d4e5f",
"date": date.to_string(),
"pauses": 0,
"tasks": 1,
"deleted_tasks": 0,
"privacy_level": "full"
}]
})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1, "the backlog goes in one request");
assert!(
requests[0].url.path().ends_with("/days/batch"),
"it goes to the batch endpoint, not the single-day one: {}",
requests[0].url.path()
);
}
#[serial]
#[tokio::test]
async fn a_batch_the_server_will_never_accept_does_not_queue_forever() {
let _sandbox = sandbox();
let date = record_a_workday();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/days/batch"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "days[0]: started_at has no offset"})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
outbox.enqueue(date, "offline").unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
match &outcomes[0] {
Delivered::Refused { reason, .. } => assert!(reason.contains("no offset"), "the reason should reach the user: {}", reason),
other => panic!("a 4xx will not come good on a retry, got {:?}", other),
}
assert_eq!(outbox.count().unwrap(), 0, "it must not sit in the queue retrying forever");
}
#[serial]
#[tokio::test]
async fn a_rate_limited_batch_keeps_its_days() {
let _sandbox = sandbox();
let date = record_a_workday();
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 mut outbox = ServerOutbox::new().unwrap();
let outcomes = deliver(&client_for(&server), "token", &mut outbox, &[date]).await.unwrap();
assert!(
matches!(outcomes[0], Delivered::Deferred { .. }),
"a rate limit is a wait, got {:?}",
outcomes[0]
);
assert_eq!(outbox.count().unwrap(), 1, "the day is kept for later");
}
#[serial]
#[test]
fn a_single_push_queues_only_what_is_worth_retrying() {
let _sandbox = sandbox();
let unreachable = UploadError::Retryable {
message: "cannot reach kasl-server".to_string(),
};
let refused = UploadError::Rejected {
status: StatusCode::BAD_REQUEST,
message: "tasks[0]: name is empty".to_string(),
};
let mut outbox = ServerOutbox::new().unwrap();
let waited = NaiveDate::from_ymd_opt(2026, 8, 30).unwrap();
let never = NaiveDate::from_ymd_opt(2026, 8, 31).unwrap();
let deferred = record_single(&mut outbox, waited, &unreachable).unwrap();
let dropped = record_single(&mut outbox, never, &refused).unwrap();
assert!(matches!(deferred, Delivered::Deferred { .. }), "got {:?}", deferred);
assert!(matches!(dropped, Delivered::Refused { .. }), "got {:?}", dropped);
let owed: Vec<NaiveDate> = outbox.pending().unwrap().into_iter().map(|day| day.date).collect();
assert_eq!(owed, vec![waited], "only the day worth retrying is owed");
}
#[serial]
#[tokio::test]
async fn a_backlog_longer_than_a_batch_is_split_into_several() {
let _sandbox = sandbox();
let days = BATCH_SIZE + 5;
let first = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let dates: Vec<NaiveDate> = (0..days as i64).map(|offset| first + Duration::days(offset)).collect();
let mut workdays = Workdays::new().unwrap();
for date in &dates {
workdays.insert_start(*date).unwrap();
workdays.update_start(*date, date.and_hms_opt(9, 0, 0).unwrap()).unwrap();
}
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": 0, "results": []
})))
.mount(&server)
.await;
let mut outbox = ServerOutbox::new().unwrap();
deliver(&client_for(&server), "token", &mut outbox, &dates).await.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "{days} days should not go as one oversized request");
let sent: usize = requests
.iter()
.map(|request| {
serde_json::from_slice::<serde_json::Value>(&request.body).unwrap()["days"]
.as_array()
.expect("a batch carries a days array")
.len()
})
.sum();
assert_eq!(sent, days, "every day should be sent exactly once");
}
}