813fb896ff
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.
447 lines
13 KiB
Rust
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");
|
|
}
|