Files
doctate/server/tests/oneliners_api_test.rs
T
Brummel 813fb896ff refactor: consolidate bulk actions, URL assembly, and case-artefact filenames
Pulls three pattern groups into shared helpers so a rename or wire-format
tweak edits one file instead of 10+:

- BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset"
  string literals that were duplicated between the bulk handler and 3
  attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion"
  error shape; the handler match is now exhaustive over the enum.
- join_url helper in doctate-common absorbs 7 identical
  trim_end_matches+format! call sites across client-core, client-desktop,
  server/transcribe (ollama, whisper), and server/analyze (llm).
- server/tests/common/artefacts.rs re-exports ONELINER_FILENAME
  (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE
  (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so
  test files reference the canonical name instead of inlining literals.

Also lifts client-desktop local duplication:
- paths.rs: project_path helper collapses 4 identical ProjectDirs chains
- main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks
- app.rs: named RecordingContext struct replaces (Uuid, String) tuple
  at 3 sites around the ffmpeg-flush finalization path

Verification: 397 tests pass (baseline was 389; +8 for new unit tests on
BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
2026-04-22 12:09:46 +02:00

447 lines
13 KiB
Rust

mod common;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use axum::http::StatusCode;
use doctate_common::oneliners::OnelinerState;
use filetime::FileTime;
use tower::util::ServiceExt;
use common::{
CLOSE_MARKER, TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match,
header_str, seed_oneliner_ready, seed_oneliner_state, seed_recording_with_age, test_user,
test_user_with_window_hours,
};
const TEST_SLUG: &str = "dr_oneliners";
const TEST_KEY: &str = "key-dr_oneliners";
fn test_config() -> (std::sync::Arc<doctate_server::config::Config>, PathBuf) {
let cfg = TestConfig::new()
.with_label("oneliners")
.with_user(test_user(TEST_SLUG))
.build();
let dp = cfg.data_path.clone();
(cfg, dp)
}
fn test_config_with_window_hours(
hours: u32,
) -> (std::sync::Arc<doctate_server::config::Config>, PathBuf) {
let cfg = TestConfig::new()
.with_label("oneliners-win")
.with_user(test_user_with_window_hours(TEST_SLUG, hours))
.build();
let dp = cfg.data_path.clone();
(cfg, dp)
}
/// Seed a case with an aged m4a and optionally a ready oneliner.
/// Combines the common primitives — a single call is more ergonomic
/// here than three repeated triples across every test.
fn seed(
user_root: &Path,
case_id: &str,
m4a_age: Duration,
oneliner_text: Option<&str>,
) -> PathBuf {
let case_dir = user_root.join(TEST_SLUG).join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
seed_recording_with_age(&case_dir, "2026-04-18T10-00-00Z", m4a_age);
if let Some(text) = oneliner_text {
seed_oneliner_ready(&case_dir, text);
}
case_dir
}
fn mark_deleted(case_dir: &Path) {
std::fs::write(case_dir.join(CLOSE_MARKER), "{}").unwrap();
}
#[tokio::test]
async fn returns_401_without_api_key() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/oneliners")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_user_dir_returns_200_empty_list() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let etag = header_str(&response, "etag");
assert!(etag.starts_with("W/\""), "etag was {etag:?}");
assert!(etag.ends_with("-72\""), "etag was {etag:?}");
let body = body_json(response).await;
assert_eq!(body["window_hours"], 72);
assert!(body["oneliners"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn recent_case_with_oneliner_shows_up() {
let (config, dp) = test_config();
let case_id = "550e8400-e29b-41d4-a716-000000000001";
seed(
&dp,
case_id,
Duration::from_secs(60),
Some("Kniegelenk re., V.a. Meniskus"),
);
let app = doctate_server::create_router(config);
let response = app
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["case_id"], case_id);
assert_eq!(arr[0]["oneliner"]["kind"], "ready");
assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus");
assert!(arr[0]["created_at"].is_string());
assert!(arr[0]["updated_at"].is_string());
}
#[tokio::test]
async fn case_without_oneliner_shows_null() {
let (config, dp) = test_config();
seed(
&dp,
"550e8400-e29b-41d4-a716-000000000002",
Duration::from_secs(60),
None,
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["oneliner"].is_null());
}
#[tokio::test]
async fn case_older_than_default_window_excluded() {
let (config, dp) = test_config();
seed(
&dp,
"550e8400-e29b-41d4-a716-000000000003",
Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default
Some("old case"),
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
assert!(body["oneliners"].as_array().unwrap().is_empty());
}
/// Demonstrates that the visibility decision lives in users.toml:
/// the same 40 h-old case is hidden under window_hours = 16 and
/// visible under window_hours = 48.
#[tokio::test]
async fn case_visibility_follows_user_window_hours() {
let case_id = "550e8400-e29b-41d4-a716-000000000004";
let age = Duration::from_secs(40 * 3600);
let (config_small, dp_small) = test_config_with_window_hours(16);
seed(&dp_small, case_id, age, Some("40h case"));
let app_small = doctate_server::create_router(config_small);
let body = body_json(
app_small
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 16);
assert!(body["oneliners"].as_array().unwrap().is_empty());
let (config_wide, dp_wide) = test_config_with_window_hours(48);
seed(&dp_wide, case_id, age, Some("40h case"));
let app_wide = doctate_server::create_router(config_wide);
let body = body_json(
app_wide
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 48);
assert_eq!(body["oneliners"].as_array().unwrap().len(), 1);
}
/// Regression guard: a lingering `?hours=N` from an old client is
/// silently ignored — the user-config value drives the response.
#[tokio::test]
async fn hours_query_param_is_ignored() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners?hours=999", TEST_KEY))
.await
.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 72);
}
#[tokio::test]
async fn matching_if_none_match_returns_304() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let first = app
.clone()
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let etag = header_str(&first, "etag");
assert!(!etag.is_empty());
let second = app
.oneshot(get_with_api_key_if_none_match(
"/api/oneliners",
TEST_KEY,
&etag,
))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(header_str(&second, "etag"), etag);
}
#[tokio::test]
async fn stale_if_none_match_returns_200() {
let (config, _dp) = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(get_with_api_key_if_none_match(
"/api/oneliners",
TEST_KEY,
"W/\"99999999-72\"",
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
/// Two users with different `window_hours` must produce different
/// ETag suffixes, even on empty data — so an admin's edit to
/// users.toml invalidates client caches after the next restart.
#[tokio::test]
async fn etag_differs_between_users_with_different_window_hours() {
let (config_a, _dp_a) = test_config_with_window_hours(16);
let (config_b, _dp_b) = test_config_with_window_hours(48);
let app_a = doctate_server::create_router(config_a);
let app_b = doctate_server::create_router(config_b);
let r_a = app_a
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
let r_b = app_b
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap();
let etag_a = header_str(&r_a, "etag");
let etag_b = header_str(&r_b, "etag");
assert_ne!(etag_a, etag_b);
assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}");
assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}");
}
#[tokio::test]
async fn deleted_case_is_excluded() {
let (config, dp) = test_config();
let case_dir = seed(
&dp,
"550e8400-e29b-41d4-a716-000000000005",
Duration::from_secs(60),
Some("deleted case"),
);
mark_deleted(&case_dir);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
assert!(body["oneliners"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn multiple_cases_sorted_newest_first() {
let (config, dp) = test_config();
seed(
&dp,
"550e8400-e29b-41d4-a716-000000000010",
Duration::from_secs(2 * 3600),
Some("older"),
);
seed(
&dp,
"550e8400-e29b-41d4-a716-000000000011",
Duration::from_secs(30),
Some("newer"),
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["oneliner"]["text"], "newer");
assert_eq!(arr[1]["oneliner"]["text"], "older");
}
/// Regression guard: if a case was first created long ago but got a
/// follow-up recording today, it must sort ahead of a case whose single
/// (older) recording happens to be newer than the first one. Sorting
/// must use `last_recording_at`, not `created_at`.
#[tokio::test]
async fn sorts_by_last_recording_not_created() {
let (config, dp) = test_config();
// Case A: first recording 20h ago (old created_at), second 1 min ago
// (new last_recording_at).
let case_a = "550e8400-e29b-41d4-a716-000000000020";
let dir_a = dp.join(TEST_SLUG).join(case_a);
std::fs::create_dir_all(&dir_a).unwrap();
let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a");
std::fs::write(&old_m4a, b"x").unwrap();
filetime::set_file_mtime(
&old_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)),
)
.unwrap();
let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a");
std::fs::write(&fresh_m4a, b"x").unwrap();
filetime::set_file_mtime(
&fresh_m4a,
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
)
.unwrap();
seed_oneliner_ready(&dir_a, "A (fresh addendum)");
// Case B: single recording 10h ago — created_at newer than A's, but
// last_recording_at older than A's fresh addendum.
seed(
&dp,
"550e8400-e29b-41d4-a716-000000000021",
Duration::from_secs(10 * 3600),
Some("B (single, middle-aged)"),
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["case_id"], case_a, "A must sort first");
assert!(arr[0]["last_recording_at"].is_string());
}
#[tokio::test]
async fn empty_state_serializes_as_kind_empty() {
let (config, dp) = test_config();
let case_dir = seed(
&dp,
"550e8400-e29b-41d4-a716-000000000030",
Duration::from_secs(60),
None,
);
seed_oneliner_state(
&case_dir,
&OnelinerState::Empty {
generated_at: "2026-04-18T10:00:00Z".into(),
},
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["oneliner"]["kind"], "empty");
assert!(arr[0]["oneliner"].get("text").is_none());
}
#[tokio::test]
async fn error_state_serializes_as_kind_error() {
let (config, dp) = test_config();
let case_dir = seed(
&dp,
"550e8400-e29b-41d4-a716-000000000031",
Duration::from_secs(60),
None,
);
seed_oneliner_state(
&case_dir,
&OnelinerState::Error {
generated_at: "2026-04-18T10:00:00Z".into(),
},
);
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
.await
.unwrap(),
)
.await;
let arr = body["oneliners"].as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["oneliner"]["kind"], "error");
}