refactor(tests): migrate remaining batches to tests/common/
Finishes the lift of shared helpers into `tests/common/`. Covered: - health, web, oneliners_api, upload (31 tests; upload exercises the new `multipart_upload_body` helper driven by doctate_common field constants) - sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled, silent_case_empty, failed_only_case_empty_oneliner, transient_failure_retries (24 tests) Also applied cargo fmt across the test tree and fixed one clippy needless_borrows_for_generic_args warning in analyze_test. All 387 tests pass; 3 ignored (as before). Side effect: health_test previously used a hardcoded `/tmp/doctate-test` data path, which parallel `cargo test` runs could collide on. The migration replaces it with the common unique-tmpdir pattern, removing a latent flake.
This commit is contained in:
@@ -781,7 +781,7 @@ async fn closed_case_detail_with_show_closed_returns_200() {
|
||||
// With ?show_closed=1 → 200 with the reopen form.
|
||||
let body = common::body_string(
|
||||
app.oneshot(get_with_cookie(
|
||||
&format!("{}?show_closed=1", paths::case_detail(case_id)),
|
||||
format!("{}?show_closed=1", paths::case_detail(case_id)),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
@@ -1252,7 +1252,12 @@ async fn bulk_reset_processes_multiple_cases_admin_only() {
|
||||
let body = format!("action=reset&case_id={case_a}&case_id={case_b}");
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(csrf_form_post(paths::BULK, &cookie_admin, &csrf_admin, &body))
|
||||
.oneshot(csrf_form_post(
|
||||
paths::BULK,
|
||||
&cookie_admin,
|
||||
&csrf_admin,
|
||||
&body,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
@@ -8,9 +8,8 @@ use common::{TestConfig, test_user};
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_api_key_returns_user() {
|
||||
let app = doctate_server::create_router(
|
||||
TestConfig::new().with_user(test_user("dr_test")).build(),
|
||||
);
|
||||
let app =
|
||||
doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -32,9 +31,8 @@ async fn valid_api_key_returns_user() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_api_key_returns_401() {
|
||||
let app = doctate_server::create_router(
|
||||
TestConfig::new().with_user(test_user("dr_test")).build(),
|
||||
);
|
||||
let app =
|
||||
doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -52,9 +50,8 @@ async fn invalid_api_key_returns_401() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_api_key_returns_401() {
|
||||
let app = doctate_server::create_router(
|
||||
TestConfig::new().with_user(test_user("dr_test")).build(),
|
||||
);
|
||||
let app =
|
||||
doctate_server::create_router(TestConfig::new().with_user(test_user("dr_test")).build());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
||||
@@ -129,9 +129,7 @@ impl TestConfig {
|
||||
/// `Config::test_default` themselves are broken (not reachable in
|
||||
/// practice).
|
||||
pub fn build(self) -> Arc<Config> {
|
||||
let data_path = self
|
||||
.data_path
|
||||
.unwrap_or_else(|| unique_tmpdir(self.label));
|
||||
let data_path = self.data_path.unwrap_or_else(|| unique_tmpdir(self.label));
|
||||
let api_keys: HashMap<String, String> = self
|
||||
.users
|
||||
.iter()
|
||||
|
||||
@@ -121,11 +121,7 @@ pub fn header_str(response: &Response, name: &str) -> String {
|
||||
/// Uses the server's canonical field names (`case_id`, `recorded_at`,
|
||||
/// `audio`) from `doctate_common::constants`, so a server-side rename
|
||||
/// propagates here for free.
|
||||
pub fn multipart_upload_body(
|
||||
case_id: &str,
|
||||
recorded_at: &str,
|
||||
audio: &[u8],
|
||||
) -> (String, Vec<u8>) {
|
||||
pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
|
||||
let boundary = "----testboundary";
|
||||
let mut body = Vec::new();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ pub mod users;
|
||||
// Re-exports for ergonomic `use common::*;` in most test files.
|
||||
pub use config::{TestConfig, unique_tmpdir};
|
||||
pub use http::{
|
||||
body_json, body_string, csrf_form_post, form_post, get_with_api_key,
|
||||
body_bytes, body_json, body_string, csrf_form_post, form_post, get_with_api_key,
|
||||
get_with_api_key_if_none_match, get_with_cookie, header_opt, header_str, multipart_upload_body,
|
||||
};
|
||||
pub use seed::{
|
||||
|
||||
@@ -40,11 +40,7 @@ pub fn seed_recording(case_dir: &Path, stem: &str, transcript: Option<&str>) ->
|
||||
/// Like [`seed_recording`] but additionally writes the `.duration.txt`
|
||||
/// sidecar — used by the delete-recording tests that assert every
|
||||
/// sidecar is cleaned up.
|
||||
pub fn seed_recording_with_sidecars(
|
||||
case_dir: &Path,
|
||||
stem: &str,
|
||||
transcript: &str,
|
||||
) -> String {
|
||||
pub fn seed_recording_with_sidecars(case_dir: &Path, stem: &str, transcript: &str) -> String {
|
||||
let filename = seed_recording(case_dir, stem, Some(transcript));
|
||||
std::fs::write(case_dir.join(format!("{stem}.duration.txt")), "42").unwrap();
|
||||
filename
|
||||
|
||||
@@ -58,11 +58,7 @@ pub fn test_user_with_password(slug: &str, password: &str) -> User {
|
||||
|
||||
/// Doctor user with custom retention settings — for the retention sweep
|
||||
/// tests that drive the lazy auto-close / auto-purge logic.
|
||||
pub fn test_user_with_retention(
|
||||
slug: &str,
|
||||
auto_close_days: u32,
|
||||
auto_delete_days: u32,
|
||||
) -> User {
|
||||
pub fn test_user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) -> User {
|
||||
let mut u = test_user(slug);
|
||||
u.retention = RetentionUserSettings {
|
||||
auto_close_days,
|
||||
|
||||
@@ -292,7 +292,11 @@ async fn bulk_with_injection_payload_returns_403_not_500() {
|
||||
// the browser flow breaks and one of these fires.
|
||||
|
||||
async fn html_body(app: &axum::Router, uri: &str, cookie: &str) -> String {
|
||||
let resp = app.clone().oneshot(get_with_cookie(uri, cookie)).await.unwrap();
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(get_with_cookie(uri, cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK, "GET {uri} failed");
|
||||
common::body_string(resp).await
|
||||
}
|
||||
|
||||
@@ -27,10 +27,8 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
|
||||
let victim =
|
||||
seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me");
|
||||
let survivor =
|
||||
seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me");
|
||||
let victim = seed_recording_with_sidecars(&case_dir, "2026-04-19T10-00-00Z", "delete me");
|
||||
let survivor = seed_recording_with_sidecars(&case_dir, "2026-04-19T11-00-00Z", "keep me");
|
||||
|
||||
// Derived artefacts the delete must also purge.
|
||||
std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap();
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
//! case to `Empty` through the same terminal branch used for silent-only
|
||||
//! cases.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_server::config::Config;
|
||||
use doctate_server::events;
|
||||
use doctate_server::gazetteer::Gazetteer;
|
||||
use doctate_server::{
|
||||
@@ -29,6 +30,8 @@ use tempfile::tempdir;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::TestConfig;
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_only_case_settles_to_empty_without_llm_call() {
|
||||
let data = tempdir().unwrap();
|
||||
@@ -50,10 +53,10 @@ async fn failed_only_case_settles_to_empty_without_llm_call() {
|
||||
.mount(&mock)
|
||||
.await;
|
||||
|
||||
let mut cfg = Config::test_default();
|
||||
cfg.data_path = data.path().to_path_buf();
|
||||
cfg.ollama_url = mock.uri();
|
||||
let config = Arc::new(cfg);
|
||||
let config = TestConfig::new()
|
||||
.with_data_path(data.path().to_path_buf())
|
||||
.with_ollama(mock.uri())
|
||||
.build();
|
||||
|
||||
let vocab = Arc::new(Gazetteer::empty());
|
||||
let events_tx = events::channel();
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::Config;
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
Arc::new(Config {
|
||||
data_path: "/tmp/doctate-test".into(),
|
||||
log_path: "/tmp/doctate-test/logs".into(),
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
use common::TestConfig;
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_ok() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let app = doctate_server::create_router(TestConfig::new().with_label("health").build());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -33,7 +25,7 @@ async fn health_returns_ok() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_json_with_status() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let app = doctate_server::create_router(TestConfig::new().with_label("health").build());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -45,11 +37,7 @@ async fn health_returns_json_with_status() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
let json = common::body_json(response).await;
|
||||
assert_eq!(json["status"], "ok");
|
||||
assert_eq!(json["port"], 3000);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
//! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on
|
||||
//! `OnelinerHealBusy` to dedup concurrent page-loads.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_server::config::Config;
|
||||
use doctate_server::events;
|
||||
use doctate_server::gazetteer::Gazetteer;
|
||||
use doctate_server::{
|
||||
@@ -22,6 +23,8 @@ use tokio::time::timeout;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::TestConfig;
|
||||
|
||||
fn write_transcript(case_dir: &Path) {
|
||||
// Seed an m4a + transcript pair, matching the worker's on-disk
|
||||
// layout. The oneliner worker iterates `.m4a` files and resolves
|
||||
@@ -68,10 +71,10 @@ async fn heal_orphans_returns_fast_and_dedups_parallel_calls() {
|
||||
.mount(&mock)
|
||||
.await;
|
||||
|
||||
let mut cfg = Config::test_default();
|
||||
cfg.data_path = data.path().to_path_buf();
|
||||
cfg.ollama_url = mock.uri();
|
||||
let config = Arc::new(cfg);
|
||||
let config = TestConfig::new()
|
||||
.with_data_path(data.path().to_path_buf())
|
||||
.with_ollama(mock.uri())
|
||||
.build();
|
||||
|
||||
let vocab = Arc::new(Gazetteer::empty());
|
||||
let events_tx = events::channel();
|
||||
|
||||
+162
-199
@@ -1,131 +1,62 @@
|
||||
use std::collections::HashMap;
|
||||
mod common;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use filetime::FileTime;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_server::config::{Config, User};
|
||||
use common::{
|
||||
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_KEY: &str = "test-key-oneliners";
|
||||
const TEST_SLUG: &str = "dr_oneliners";
|
||||
const TEST_KEY: &str = "key-dr_oneliners";
|
||||
|
||||
fn test_config() -> (Arc<Config>, PathBuf) {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-oneliners-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let config = Arc::new(Config {
|
||||
data_path: data_path.clone(),
|
||||
users: vec![User {
|
||||
slug: TEST_SLUG.into(),
|
||||
api_key: TEST_KEY.into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
|
||||
..Config::test_default()
|
||||
});
|
||||
(config, data_path)
|
||||
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) -> (Arc<Config>, PathBuf) {
|
||||
let (cfg, dp) = test_config();
|
||||
// Safe because `test_config` just created the Arc and handed it
|
||||
// back with refcount == 1.
|
||||
let mut cfg =
|
||||
Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique"));
|
||||
cfg.users[0].window_hours = hours;
|
||||
(Arc::new(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)
|
||||
}
|
||||
|
||||
async fn seed_case(
|
||||
/// 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);
|
||||
tokio::fs::create_dir_all(&case_dir).await.unwrap();
|
||||
|
||||
// Write a dummy .m4a and rewind its mtime to simulate "case born N ago".
|
||||
let m4a = case_dir.join("2026-04-18T10-00-00Z.m4a");
|
||||
tokio::fs::write(&m4a, b"x").await.unwrap();
|
||||
let target_mtime = SystemTime::now() - m4a_age;
|
||||
filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap();
|
||||
|
||||
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 {
|
||||
write_ready_state(&case_dir, text).await;
|
||||
seed_oneliner_ready(&case_dir, text);
|
||||
}
|
||||
case_dir
|
||||
}
|
||||
|
||||
async fn write_ready_state(case_dir: &Path, text: &str) {
|
||||
let state = OnelinerState::Ready {
|
||||
text: text.to_owned(),
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn write_state(case_dir: &Path, state: &OnelinerState) {
|
||||
let bytes = serde_json::to_vec(state).unwrap();
|
||||
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn mark_deleted(case_dir: &Path) {
|
||||
tokio::fs::write(case_dir.join(".closed"), "{}")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn get(uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("X-API-Key", TEST_KEY)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get_with_if_none_match(uri: &str, etag: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(uri)
|
||||
.header("X-API-Key", TEST_KEY)
|
||||
.header("If-None-Match", etag)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn body_json(response: axum::http::Response<axum::body::Body>) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn header_str(response: &axum::http::Response<axum::body::Body>, name: &str) -> String {
|
||||
response
|
||||
.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
fn mark_deleted(case_dir: &Path) {
|
||||
std::fs::write(case_dir.join(".closed"), "{}").unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -135,10 +66,10 @@ async fn returns_401_without_api_key() {
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/oneliners")
|
||||
.body(Body::empty())
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
@@ -149,10 +80,13 @@ async fn returns_401_without_api_key() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_user_dir_returns_200_empty_list() {
|
||||
let (config, dp) = test_config();
|
||||
let (config, _dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app.oneshot(get("/api/oneliners")).await.unwrap();
|
||||
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");
|
||||
@@ -162,24 +96,24 @@ async fn empty_user_dir_returns_200_empty_list() {
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["window_hours"], 72);
|
||||
assert!(body["oneliners"].as_array().unwrap().is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recent_case_with_oneliner_shows_up() {
|
||||
let (config, dp) = test_config();
|
||||
let case_id = "550e8400-e29b-41d4-a716-000000000001";
|
||||
seed_case(
|
||||
seed(
|
||||
&dp,
|
||||
case_id,
|
||||
Duration::from_secs(60),
|
||||
Some("Kniegelenk re., V.a. Meniskus"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app.oneshot(get("/api/oneliners")).await.unwrap();
|
||||
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();
|
||||
@@ -189,46 +123,48 @@ async fn recent_case_with_oneliner_shows_up() {
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_without_oneliner_shows_null() {
|
||||
let (config, dp) = test_config();
|
||||
seed_case(
|
||||
seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000002",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_older_than_default_window_excluded() {
|
||||
let (config, dp) = test_config();
|
||||
seed_case(
|
||||
seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000003",
|
||||
Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default
|
||||
Some("old case"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
/// Demonstrates that the visibility decision lives in users.toml:
|
||||
@@ -240,71 +176,88 @@ async fn case_visibility_follows_user_window_hours() {
|
||||
let age = Duration::from_secs(40 * 3600);
|
||||
|
||||
let (config_small, dp_small) = test_config_with_window_hours(16);
|
||||
seed_case(&dp_small, case_id, age, Some("40h case")).await;
|
||||
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("/api/oneliners")).await.unwrap()).await;
|
||||
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_case(&dp_wide, case_id, age, Some("40h case")).await;
|
||||
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("/api/oneliners")).await.unwrap()).await;
|
||||
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);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp_small);
|
||||
let _ = std::fs::remove_dir_all(&dp_wide);
|
||||
}
|
||||
|
||||
/// 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 (config, _dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners?hours=999")).await.unwrap()).await;
|
||||
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);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_if_none_match_returns_304() {
|
||||
let (config, dp) = test_config();
|
||||
let (config, _dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let first = app.clone().oneshot(get("/api/oneliners")).await.unwrap();
|
||||
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_if_none_match("/api/oneliners", &etag))
|
||||
.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);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_if_none_match_returns_200() {
|
||||
let (config, dp) = test_config();
|
||||
let (config, _dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(get_with_if_none_match(
|
||||
.oneshot(get_with_api_key_if_none_match(
|
||||
"/api/oneliners",
|
||||
TEST_KEY,
|
||||
"W/\"99999999-72\"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
/// Two users with different `window_hours` must produce different
|
||||
@@ -312,70 +265,76 @@ async fn stale_if_none_match_returns_200() {
|
||||
/// 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 (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("/api/oneliners")).await.unwrap();
|
||||
let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap();
|
||||
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:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp_a);
|
||||
let _ = std::fs::remove_dir_all(&dp_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleted_case_is_excluded() {
|
||||
let (config, dp) = test_config();
|
||||
let case_dir = seed_case(
|
||||
let case_dir = seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000005",
|
||||
Duration::from_secs(60),
|
||||
Some("deleted case"),
|
||||
)
|
||||
.await;
|
||||
mark_deleted(&case_dir).await;
|
||||
);
|
||||
mark_deleted(&case_dir);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_cases_sorted_newest_first() {
|
||||
let (config, dp) = test_config();
|
||||
seed_case(
|
||||
seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000010",
|
||||
Duration::from_secs(2 * 3600),
|
||||
Some("older"),
|
||||
)
|
||||
.await;
|
||||
seed_case(
|
||||
);
|
||||
seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000011",
|
||||
Duration::from_secs(30),
|
||||
Some("newer"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
/// Regression guard: if a case was first created long ago but got a
|
||||
@@ -390,94 +349,98 @@ async fn sorts_by_last_recording_not_created() {
|
||||
// (new last_recording_at).
|
||||
let case_a = "550e8400-e29b-41d4-a716-000000000020";
|
||||
let dir_a = dp.join(TEST_SLUG).join(case_a);
|
||||
tokio::fs::create_dir_all(&dir_a).await.unwrap();
|
||||
std::fs::create_dir_all(&dir_a).unwrap();
|
||||
let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a");
|
||||
tokio::fs::write(&old_m4a, b"x").await.unwrap();
|
||||
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");
|
||||
tokio::fs::write(&fresh_m4a, b"x").await.unwrap();
|
||||
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();
|
||||
write_ready_state(&dir_a, "A (fresh addendum)").await;
|
||||
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_case(
|
||||
seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000021",
|
||||
Duration::from_secs(10 * 3600),
|
||||
Some("B (single, middle-aged)"),
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_state_serializes_as_kind_empty() {
|
||||
let (config, dp) = test_config();
|
||||
let case_dir = seed_case(
|
||||
let case_dir = seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000030",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
write_state(
|
||||
);
|
||||
seed_oneliner_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Empty {
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_state_serializes_as_kind_error() {
|
||||
let (config, dp) = test_config();
|
||||
let case_dir = seed_case(
|
||||
let case_dir = seed(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000031",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
write_state(
|
||||
);
|
||||
seed_oneliner_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Error {
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
);
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
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");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,11 @@ async fn trigger_sweep(app: &axum::Router, cookie: &str) {
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
fn build_cfg(label: &'static str, auto_close_days: u32, auto_delete_days: u32) -> std::sync::Arc<doctate_server::config::Config> {
|
||||
fn build_cfg(
|
||||
label: &'static str,
|
||||
auto_close_days: u32,
|
||||
auto_delete_days: u32,
|
||||
) -> std::sync::Arc<doctate_server::config::Config> {
|
||||
TestConfig::new()
|
||||
.with_label(label)
|
||||
.with_user(test_user_with_retention(
|
||||
@@ -93,11 +97,7 @@ async fn auto_close_respects_newest_recording() {
|
||||
"2026-04-01T10-00-00Z",
|
||||
Duration::from_secs(30 * 86400),
|
||||
);
|
||||
seed_recording_with_age(
|
||||
&case_dir,
|
||||
"2026-04-01T11-00-00Z",
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
seed_recording_with_age(&case_dir, "2026-04-01T11-00-00Z", Duration::from_secs(60));
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = common::login(&app, "dr_a", "s").await;
|
||||
@@ -114,16 +114,9 @@ async fn auto_purge_removes_old_closed_case() {
|
||||
let cfg = build_cfg("purge-old", 0, 30);
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
seed_recording_with_age(
|
||||
&case_dir,
|
||||
"2026-04-01T10-00-00Z",
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
|
||||
// closed_at = 31 days ago → must be purged.
|
||||
write_closed_marker(
|
||||
&case_dir,
|
||||
&past_rfc3339(Duration::from_secs(31 * 86400)),
|
||||
);
|
||||
write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(31 * 86400)));
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = common::login(&app, "dr_a", "s").await;
|
||||
@@ -142,15 +135,8 @@ async fn auto_purge_disabled_when_days_zero() {
|
||||
let cfg = build_cfg("purge-zero", 0, 0);
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
seed_recording_with_age(
|
||||
&case_dir,
|
||||
"2026-04-01T10-00-00Z",
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
write_closed_marker(
|
||||
&case_dir,
|
||||
&past_rfc3339(Duration::from_secs(365 * 86400)),
|
||||
);
|
||||
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
|
||||
write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400)));
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = common::login(&app, "dr_a", "s").await;
|
||||
@@ -198,11 +184,7 @@ async fn auto_purge_skips_open_cases() {
|
||||
let cfg = build_cfg("purge-skip-open", 0, 30);
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
||||
seed_recording_with_age(
|
||||
&case_dir,
|
||||
"2026-04-01T10-00-00Z",
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
|
||||
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = common::login(&app, "dr_a", "s").await;
|
||||
|
||||
@@ -26,14 +26,9 @@ fn count_header_values(resp: &Response, name: &str) -> usize {
|
||||
}
|
||||
|
||||
async fn get(app: axum::Router, uri: &str) -> Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------- presence tests ----------
|
||||
@@ -42,10 +37,7 @@ async fn get(app: axum::Router, uri: &str) -> Response {
|
||||
async fn api_health_has_all_security_headers() {
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
assert!(
|
||||
@@ -123,10 +115,7 @@ async fn csp_blocks_form_action_hijack() {
|
||||
#[tokio::test]
|
||||
async fn nosniff_blocks_mime_confusion() {
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
||||
}
|
||||
|
||||
/// Session-carrying URLs should not leak to third parties via `Referer`.
|
||||
@@ -162,10 +151,7 @@ async fn error_redirect_still_carries_security_headers() {
|
||||
// session extractor).
|
||||
let resp = get(test_app(), "/web/cases").await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_opt(&resp, "content-security-policy").is_some());
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
//! makes `update_oneliner` settle the case to `OnelinerState::Empty`
|
||||
//! when no content transcript exists and no recording is still Pending.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_server::config::Config;
|
||||
use doctate_server::events;
|
||||
use doctate_server::gazetteer::Gazetteer;
|
||||
use doctate_server::{
|
||||
@@ -25,6 +26,8 @@ use tempfile::tempdir;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::TestConfig;
|
||||
|
||||
/// Seed an m4a plus a silent (0-byte) transcript sidecar — the on-disk
|
||||
/// shape of a recording that whisper classified as silence.
|
||||
fn seed_silent_recording(case_dir: &Path, stem: &str) {
|
||||
@@ -52,10 +55,10 @@ async fn silent_only_case_settles_to_empty_without_llm_call() {
|
||||
.mount(&mock)
|
||||
.await;
|
||||
|
||||
let mut cfg = Config::test_default();
|
||||
cfg.data_path = data.path().to_path_buf();
|
||||
cfg.ollama_url = mock.uri();
|
||||
let config = Arc::new(cfg);
|
||||
let config = TestConfig::new()
|
||||
.with_data_path(data.path().to_path_buf())
|
||||
.with_ollama(mock.uri())
|
||||
.build();
|
||||
|
||||
let vocab = Arc::new(Gazetteer::empty());
|
||||
let events_tx = events::channel();
|
||||
|
||||
@@ -11,88 +11,31 @@
|
||||
//! channel through an HTTP test would require reading a never-ending
|
||||
//! body, which these tests deliberately avoid.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use common::{TestConfig, get_with_cookie, header_opt, paths, test_user};
|
||||
|
||||
fn unique_data_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"doctate-sse-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
|
||||
fn make_user(slug: &str) -> User {
|
||||
User {
|
||||
slug: slug.into(),
|
||||
api_key: format!("key-{slug}"),
|
||||
// Password "s" — matches the login helper below.
|
||||
web_password: bcrypt::hash("s", 4).unwrap(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(data_path: PathBuf) -> Arc<Config> {
|
||||
let users = vec![make_user("alice")];
|
||||
let api_keys: HashMap<String, String> = users
|
||||
.iter()
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users,
|
||||
api_keys,
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Log in via POST /web/login and return the resulting `session=…` cookie
|
||||
/// header value. Mirrors the helper in the other integration tests.
|
||||
async fn login(app: axum::Router, slug: &str) -> String {
|
||||
let body = format!("slug={slug}&password=s");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||||
let s = v.to_str().unwrap();
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return pair.to_string();
|
||||
}
|
||||
}
|
||||
panic!("no session cookie after login");
|
||||
fn test_app() -> axum::Router {
|
||||
let cfg = TestConfig::new()
|
||||
.with_label("sse")
|
||||
.with_user(test_user("alice"))
|
||||
.build();
|
||||
doctate_server::create_router(cfg)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_without_session_redirects_to_login() {
|
||||
let config = build_config(unique_data_path());
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = test_app();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/web/events")
|
||||
.uri(paths::EVENTS)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -100,38 +43,24 @@ async fn events_without_session_redirects_to_login() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
let location = resp
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(location, "/web/login");
|
||||
assert_eq!(
|
||||
header_opt(&resp, header::LOCATION.as_str()),
|
||||
Some(paths::LOGIN)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_with_session_returns_sse_content_type() {
|
||||
let config = build_config(unique_data_path());
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "alice").await;
|
||||
let app = test_app();
|
||||
let cookie = common::login(&app, "alice", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/web/events")
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(get_with_cookie(paths::EVENTS, &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let ct = header_opt(&resp, header::CONTENT_TYPE.as_str()).unwrap_or("");
|
||||
assert!(
|
||||
ct.starts_with("text/event-stream"),
|
||||
"expected text/event-stream, got {ct:?}"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use doctate_server::config::{Config, User, WhisperUserSettings};
|
||||
use doctate_server::config::WhisperUserSettings;
|
||||
use doctate_server::transcribe;
|
||||
use doctate_server::transcribe::ffmpeg::remux_faststart;
|
||||
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
|
||||
@@ -13,6 +14,8 @@ use serde_json::json;
|
||||
use wiremock::matchers::{body_partial_json, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{TestConfig, test_user};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
@@ -257,25 +260,15 @@ async fn recovery_enqueues_only_pending_recordings() {
|
||||
|
||||
// -------------------- Worker: failure marker --------------------
|
||||
|
||||
fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
|
||||
Arc::new(Config {
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "k".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
whisper_url,
|
||||
whisper_timeout_seconds: 5,
|
||||
// Ollama URL is irrelevant — worker never reaches it in the failure path.
|
||||
ollama_url: "http://127.0.0.1:1".into(),
|
||||
ollama_model: "test".into(),
|
||||
..Config::test_default()
|
||||
})
|
||||
fn config_with_whisper(whisper_url: String) -> std::sync::Arc<doctate_server::config::Config> {
|
||||
// Ollama URL is irrelevant — worker never reaches it in the failure path.
|
||||
// Port 1 is effectively unreachable, matching the historical intent.
|
||||
TestConfig::new()
|
||||
.with_label("transcribe")
|
||||
.with_user(test_user("dr_test"))
|
||||
.with_whisper(whisper_url, 5)
|
||||
.with_ollama("http://127.0.0.1:1")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to
|
||||
@@ -299,7 +292,7 @@ async fn worker_renames_audio_to_failed_on_permanent_whisper_error() {
|
||||
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
||||
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
||||
|
||||
let config = test_config_with_whisper(server.uri());
|
||||
let config = config_with_whisper(server.uri());
|
||||
let (tx, rx) = transcribe::channel();
|
||||
tx.send(transcribe::TranscribeJob {
|
||||
audio_path: audio.clone(),
|
||||
@@ -310,8 +303,8 @@ async fn worker_renames_audio_to_failed_on_permanent_whisper_error() {
|
||||
drop(tx); // close channel so the worker loop exits after processing the job
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
transcribe::worker::run(
|
||||
rx,
|
||||
config,
|
||||
@@ -347,7 +340,7 @@ async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
|
||||
let audio = case_dir.join("2026-04-13T10-31-00Z.m4a");
|
||||
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
||||
|
||||
let config = test_config_with_whisper(server.uri());
|
||||
let config = config_with_whisper(server.uri());
|
||||
let (tx, rx) = transcribe::channel();
|
||||
tx.send(transcribe::TranscribeJob {
|
||||
audio_path: audio.clone(),
|
||||
@@ -358,8 +351,8 @@ async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
|
||||
drop(tx);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
transcribe::worker::run(
|
||||
rx,
|
||||
config,
|
||||
@@ -405,14 +398,14 @@ async fn transcribe_worker_normalizes_whisper_output() {
|
||||
|
||||
let vocab_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap();
|
||||
let vocab = std::sync::Arc::new(
|
||||
let vocab = Arc::new(
|
||||
doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(vocab.len(), 1);
|
||||
|
||||
let config = test_config_with_whisper(server.uri());
|
||||
let config = config_with_whisper(server.uri());
|
||||
let (tx, rx) = transcribe::channel();
|
||||
tx.send(transcribe::TranscribeJob {
|
||||
audio_path: audio.clone(),
|
||||
@@ -423,7 +416,7 @@ async fn transcribe_worker_normalizes_whisper_output() {
|
||||
drop(tx);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
transcribe::worker::run(
|
||||
rx,
|
||||
config,
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
//! terminating progress permanently. Only a manual admin reset un-failed
|
||||
//! the recording.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use doctate_server::events;
|
||||
use doctate_server::gazetteer::Gazetteer;
|
||||
use doctate_server::{
|
||||
@@ -26,6 +27,8 @@ use tempfile::tempdir;
|
||||
use wiremock::matchers::{method, path as wm_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use common::{TestConfig, test_user};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
@@ -64,21 +67,11 @@ async fn transient_whisper_failure_is_reenqueued_and_recovers() {
|
||||
// Config points at the mock Whisper, ollama intentionally unreachable
|
||||
// (the oneliner heal may fire in parallel; we only assert on
|
||||
// the transcript artefact, not on the oneliner).
|
||||
let mut cfg = Config::test_default();
|
||||
cfg.data_path = data.path().to_path_buf();
|
||||
cfg.whisper_url = whisper.uri();
|
||||
cfg.whisper_timeout_seconds = 5;
|
||||
cfg.users = vec![User {
|
||||
slug: slug.into(),
|
||||
api_key: "k".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}];
|
||||
let config = Arc::new(cfg);
|
||||
let config = TestConfig::new()
|
||||
.with_data_path(data.path().to_path_buf())
|
||||
.with_user(test_user(slug))
|
||||
.with_whisper(whisper.uri(), 5)
|
||||
.build();
|
||||
|
||||
let vocab = Arc::new(Gazetteer::empty());
|
||||
let events_tx = events::channel();
|
||||
|
||||
+64
-233
@@ -1,66 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use doctate_common::UPLOAD_PATH;
|
||||
use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker};
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "test-key-123".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
})
|
||||
use common::{
|
||||
TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str,
|
||||
multipart_upload_body, test_user,
|
||||
};
|
||||
|
||||
const TEST_KEY: &str = "key-dr_test";
|
||||
|
||||
fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
|
||||
TestConfig::new()
|
||||
.with_label("upload")
|
||||
.with_user(test_user("dr_test"))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Build a multipart body with the given fields.
|
||||
fn multipart_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
|
||||
let boundary = "----testboundary";
|
||||
let mut body = Vec::new();
|
||||
|
||||
// case_id field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
|
||||
body.extend_from_slice(case_id.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// recorded_at field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
|
||||
body.extend_from_slice(recorded_at.as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// audio field
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
|
||||
);
|
||||
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
|
||||
body.extend_from_slice(audio);
|
||||
body.extend_from_slice(b"\r\n");
|
||||
|
||||
// End boundary
|
||||
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
|
||||
(boundary.to_owned(), body)
|
||||
/// Wrap an `(content_type, body)` pair into the actual HTTP upload request.
|
||||
/// Keeps the multipart boundary and the audio-upload path in sync for
|
||||
/// every test in this file.
|
||||
fn upload_request(content_type: &str, body: Vec<u8>, api_key: Option<&str>) -> Request<Body> {
|
||||
let mut b = Request::builder()
|
||||
.method("POST")
|
||||
.uri(UPLOAD_PATH)
|
||||
.header("Content-Type", content_type);
|
||||
if let Some(k) = api_key {
|
||||
b = b.header("X-API-Key", k);
|
||||
}
|
||||
b.body(Body::from(body)).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -70,31 +42,17 @@ async fn upload_creates_new_case() {
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
|
||||
let (content_type, body) =
|
||||
multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
|
||||
let json = body_json(response).await;
|
||||
assert_eq!(json["case_id"], case_id);
|
||||
assert_eq!(json["status"], "received");
|
||||
|
||||
@@ -104,31 +62,17 @@ async fn upload_creates_new_case() {
|
||||
.join(case_id)
|
||||
.join("2026-04-13T10-30-00Z.m4a");
|
||||
assert!(file.exists(), "Audio file should exist at {file:?}");
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_invalid_case_id_returns_400() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
||||
let (content_type, body) =
|
||||
multipart_upload_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -137,27 +81,16 @@ async fn upload_invalid_case_id_returns_400() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_without_auth_returns_401() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
let (content_type, body) = multipart_upload_body(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"2026-04-13T10:30:00Z",
|
||||
b"audio",
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, None))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -166,28 +99,16 @@ async fn upload_without_auth_returns_401() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_empty_audio_returns_400() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let (boundary, body) = multipart_body(
|
||||
let (content_type, body) = multipart_upload_body(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"2026-04-13T10:30:00Z",
|
||||
b"", // empty audio
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&content_type, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -203,40 +124,18 @@ async fn upload_second_recording_same_case() {
|
||||
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// First upload
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
||||
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Second upload, same case, different timestamp
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
|
||||
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -245,9 +144,6 @@ async fn upload_second_recording_same_case() {
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
||||
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
/// Scenario A: a new upload to a *closed* case must silently remove
|
||||
@@ -262,21 +158,10 @@ async fn upload_reopens_closed_case() {
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// First upload — creates the case directory.
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
||||
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -297,40 +182,20 @@ async fn upload_reopens_closed_case() {
|
||||
let etag_before = {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/oneliners")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(get_with_api_key("/api/oneliners", TEST_KEY))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
resp.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.expect("server must set ETag")
|
||||
let t = header_str(&resp, "etag");
|
||||
assert!(!t.is_empty(), "server must set ETag");
|
||||
t
|
||||
};
|
||||
|
||||
// Second upload — must auto-reopen.
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
|
||||
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -348,15 +213,11 @@ async fn upload_reopens_closed_case() {
|
||||
// If-None-Match must return 200 (not 304) so clients who were
|
||||
// offline during the reopen notice it on their next poll.
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/oneliners")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header("If-None-Match", &etag_before)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(get_with_api_key_if_none_match(
|
||||
"/api/oneliners",
|
||||
TEST_KEY,
|
||||
&etag_before,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -364,15 +225,9 @@ async fn upload_reopens_closed_case() {
|
||||
StatusCode::OK,
|
||||
"auto-reopen must invalidate the pre-reopen ETag"
|
||||
);
|
||||
let etag_after = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.expect("server must set ETag");
|
||||
let etag_after = header_str(&resp, "etag");
|
||||
assert!(!etag_after.is_empty(), "server must set ETag");
|
||||
assert_ne!(etag_after, etag_before);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
/// Scenario B: a new upload to a case whose server-side directory has
|
||||
@@ -390,21 +245,10 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
let case_id = "880e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// First upload.
|
||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"original recording");
|
||||
let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"original recording");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -415,21 +259,10 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
assert!(!case_dir.exists());
|
||||
|
||||
// Second upload with the same client-generated UUID.
|
||||
let (boundary, body) =
|
||||
multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
|
||||
let (ct, body) =
|
||||
multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/upload")
|
||||
.header("X-API-Key", "test-key-123")
|
||||
.header(
|
||||
"Content-Type",
|
||||
format!("multipart/form-data; boundary={boundary}"),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(upload_request(&ct, body, Some(TEST_KEY)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
@@ -438,6 +271,4 @@ async fn upload_resurrects_hard_deleted_case() {
|
||||
assert!(case_dir.exists());
|
||||
assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists());
|
||||
assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
+26
-109
@@ -1,33 +1,16 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use common::{TestConfig, header_opt, test_user};
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-web-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "test-key".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
})
|
||||
fn test_config() -> std::sync::Arc<doctate_server::config::Config> {
|
||||
TestConfig::new()
|
||||
.with_label("web")
|
||||
.with_user(test_user("dr_test"))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -36,8 +19,7 @@ async fn web_audio_serves_file() {
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
|
||||
|
||||
let audio_bytes = b"fake audio content for test";
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
@@ -55,28 +37,15 @@ async fn web_audio_serves_file() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"audio/mp4"
|
||||
);
|
||||
assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4"));
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = common::body_bytes(response).await;
|
||||
assert_eq!(&bytes[..], audio_bytes);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_path_traversal_rejected() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -93,8 +62,7 @@ async fn web_audio_path_traversal_rejected() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_invalid_case_id_rejected() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
@@ -115,8 +83,7 @@ async fn web_audio_serves_range_as_206() {
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440001";
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
|
||||
|
||||
let audio_bytes: Vec<u8> = (0u8..=200u8).collect();
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
@@ -136,39 +103,14 @@ async fn web_audio_serves_range_as_206() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
format!("bytes 10-19/{}", audio_bytes.len())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"10"
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"bytes"
|
||||
header_opt(&response, "content-range"),
|
||||
Some(format!("bytes 10-19/{}", audio_bytes.len()).as_str())
|
||||
);
|
||||
assert_eq!(header_opt(&response, "content-length"), Some("10"));
|
||||
assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes"));
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = common::body_bytes(response).await;
|
||||
assert_eq!(&bytes[..], &audio_bytes[10..20]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -177,8 +119,7 @@ async fn web_audio_invalid_range_returns_416() {
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440002";
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
|
||||
|
||||
let audio_bytes = b"short";
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
@@ -198,16 +139,9 @@ async fn web_audio_invalid_range_returns_416() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
format!("bytes */{}", audio_bytes.len())
|
||||
header_opt(&response, "content-range"),
|
||||
Some(format!("bytes */{}", audio_bytes.len()).as_str())
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -216,8 +150,7 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440003";
|
||||
let case_dir = data_path.join("dr_test").join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
let case_dir = common::seed_case(&data_path, "dr_test", case_id);
|
||||
|
||||
let audio_bytes = b"full file content";
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
@@ -235,32 +168,16 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes"));
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"bytes"
|
||||
header_opt(&response, "content-length"),
|
||||
Some(audio_bytes.len().to_string().as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
audio_bytes.len().to_string()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_nonexistent_file_returns_404() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
let app = doctate_server::create_router(test_config());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
|
||||
Reference in New Issue
Block a user