mod support;
use axum::http::StatusCode;
use serde_json::{Value, json};
use support::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",
"completeness": 100
}]
})
}
struct Team {
admin: Option<String>,
manager: Option<String>,
employee: Option<String>,
inside_id: String,
outside_id: String,
}
async fn team(server: &TestServer) -> Team {
server.add_admin("boss@example.test", "correct horse").await;
let (_, admin, _) = server.login("boss@example.test", "correct horse").await;
server.add_agent("inside@example.test", "inside-token").await;
server.add_agent("outside@example.test", "outside-token").await;
server.add_agent("lead@example.test", "lead-token").await;
for email in ["inside@example.test", "outside@example.test", "lead@example.test"] {
server.set_password(email, "correct horse").await;
}
let ids = user_ids(server, admin.as_deref()).await;
let lead_id = ids["lead@example.test"].clone();
let inside_id = ids["inside@example.test"].clone();
let outside_id = ids["outside@example.test"].clone();
let (status, _, body) = server
.patch_with_cookie(&format!("/api/v1/users/{lead_id}"), admin.as_deref(), json!({ "role": "manager" }))
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{body}");
let (status, _, body) = server
.post_with_cookie("/api/v1/departments", admin.as_deref(), json!({ "name": "Engineering", "manager_id": lead_id }))
.await;
assert_eq!(status, StatusCode::CREATED, "{body}");
let department_id = body["id"].as_str().expect("the department should have an id").to_string();
let (status, _, body) = server
.put_with_cookie(
&format!("/api/v1/users/{inside_id}/department"),
admin.as_deref(),
json!({ "department_id": department_id }),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{body}");
let (_, manager, _) = server.login("lead@example.test", "correct horse").await;
let (_, employee, _) = server.login("inside@example.test", "correct horse").await;
Team {
admin,
manager,
employee,
inside_id,
outside_id,
}
}
async fn user_ids(server: &TestServer, admin: Option<&str>) -> std::collections::HashMap<String, String> {
let (status, body) = server.get_with_cookie("/api/v1/users", admin).await;
assert_eq!(status, StatusCode::OK, "{body}");
body.as_array()
.expect("a list of users")
.iter()
.map(|user| {
(
user["email"].as_str().expect("an email").to_string(),
user["id"].as_str().expect("an id").to_string(),
)
})
.collect()
}
fn emails(body: &Value) -> Vec<&str> {
body["members"]
.as_array()
.expect("members")
.iter()
.map(|m| m["email"].as_str().unwrap())
.collect()
}
#[tokio::test]
async fn a_manager_sees_their_department_and_themselves_and_nobody_else() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let seen = emails(&body);
assert!(seen.contains(&"inside@example.test"), "their department: {body}");
assert!(seen.contains(&"lead@example.test"), "a manager must find their own row: {body}");
assert!(!seen.contains(&"outside@example.test"), "an unfiled employee is not theirs to see: {body}");
assert!(!seen.contains(&"boss@example.test"), "{body}");
}
#[tokio::test]
async fn an_administrator_sees_everyone() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.admin.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let seen = emails(&body);
for email in ["inside@example.test", "outside@example.test", "lead@example.test", "boss@example.test"] {
assert!(seen.contains(&email), "the admin should see {email}: {body}");
}
}
#[tokio::test]
async fn an_employee_is_refused_the_team_entirely() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.employee.as_deref())
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
let (status, body) = server
.get_with_cookie(
&format!("/api/v1/users/{}/days?from=2026-08-24&to=2026-08-30", team.inside_id),
team.employee.as_deref(),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
}
#[tokio::test]
async fn signing_in_is_required() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}");
let (status, body) = server
.get_with_header(
&format!("/api/v1/users/{}/days?from=2026-08-24&to=2026-08-30", team.inside_id),
Some("Bearer inside-token"),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}");
}
#[tokio::test]
async fn someone_elses_employee_is_indistinguishable_from_a_stranger() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (real, body) = server
.get_with_cookie(
&format!("/api/v1/users/{}/days?from=2026-08-24&to=2026-08-30", team.outside_id),
team.manager.as_deref(),
)
.await;
assert_eq!(real, StatusCode::NOT_FOUND, "{body}");
let (invented, body) = server
.get_with_cookie(
"/api/v1/users/00000000-0000-0000-0000-000000000000/days?from=2026-08-24&to=2026-08-30",
team.manager.as_deref(),
)
.await;
assert_eq!(invented, StatusCode::NOT_FOUND, "{body}");
}
#[tokio::test]
async fn the_drill_down_answers_the_same_shape_as_the_personal_page() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server.post_day("inside-token", day("2026-08-25", 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, manager_view) = server
.get_with_cookie(
&format!("/api/v1/users/{}/days?from=2026-08-25&to=2026-08-25", team.inside_id),
team.manager.as_deref(),
)
.await;
assert_eq!(status, StatusCode::OK, "{manager_view}");
let (status, own_view) = server
.get_with_cookie("/api/v1/me/days?from=2026-08-25&to=2026-08-25", team.employee.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{own_view}");
assert_eq!(manager_view, own_view, "the manager's drill-down and the employee's own page must agree");
assert_eq!(manager_view["days"][0]["worked_seconds"], 9 * 3600 - 1800);
}
#[tokio::test]
async fn hours_add_up_across_the_range_and_stop_at_its_edges() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
for (index, date) in ["2026-08-25", "2026-08-26", "2026-09-01"].iter().enumerate() {
let (status, body) = server.post_day("inside-token", day(date, index as i64 + 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
}
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let inside = body["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["email"] == "inside@example.test")
.expect("the department member should be listed");
assert_eq!(inside["days_recorded"], 2, "September is outside the range: {body}");
assert_eq!(inside["worked_seconds"], 2 * (9 * 3600 - 1800), "{body}");
assert_eq!(inside["paused_seconds"], 2 * 1800, "{body}");
assert_eq!(inside["last_day"], "2026-08-26", "{body}");
let both: i64 = sqlx::query_scalar(
"SELECT count(*) FROM workdays w
WHERE w.paused_seconds IS NOT NULL AND EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)",
)
.fetch_one(&server.pool)
.await
.expect("the invariant query should run");
assert_eq!(both, 0, "a day carries pause rows or their totals, never both");
}
#[tokio::test]
async fn hours_stay_honest_when_pauses_are_not_stored_one_by_one() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, _, body) = server
.put_with_cookie("/api/v1/privacy", team.admin.as_deref(), json!({ "level": "coarse" }))
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = server.post_day("inside-token", day("2026-08-25", 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let inside = body["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["email"] == "inside@example.test")
.unwrap();
assert_eq!(inside["paused_seconds"], 1800, "{body}");
assert_eq!(inside["worked_seconds"], 9 * 3600 - 1800, "{body}");
assert_eq!(body["not_stored"], json!(["pauses", "tasks", "free_text"]), "{body}");
}
#[tokio::test]
async fn a_person_with_nothing_recorded_is_still_listed() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let inside = body["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["email"] == "inside@example.test")
.unwrap();
assert_eq!(inside["days_recorded"], 0, "{body}");
assert_eq!(inside["worked_seconds"], 0);
assert_eq!(inside["last_day"], Value::Null, "no data is not a date: {body}");
assert_eq!(inside["day_open"], false);
assert_eq!(inside["agents"], 1, "{body}");
assert_eq!(inside["last_seen_at"], Value::Null, "{body}");
}
#[tokio::test]
async fn an_open_day_today_is_reported_as_open() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let today = chrono::Utc::now().date_naive().to_string();
let mut open = day(&today, 7);
open.as_object_mut().unwrap().remove("ended_at");
let (status, body) = server.post_day("inside-token", open).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = server
.get_with_cookie(&format!("/api/v1/team/days?from={today}&to={today}"), team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let inside = body["members"]
.as_array()
.unwrap()
.iter()
.find(|m| m["email"] == "inside@example.test")
.unwrap();
assert_eq!(inside["day_open"], true, "{body}");
assert_eq!(inside["days_recorded"], 1);
assert_eq!(inside["worked_seconds"], 0, "an open day has no total to add: {body}");
assert_ne!(inside["last_seen_at"], Value::Null, "{body}");
}
#[tokio::test]
async fn a_deactivated_person_leaves_the_dashboard_but_keeps_their_history() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
let (status, body) = server.post_day("inside-token", day("2026-08-25", 1)).await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, _, body) = server
.patch_with_cookie(&format!("/api/v1/users/{}", team.inside_id), team.admin.as_deref(), json!({ "active": false }))
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{body}");
let (status, body) = server
.get_with_cookie("/api/v1/team/days?from=2026-08-24&to=2026-08-30", team.manager.as_deref())
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert!(
!emails(&body).contains(&"inside@example.test"),
"a deactivated person is not on today's dashboard: {body}"
);
let (status, body) = server
.get_with_cookie(
&format!("/api/v1/users/{}/days?from=2026-08-24&to=2026-08-30", team.inside_id),
team.admin.as_deref(),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["days"].as_array().unwrap().len(), 1, "{body}");
}
#[tokio::test]
async fn a_malformed_range_is_refused() {
let Some(server) = TestServer::start().await else { return };
let team = team(&server).await;
for query in ["", "?from=2026-08-24", "?from=2026-01-01&to=2027-06-01"] {
let (status, body) = server.get_with_cookie(&format!("/api/v1/team/days{query}"), team.manager.as_deref()).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "`{query}` should be refused: {body}");
}
}