mod support;
use sqlx::PgPool;
use support::with_db;
use uuid::Uuid;
async fn insert_user(pool: &PgPool, email: &str) -> Uuid {
sqlx::query_scalar("INSERT INTO users (email, display_name) VALUES ($1, $2) RETURNING id")
.bind(email)
.bind("Test Person")
.fetch_one(pool)
.await
.expect("failed to insert a user")
}
async fn insert_workday(pool: &PgPool, user: Uuid, date: &str) -> Result<Uuid, sqlx::Error> {
sqlx::query_scalar("INSERT INTO workdays (user_id, date, started_at) VALUES ($1, $2::date, $2::date + time '09:00' AT TIME ZONE 'UTC') RETURNING id")
.bind(user)
.bind(date)
.fetch_one(pool)
.await
}
#[tokio::test]
async fn migrations_apply_and_are_idempotent() {
with_db(|pool| async move {
kasl_server::migrator().run(&pool).await.expect("re-running migrations must be a no-op");
let tables: Vec<String> = sqlx::query_scalar("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name")
.fetch_all(&pool)
.await
.expect("failed to list tables");
for expected in ["agents", "pauses", "reports", "tags", "task_tags", "tasks", "users", "workdays"] {
assert!(tables.iter().any(|t| t == expected), "table `{expected}` is missing; got {tables:?}");
}
})
.await;
}
#[tokio::test]
async fn a_person_has_one_workday_per_date() {
with_db(|pool| async move {
let user = insert_user(&pool, "one-day@example.test").await;
insert_workday(&pool, user, "2026-08-14").await.expect("the first workday should insert");
let duplicate = insert_workday(&pool, user, "2026-08-14").await;
assert!(duplicate.is_err(), "a second workday on the same date must be rejected");
let colleague = insert_user(&pool, "colleague@example.test").await;
insert_workday(&pool, colleague, "2026-08-14")
.await
.expect("another person's day on the same date must be allowed");
})
.await;
}
#[tokio::test]
async fn emails_are_unique_regardless_of_case() {
with_db(|pool| async move {
insert_user(&pool, "Person@Example.test").await;
let duplicate = sqlx::query("INSERT INTO users (email, display_name) VALUES ($1, $2)")
.bind("person@example.test")
.bind("Impostor")
.execute(&pool)
.await;
assert!(duplicate.is_err(), "logins must not differ only by letter case");
})
.await;
}
#[tokio::test]
async fn an_upload_of_the_same_task_updates_one_row() {
with_db(|pool| async move {
let user = insert_user(&pool, "tasks@example.test").await;
for completeness in [40_i16, 100] {
sqlx::query(
"INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, completeness)
VALUES ($1, 7, 7, date '2026-08-14', now(), 'Ship the schema', $2)
ON CONFLICT (user_id, agent_task_id) DO UPDATE SET completeness = EXCLUDED.completeness",
)
.bind(user)
.bind(completeness)
.execute(&pool)
.await
.expect("upsert should succeed");
}
let rows: Vec<(i32,)> = sqlx::query_as("SELECT completeness::int FROM tasks WHERE user_id = $1")
.bind(user)
.fetch_all(&pool)
.await
.expect("failed to read tasks");
assert_eq!(rows, [(100,)], "the re-upload should have updated the single row");
})
.await;
}
#[tokio::test]
async fn a_workday_owns_its_pauses() {
with_db(|pool| async move {
let user = insert_user(&pool, "pauses@example.test").await;
let workday = insert_workday(&pool, user, "2026-08-14").await.expect("workday");
sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds) VALUES ($1, now(), now() + interval '20 minutes', 1200)")
.bind(workday)
.execute(&pool)
.await
.expect("failed to insert a pause");
sqlx::query("DELETE FROM workdays WHERE id = $1")
.bind(workday)
.execute(&pool)
.await
.expect("failed to delete the workday");
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM pauses")
.fetch_one(&pool)
.await
.expect("failed to count pauses");
assert_eq!(left, 0, "pauses must not outlive their workday");
})
.await;
}
#[tokio::test]
async fn deleting_a_user_takes_their_data() {
with_db(|pool| async move {
let user = insert_user(&pool, "departed@example.test").await;
let workday = insert_workday(&pool, user, "2026-08-14").await.expect("workday");
sqlx::query("INSERT INTO pauses (workday_id, started_at) VALUES ($1, now())")
.bind(workday)
.execute(&pool)
.await
.expect("pause");
sqlx::query("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, 'laptop', 'hash')")
.bind(user)
.execute(&pool)
.await
.expect("agent");
sqlx::query("INSERT INTO reports (user_id, kind, period_start, submitted_at) VALUES ($1, 'daily', date '2026-08-14', now())")
.bind(user)
.execute(&pool)
.await
.expect("report");
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(user)
.execute(&pool)
.await
.expect("failed to delete the user");
for (table, sql) in [
("workdays", "SELECT count(*) FROM workdays"),
("pauses", "SELECT count(*) FROM pauses"),
("agents", "SELECT count(*) FROM agents"),
("reports", "SELECT count(*) FROM reports"),
] {
let left: i64 = sqlx::query_scalar(sql).fetch_one(&pool).await.expect("failed to count rows");
assert_eq!(left, 0, "`{table}` still holds rows of a deleted user");
}
})
.await;
}
#[tokio::test]
async fn impossible_intervals_are_rejected() {
with_db(|pool| async move {
let user = insert_user(&pool, "checks@example.test").await;
let workday = insert_workday(&pool, user, "2026-08-14").await.expect("workday");
let backwards = sqlx::query("UPDATE workdays SET ended_at = started_at - interval '1 hour' WHERE id = $1")
.bind(workday)
.execute(&pool)
.await;
assert!(backwards.is_err(), "a day cannot end before it starts");
let negative = sqlx::query("INSERT INTO pauses (workday_id, started_at, duration_seconds) VALUES ($1, now(), -1)")
.bind(workday)
.execute(&pool)
.await;
assert!(negative.is_err(), "a pause cannot last a negative time");
let over_complete = sqlx::query(
"INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, completeness)
VALUES ($1, 1, 1, date '2026-08-14', now(), 'Overachieve', 101)",
)
.bind(user)
.execute(&pool)
.await;
assert!(over_complete.is_err(), "completeness above 100 must be rejected");
})
.await;
}
#[tokio::test]
async fn updated_at_follows_the_row() {
with_db(|pool| async move {
let user = insert_user(&pool, "touched@example.test").await;
let before: (chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>) = sqlx::query_as("SELECT created_at, updated_at FROM users WHERE id = $1")
.bind(user)
.fetch_one(&pool)
.await
.expect("failed to read timestamps");
assert_eq!(before.0, before.1, "a fresh row should carry equal timestamps");
sqlx::query("UPDATE users SET display_name = 'Renamed' WHERE id = $1")
.bind(user)
.execute(&pool)
.await
.expect("failed to update");
let after: (chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>) = sqlx::query_as("SELECT created_at, updated_at FROM users WHERE id = $1")
.bind(user)
.fetch_one(&pool)
.await
.expect("failed to read timestamps");
assert_eq!(after.0, before.0, "created_at must not move");
assert!(after.1 > before.1, "updated_at must advance on write");
})
.await;
}