mod support;
use axum::http::StatusCode;
use serde_json::{Value, json};
use support::{TestDb, TestServer};
fn day(date: &str, agent_task_id: i64) -> Value {
json!({
"date": date,
"started_at": format!("{date}T09:00:00-03:00"),
"ended_at": format!("{date}T18:00:00-03:00"),
"pauses": [{
"started_at": format!("{date}T12:00:00-03:00"),
"ended_at": format!("{date}T12:30:00-03:00"),
"duration_seconds": 1800,
"manual": true,
"reason": "lunch"
}],
"tasks": [{
"agent_task_id": agent_task_id,
"recorded_at": format!("{date}T17:00:00-03:00"),
"name": "Ship the release",
"comment": "went fine",
"completeness": 100
}]
})
}
fn schema_version() -> i64 {
kasl_server::migrator().migrations.last().map(|m| m.version).unwrap_or_default()
}
async fn dump(server: &TestServer) -> Vec<u8> {
let mut out = Vec::new();
kasl_server::backup::dump(&server.pool, schema_version(), &mut out)
.await
.expect("the backup should be written");
out
}
#[tokio::test]
async fn what_goes_in_comes_back_out() {
let Some(server) = TestServer::start().await else { return };
for (index, date) in ["2026-08-24", "2026-08-25"].iter().enumerate() {
let (status, body) = server.post_day(&server.token, day(date, index as i64 + 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
}
server.add_admin("boss@example.test", "correct horse").await;
let (_, admin, _) = server.login("boss@example.test", "correct horse").await;
let (status, _, body) = server
.post_with_cookie("/api/v1/departments", admin.as_deref(), json!({ "name": "Engineering" }))
.await;
assert_eq!(status, StatusCode::CREATED, "{body}");
let file = dump(&server).await;
assert!(!file.is_empty(), "the backup should not be empty");
let Some(fresh) = TestDb::create().await else { return };
let restored = kasl_server::backup::load(&fresh.pool, schema_version(), file.as_slice())
.await
.expect("the restore should succeed");
assert!(restored.rows > 0, "the restore moved nothing: {restored:?}");
let second = TestServer::wrap(fresh);
let (_, cookie, _) = second.login("boss@example.test", "correct horse").await;
let (status, body) = second
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-25", cookie.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let employee = body["members"]
.as_array()
.expect("members")
.iter()
.find(|m| m["email"] == "employee@example.test")
.expect("the employee should have survived the restore");
assert_eq!(employee["days_recorded"], 2, "{body}");
assert_eq!(employee["worked_seconds"], 2 * (9 * 3600 - 1800), "{body}");
let ids: Vec<String> = body["members"]
.as_array()
.unwrap()
.iter()
.map(|m| m["id"].as_str().unwrap().to_string())
.collect();
assert!(!ids.is_empty());
let (status, days) = second
.get_with_cookie(
&format!("/api/v1/users/{}/days?from=2026-08-24&to=2026-08-24", employee["id"].as_str().unwrap()),
cookie.as_deref(),
)
.await;
assert_eq!(status, StatusCode::OK, "{days}");
assert_eq!(days["days"][0]["pauses"][0]["reason"], "lunch", "{days}");
assert_eq!(days["days"][0]["tasks"][0]["comment"], "went fine", "{days}");
second.close().await;
}
#[tokio::test]
async fn a_department_and_its_manager_survive_each_other() {
let Some(server) = TestServer::start().await else { return };
server.add_admin("boss@example.test", "correct horse").await;
let (_, admin, _) = server.login("boss@example.test", "correct horse").await;
let (status, body) = server.get_with_cookie("/api/v1/users", admin.as_deref()).await;
assert_eq!(status, StatusCode::OK, "{body}");
let boss_id = body.as_array().unwrap().iter().find(|u| u["email"] == "boss@example.test").expect("the admin")["id"]
.as_str()
.unwrap()
.to_string();
let (status, _, body) = server
.post_with_cookie("/api/v1/departments", admin.as_deref(), json!({ "name": "Engineering", "manager_id": boss_id }))
.await;
assert_eq!(status, StatusCode::CREATED, "{body}");
let department_id = body["id"].as_str().unwrap().to_string();
let (status, _, body) = server
.put_with_cookie(
&format!("/api/v1/users/{boss_id}/department"),
admin.as_deref(),
json!({ "department_id": department_id }),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{body}");
let file = dump(&server).await;
let Some(fresh) = TestDb::create().await else { return };
kasl_server::backup::load(&fresh.pool, schema_version(), file.as_slice())
.await
.expect("a department and its manager must restore together");
let restored: (Option<uuid::Uuid>, Option<uuid::Uuid>) = sqlx::query_as(
"SELECT u.department_id, d.manager_id
FROM users u JOIN departments d ON d.id = u.department_id
WHERE u.email = 'boss@example.test'",
)
.fetch_one(&fresh.pool)
.await
.expect("the manager should be in their department");
assert!(restored.0.is_some(), "the department membership was lost");
assert_eq!(restored.1.map(|id| id.to_string()), Some(boss_id), "the department forgot who runs it");
fresh.drop().await;
}
#[tokio::test]
async fn an_agent_can_still_deliver_after_a_restore() {
let Some(server) = TestServer::start().await else { return };
let (status, body) = server.post_day(&server.token, day("2026-08-24", 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let file = dump(&server).await;
let Some(fresh) = TestDb::create().await else { return };
kasl_server::backup::load(&fresh.pool, schema_version(), file.as_slice())
.await
.expect("restore");
let second = TestServer::wrap(fresh);
let (status, body) = second.post_day(&server.token, day("2026-08-26", 2)).await;
assert_eq!(status, StatusCode::OK, "an agent's token must survive a restore: {body}");
second.close().await;
}
#[tokio::test]
async fn the_command_writes_a_parseable_file_to_stdout() {
let Some(server) = TestServer::start().await else { return };
let Ok(url) = std::env::var("DATABASE_URL") else { return };
let (status, body) = server.post_day(&server.token, day("2026-08-24", 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let database = support::replace_database(&url, &server.database_name());
let output = std::process::Command::new(env!("CARGO_BIN_EXE_kasl-server"))
.arg("backup")
.env("DATABASE_URL", database)
.output()
.expect("the binary should run");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let first = String::from_utf8_lossy(&output.stdout);
let first = first.lines().next().expect("stdout should carry the backup");
let header: serde_json::Value = serde_json::from_str(first).unwrap_or_else(|error| {
panic!("the first line of stdout is not the backup header ({error}): {first}");
});
assert_eq!(header["format"], "kasl-server-backup");
assert!(
String::from_utf8_lossy(&output.stderr).contains("wrote"),
"the summary belongs on stderr, where it cannot corrupt the file"
);
}
#[tokio::test]
async fn a_restore_refuses_a_database_that_already_holds_people() {
let Some(server) = TestServer::start().await else { return };
let file = dump(&server).await;
let error = kasl_server::backup::load(&server.pool, schema_version(), file.as_slice())
.await
.expect_err("restoring over data should be refused");
assert!(error.to_string().contains("already holds"), "{error}");
}
#[tokio::test]
async fn a_backup_from_a_newer_server_is_refused() {
let Some(server) = TestServer::start().await else { return };
let mut file = Vec::new();
kasl_server::backup::dump(&server.pool, schema_version() + 1, &mut file).await.expect("dump");
let Some(fresh) = TestDb::create().await else { return };
let error = kasl_server::backup::load(&fresh.pool, schema_version(), file.as_slice())
.await
.expect_err("a newer backup should be refused");
assert!(error.to_string().contains("newer server"), "{error}");
fresh.drop().await;
}
#[tokio::test]
async fn a_file_that_is_not_a_backup_is_refused() {
let Some(db) = TestDb::create().await else { return };
for (bytes, expected) in [
(&b""[..], "empty"),
(&b"{}\n"[..], "not a kasl-server backup"),
(&b"not json at all\n"[..], "not a kasl-server backup header"),
] {
let error = kasl_server::backup::load(&db.pool, schema_version(), bytes)
.await
.expect_err("garbage should be refused");
assert!(error.to_string().contains(expected), "expected `{expected}` in: {error}");
}
db.drop().await;
}
#[tokio::test]
async fn a_backup_names_a_table_this_server_does_not_have() {
let Some(db) = TestDb::create().await else { return };
let header = json!({
"format": "kasl-server-backup",
"schema_version": schema_version(),
"server_version": "0.14.0",
"taken_at": "2026-08-28T10:00:00Z"
});
let file = format!("{header}\n{}\n", json!({ "table": "users; DROP TABLE users", "rows": [{}] }));
let error = kasl_server::backup::load(&db.pool, schema_version(), file.as_bytes())
.await
.expect_err("an unknown table should be refused");
assert!(error.to_string().contains("does not have"), "{error}");
let users: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
.fetch_one(&db.pool)
.await
.expect("users should still be there");
assert_eq!(users, 0);
db.drop().await;
}
#[tokio::test]
async fn every_table_in_the_schema_is_carried() {
let Some(db) = TestDb::create().await else { return };
let in_schema: Vec<String> = sqlx::query_scalar(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name",
)
.fetch_all(&db.pool)
.await
.expect("failed to list the schema's tables");
let carried_elsewhere = [
"_sqlx_migrations",
kasl_server::backup::SETTINGS,
];
let missing: Vec<&String> = in_schema
.iter()
.filter(|table| !kasl_server::backup::TABLES.contains(&table.as_str()))
.filter(|table| !carried_elsewhere.contains(&table.as_str()))
.collect();
assert!(
missing.is_empty(),
"these tables exist in the schema and no backup would carry them: {missing:?}. \
Add each to `TABLES` in src/backup.rs, after a parent it points at, or to \
`carried_elsewhere` here with the reason it is exempt.",
);
for table in kasl_server::backup::TABLES {
assert!(in_schema.iter().any(|t| t == table), "`{table}` is in TABLES and not in the schema");
}
db.drop().await;
}