mod support;
use chrono::FixedOffset;
use kasl_server::import;
use support::TestServer;
fn agent_db(path: &std::path::Path) {
let connection = rusqlite::Connection::open(path).expect("the fixture database should open");
connection
.execute_batch(
"CREATE TABLE workdays (id INTEGER PRIMARY KEY, date DATE NOT NULL UNIQUE, start TIMESTAMP NOT NULL, end TIMESTAMP, notes TEXT);
CREATE TABLE pauses (id INTEGER PRIMARY KEY, start TIMESTAMP NOT NULL, end TIMESTAMP, duration INTEGER);
CREATE TABLE tasks (id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL DEFAULT 0, timestamp TIMESTAMP,
name TEXT NOT NULL, comment TEXT, completeness INTEGER NOT NULL DEFAULT 100,
excluded_from_search BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMP);
CREATE TABLE breaks (id INTEGER PRIMARY KEY, date DATE NOT NULL, start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL, duration INTEGER NOT NULL, reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
-- Two days of history, in the bare wall-clock text the agent writes.
INSERT INTO workdays (date, start, end) VALUES
('2025-03-10', '2025-03-10 09:12:00', '2025-03-10 18:31:00'),
('2025-03-11', '2025-03-11 09:05:00', NULL);
INSERT INTO pauses (start, end, duration) VALUES
('2025-03-10 13:02:00', '2025-03-10 14:05:00', 3780),
('2025-03-11 11:00:00', NULL, NULL);
INSERT INTO breaks (date, start_time, end_time, duration, reason) VALUES
('2025-03-10', '2025-03-10 16:00:00', '2025-03-10 16:15:00', 900, 'coffee');
-- Task 2 continues task 1's work; task 3 the employee deleted.
INSERT INTO tasks (id, task_id, timestamp, name, comment, completeness, deleted_at) VALUES
(1, 0, '2025-03-10 17:50:00', 'Write the importer', 'first pass', 80, NULL),
(2, 1, '2025-03-11 17:55:00', 'Write the importer', NULL, 100, NULL),
(3, 0, '2025-03-10 18:00:00', 'Entered by mistake', NULL, 100, '2025-03-10 18:01:00');",
)
.expect("the fixture schema should apply");
}
fn offset(hours: i32) -> FixedOffset {
FixedOffset::east_opt(hours * 3600).expect("a valid offset")
}
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!("kasl-import-{}-{name}.db", uuid::Uuid::new_v4().simple()));
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn reads_an_agent_database_the_way_the_agent_wrote_it() {
let scratch = Scratch::new("read");
agent_db(&scratch.0);
let (days, summary) = import::read_agent_db(&scratch.0).expect("a kasl database should be readable");
assert_eq!(summary.days, 2);
assert_eq!(summary.pauses, 3, "two automatic pauses and one manual break");
assert_eq!(summary.tasks, 2, "the deleted task is not among them");
assert_eq!(summary.skipped_deleted_tasks, 1, "and it is counted, not silently dropped");
let first = &days[0];
assert_eq!(first.date.to_string(), "2025-03-10");
assert_eq!(first.pauses.len(), 2);
assert_eq!(first.tasks.len(), 1);
let manual = first.pauses.iter().find(|pause| pause.manual).expect("the break should be imported");
assert_eq!(manual.reason.as_deref(), Some("coffee"));
assert!(
first.pauses.iter().any(|pause| !pause.manual),
"and the automatic pause should still be automatic"
);
let second = &days[1];
assert!(second.end.is_none(), "a day the employee never closed has no end");
assert!(second.pauses[0].end.is_none());
assert_eq!(first.tasks[0].agent_group_id, 1, "a group of 0 resolves to the task itself");
assert_eq!(days[1].tasks[0].agent_group_id, 1, "and a real group is kept as it is");
}
#[tokio::test]
async fn a_year_of_local_history_lands_under_the_operators_offset() {
let Some(server) = TestServer::start().await else { return };
let scratch = Scratch::new("write");
agent_db(&scratch.0);
let user_id = import::resolve_user(&server.pool, "employee@example.test")
.await
.expect("the seeded user should resolve");
let (days, _) = import::read_agent_db(&scratch.0).unwrap();
let written = import::write_days(&server.pool, user_id, &days, offset(-3))
.await
.expect("the import should succeed");
assert_eq!(written, 2);
assert_eq!(server.count("workdays").await, 2);
assert_eq!(server.count("pauses").await, 3);
assert_eq!(server.count("tasks").await, 2);
let started_at: String = server
.scalar("SELECT to_char(started_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') FROM workdays WHERE date = '2025-03-10'")
.await;
assert_eq!(started_at, "2025-03-10 12:12", "09:12-03:00 is 12:12 UTC");
let date: String = server.scalar("SELECT to_char(date, 'YYYY-MM-DD') FROM workdays ORDER BY date LIMIT 1").await;
assert_eq!(date, "2025-03-10");
let manual: bool = server.scalar("SELECT manual FROM pauses WHERE reason = 'coffee'").await;
assert!(manual, "the manual break must stay marked as the employee's own");
}
#[tokio::test]
async fn the_offset_chosen_is_the_offset_stored() {
let Some(server) = TestServer::start().await else { return };
let scratch = Scratch::new("offset");
agent_db(&scratch.0);
let user_id = import::resolve_user(&server.pool, "employee@example.test").await.unwrap();
let (days, _) = import::read_agent_db(&scratch.0).unwrap();
import::write_days(&server.pool, user_id, &days, offset(5)).await.unwrap();
let started_at: String = server
.scalar("SELECT to_char(started_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') FROM workdays WHERE date = '2025-03-10'")
.await;
assert_eq!(started_at, "2025-03-10 04:12", "09:12+05:00 is 04:12 UTC - a different moment entirely");
}
#[tokio::test]
async fn importing_twice_corrects_rather_than_doubles() {
let Some(server) = TestServer::start().await else { return };
let scratch = Scratch::new("twice");
agent_db(&scratch.0);
let user_id = import::resolve_user(&server.pool, "employee@example.test").await.unwrap();
let (days, _) = import::read_agent_db(&scratch.0).unwrap();
import::write_days(&server.pool, user_id, &days, offset(-3)).await.unwrap();
import::write_days(&server.pool, user_id, &days, offset(5)).await.unwrap();
assert_eq!(server.count("workdays").await, 2, "the same days must not become new ones");
assert_eq!(server.count("pauses").await, 3, "nor the pauses pile up");
assert_eq!(server.count("tasks").await, 2);
let started_at: String = server
.scalar("SELECT to_char(started_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') FROM workdays WHERE date = '2025-03-10'")
.await;
assert_eq!(started_at, "2025-03-10 04:12", "the second import's offset must win");
}
#[test]
fn a_date_range_splits_a_history_that_crossed_time_zones() {
let scratch = Scratch::new("range");
agent_db(&scratch.0);
let (days, _) = import::read_agent_db(&scratch.0).unwrap();
let day = |text: &str| text.parse::<chrono::NaiveDate>().unwrap();
let first = import::within(import::read_agent_db(&scratch.0).unwrap().0, None, Some(day("2025-03-10")));
assert_eq!(first.len(), 1);
assert_eq!(first[0].date, day("2025-03-10"), "the boundary day belongs to the earlier run");
let second = import::within(import::read_agent_db(&scratch.0).unwrap().0, Some(day("2025-03-11")), None);
assert_eq!(second.len(), 1);
assert_eq!(second[0].date, day("2025-03-11"));
assert_eq!(import::within(days, None, None).len(), 2);
let empty = import::within(import::read_agent_db(&scratch.0).unwrap().0, Some(day("2026-01-01")), None);
assert!(empty.is_empty());
}
#[tokio::test]
async fn an_import_will_not_invent_the_person() {
let Some(server) = TestServer::start().await else { return };
let error = import::resolve_user(&server.pool, "employe@example.test")
.await
.expect_err("an unknown email must stop the import");
assert!(error.to_string().contains("create the account"), "the message should say what to do: {error}");
}
#[test]
fn a_file_that_is_not_a_kasl_database_is_refused_clearly() {
let scratch = Scratch::new("empty");
rusqlite::Connection::open(&scratch.0)
.unwrap()
.execute_batch("CREATE TABLE unrelated (x INTEGER)")
.unwrap();
let error = import::read_agent_db(&scratch.0).expect_err("a database without kasl's tables must be refused");
assert!(
error.to_string().contains("kasl database"),
"the message should name the likely mistake: {error}"
);
let missing = import::read_agent_db(std::path::Path::new("no-such-file.db")).expect_err("a missing file must be refused");
assert!(missing.to_string().contains("no such file"), "{missing}");
}
#[test]
fn the_employees_file_survives_the_import_untouched() {
let scratch = Scratch::new("readonly");
agent_db(&scratch.0);
let before = std::fs::metadata(&scratch.0).expect("the fixture exists").len();
let modified_before = std::fs::metadata(&scratch.0).unwrap().modified().unwrap();
import::read_agent_db(&scratch.0).expect("the import should read it");
let after = std::fs::metadata(&scratch.0).unwrap().len();
assert_eq!(before, after, "the file's size must not change");
assert_eq!(
modified_before,
std::fs::metadata(&scratch.0).unwrap().modified().unwrap(),
"nor its modification time"
);
let mut permissions = std::fs::metadata(&scratch.0).unwrap().permissions();
permissions.set_readonly(true);
std::fs::set_permissions(&scratch.0, permissions).expect("the fixture should become read-only");
let result = import::read_agent_db(&scratch.0);
let mut permissions = std::fs::metadata(&scratch.0).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
let _ = std::fs::set_permissions(&scratch.0, permissions);
let (days, _) = result.expect("a read-only file must still be importable");
assert_eq!(days.len(), 2, "and yield the same history");
}
#[test]
fn an_older_agent_database_still_imports() {
let scratch = Scratch::new("old");
let connection = rusqlite::Connection::open(&scratch.0).unwrap();
connection
.execute_batch(
"CREATE TABLE workdays (id INTEGER PRIMARY KEY, date DATE NOT NULL UNIQUE, start TIMESTAMP NOT NULL, end TIMESTAMP);
CREATE TABLE pauses (id INTEGER PRIMARY KEY, start TIMESTAMP NOT NULL, end TIMESTAMP, duration INTEGER);
CREATE TABLE tasks (id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL DEFAULT 0, timestamp TIMESTAMP,
name TEXT NOT NULL, comment TEXT, completeness INTEGER NOT NULL DEFAULT 100,
excluded_from_search BOOLEAN NOT NULL DEFAULT FALSE);
INSERT INTO workdays (date, start, end) VALUES ('2024-01-15', '2024-01-15 08:00:00', '2024-01-15 17:00:00');
INSERT INTO tasks (id, task_id, timestamp, name, completeness) VALUES (1, 0, '2024-01-15 16:00:00', 'Old work', 100);",
)
.unwrap();
let (days, summary) = import::read_agent_db(&scratch.0).expect("a pre-migration database should still import");
assert_eq!(summary.days, 1);
assert_eq!(summary.tasks, 1);
assert_eq!(summary.skipped_deleted_tasks, 0, "a file without the column has nothing deleted to skip");
assert_eq!(days[0].tasks[0].name, "Old work");
}
#[test]
fn a_row_with_unreadable_time_is_skipped_and_counted() {
let scratch = Scratch::new("broken");
let connection = rusqlite::Connection::open(&scratch.0).unwrap();
connection
.execute_batch(
"CREATE TABLE workdays (id INTEGER PRIMARY KEY, date DATE NOT NULL UNIQUE, start TIMESTAMP NOT NULL, end TIMESTAMP);
CREATE TABLE pauses (id INTEGER PRIMARY KEY, start TIMESTAMP NOT NULL, end TIMESTAMP, duration INTEGER);
CREATE TABLE tasks (id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL DEFAULT 0, timestamp TIMESTAMP,
name TEXT NOT NULL, comment TEXT, completeness INTEGER NOT NULL DEFAULT 100);
INSERT INTO workdays (date, start, end) VALUES
('2024-01-15', '2024-01-15 08:00:00', NULL),
('2024-01-16', 'not a timestamp', NULL);",
)
.unwrap();
let (days, summary) = import::read_agent_db(&scratch.0).expect("a damaged row should not fail the import");
assert_eq!(days.len(), 1, "the readable day survives");
assert_eq!(summary.skipped_unreadable, 1, "and the damaged one is reported, not hidden");
}