Formatting

This commit is contained in:
2026-04-19 15:35:10 +02:00
parent 041f9015ca
commit 0d5c2f5888
42 changed files with 612 additions and 357 deletions
+15 -4
View File
@@ -84,7 +84,10 @@ pub async fn chat_once(
user_content: &str,
timeout: Duration,
) -> Result<String, LlmError> {
let url = format!("{}/v1/chat/completions", settings.base_url.trim_end_matches('/'));
let url = format!(
"{}/v1/chat/completions",
settings.base_url.trim_end_matches('/')
);
debug!(%url, model = settings.model, "calling llm chat completions");
let body = ChatRequest {
@@ -92,8 +95,14 @@ pub async fn chat_once(
temperature: settings.temperature,
stream: false,
messages: vec![
Message { role: "system", content: system_prompt },
Message { role: "user", content: user_content },
Message {
role: "system",
content: system_prompt,
},
Message {
role: "user",
content: user_content,
},
],
};
@@ -107,7 +116,9 @@ pub async fn chat_once(
if !status.is_success() {
// Body deliberately dropped — see LlmError docstring.
drop(response);
return Err(LlmError::Status { status: status.as_u16() });
return Err(LlmError::Status {
status: status.as_u16(),
});
}
let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?;
+1 -1
View File
@@ -2,7 +2,7 @@ use std::path::Path;
use tracing::{info, warn};
use super::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use super::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
/// Walk `data_path/*/` and enqueue every pending analysis for every user.
/// Intended to run once at startup; the same primitive backs the per-user
+1 -1
View File
@@ -10,7 +10,7 @@
//! Pipeline:
//! raw markdown → html-escape → ==X== → <mark>X</mark> → pulldown-cmark → html
use pulldown_cmark::{html, Options, Parser};
use pulldown_cmark::{Options, Parser, html};
/// Render analysis Markdown to HTML. Safe to inject into an Askama template
/// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped
+6 -6
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tracing::{error, info, warn};
use super::{llm, prompt, AnalysisInput, AnalyzeReceiver, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, llm, prompt};
use crate::config::Config;
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, WorkerBusy};
@@ -20,10 +20,7 @@ pub async fn run(
worker_busy: WorkerBusy,
vocab: Arc<Gazetteer>,
) {
info!(
vocab_entries = vocab.len(),
"Analyze worker started"
);
info!(vocab_entries = vocab.len(), "Analyze worker started");
let timeout = Duration::from_secs(config.llm_timeout_seconds);
while let Some(job) = rx.recv().await {
@@ -144,7 +141,10 @@ async fn process(
/// state machine simply sees the job as still pending and retries.
async fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension({
let mut ext = path.extension().map(|s| s.to_os_string()).unwrap_or_default();
let mut ext = path
.extension()
.map(|s| s.to_os_string())
.unwrap_or_default();
ext.push(".tmp");
ext
});
+1 -4
View File
@@ -48,10 +48,7 @@ where
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let slug = config
.api_keys
.get(api_key)
.ok_or(AppError::Unauthorized)?;
let slug = config.api_keys.get(api_key).ok_or(AppError::Unauthorized)?;
let user = config
.users
+11 -9
View File
@@ -35,7 +35,10 @@ fn main() -> ExitCode {
Ok(Mode::PatchFile { slug, file }) => match read_password_twice() {
Ok(pw) => match patch_users_toml(&file, &slug, &hash(&pw)) {
Ok(()) => {
eprintln!("Updated web_password for slug \"{slug}\" in {}", file.display());
eprintln!(
"Updated web_password for slug \"{slug}\" in {}",
file.display()
);
ExitCode::SUCCESS
}
Err(e) => {
@@ -131,11 +134,8 @@ fn read_password_twice() -> Result<String, String> {
/// Patch `web_password` for the `[[user]]` entry whose `slug` matches.
/// Preserves comments and formatting via `toml_edit`. Writes atomically.
fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("read: {e}"))?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| format!("parse: {e}"))?;
let content = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e| format!("parse: {e}"))?;
let users = doc
.get_mut("user")
@@ -158,8 +158,7 @@ fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), Strin
// Atomic write: tmp file in same dir, then rename.
let tmp = path.with_extension("toml.tmp");
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| format!("create tmp: {e}"))?;
let mut f = std::fs::File::create(&tmp).map_err(|e| format!("create tmp: {e}"))?;
f.write_all(doc.to_string().as_bytes())
.map_err(|e| format!("write tmp: {e}"))?;
f.sync_all().map_err(|e| format!("sync tmp: {e}"))?;
@@ -203,7 +202,10 @@ role = "doctor"
patch_users_toml(&p, "dr_a", "$2b$12$NEW").unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains(r#"web_password = "$2b$12$NEW""#), "got: {out}");
assert!(out.contains(r#"web_password = "$2b$12$KEEP""#), "dr_b changed: {out}");
assert!(
out.contains(r#"web_password = "$2b$12$KEEP""#),
"dr_b changed: {out}"
);
assert!(out.contains("# header comment"), "comment lost: {out}");
std::fs::remove_file(&p).ok();
}
+2 -2
View File
@@ -50,8 +50,8 @@ where
let Path(s) = Path::<String>::from_request_parts(parts, state)
.await
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
let id = CaseId::from_str(&s)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
let id =
CaseId::from_str(&s).map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
Ok(CaseIdPath(id))
}
}
+1 -2
View File
@@ -216,8 +216,7 @@ pub fn validate_and_index_users(users: &[User]) -> Result<HashMap<String, String
/// Read a required environment variable. Panics with a clear message if missing.
fn required_env(name: &str) -> String {
std::env::var(name)
.unwrap_or_else(|_| panic!("Missing required environment variable: {name}"))
std::env::var(name).unwrap_or_else(|_| panic!("Missing required environment variable: {name}"))
}
/// Read a required environment variable and parse it. Panics if missing or unparseable.
+1 -5
View File
@@ -495,10 +495,7 @@ mod tests {
#[test]
fn dict_veto_allows_nonword_rewrite() {
let g = from_entries(&["Cerebrum"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
assert_eq!(
g.replace("Blutung im Zerebrum."),
"Blutung im Cerebrum."
);
assert_eq!(g.replace("Blutung im Zerebrum."), "Blutung im Cerebrum.");
}
/// The exact-match short-circuit runs *before* any dict lookup.
@@ -552,5 +549,4 @@ mod tests {
"inflected form must be recognized via affix expansion"
);
}
}
+2 -2
View File
@@ -10,11 +10,11 @@ pub mod routes;
pub mod transcribe;
pub mod web_session;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::FromRef;
use axum::Router;
use axum::extract::FromRef;
use analyze::AnalyzeSender;
use config::Config;
+6 -8
View File
@@ -4,15 +4,15 @@ use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use doctate_server::AppState;
use doctate_server::analyze;
use doctate_server::config::Config;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::transcribe;
use doctate_server::AppState;
#[tokio::main]
async fn main() {
@@ -20,8 +20,8 @@ async fn main() {
let config = Arc::new(Config::from_env());
// Logging: stdout + daily rotating log files.
let env_filter = EnvFilter::try_new(&config.log_level)
.unwrap_or_else(|_| EnvFilter::new("info"));
let env_filter =
EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let file_appender = tracing_appender::rolling::daily(&config.log_path, "recorder.log");
let (file_writer, _guard) = tracing_appender::non_blocking(file_appender);
@@ -131,10 +131,8 @@ async fn main() {
transcribe::recovery::scan_and_enqueue(&data_path, &tx).await;
// Then catch up oneliners for cases that crashed between
// transcript-write and oneliner-write in a previous run.
transcribe::recovery::regenerate_missing_oneliners(
&data_path, &client, &cfg, &vocab,
)
.await;
transcribe::recovery::regenerate_missing_oneliners(&data_path, &client, &cfg, &vocab)
.await;
});
}
+1 -4
View File
@@ -41,10 +41,7 @@ pub async fn read_delete_marker(case_dir: &Path) -> Option<DeleteMarker> {
}
/// Write the `.deleted` marker as JSON. Overwrites any existing marker.
pub async fn write_delete_marker(
case_dir: &Path,
marker: &DeleteMarker,
) -> std::io::Result<()> {
pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std::io::Result<()> {
let bytes = serde_json::to_vec(marker)
.map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?;
tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await
+9 -5
View File
@@ -4,16 +4,18 @@ use axum::extract::State;
use axum::response::Redirect;
use axum_extra::extract::Form;
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::{info, warn};
use crate::analyze::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{write_delete_marker, DeleteMarker};
use crate::routes::case_actions::{build_analysis_input, reset_case_artefacts, write_input_create_new};
use crate::paths::{DeleteMarker, write_delete_marker};
use crate::routes::case_actions::{
build_analysis_input, reset_case_artefacts, write_input_create_new,
};
use crate::routes::user_web::locate_case;
#[derive(Debug, Deserialize)]
@@ -37,7 +39,9 @@ pub async fn handle_bulk_action(
let user_root = config.data_path.join(&user.slug);
match form.action.as_str() {
"analyze" => bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await,
"analyze" => {
bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await
}
"delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await,
"reset" => {
if !user.is_admin() {
+9 -12
View File
@@ -5,21 +5,21 @@ use std::time::SystemTime;
use askama::Template;
use axum::extract::State;
use axum::response::{Html, Redirect};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
use crate::analyze::{
AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE,
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE, RecordingInput,
};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER};
use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
use crate::routes::user_web::locate_case_or_404;
#[derive(Template)]
@@ -157,17 +157,13 @@ fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
/// transkribiert" body. Blank transcripts are silently skipped — they
/// represent recordings the transcriber classified as silence and must
/// not be pushed to the LLM.
async fn read_recordings(
m4as: &[(PathBuf, SystemTime)],
) -> Result<Vec<RecordingInput>, AppError> {
async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<RecordingInput>, AppError> {
let mut recordings: Vec<RecordingInput> = Vec::new();
for (m4a_path, _) in m4as {
let transcript_path = m4a_path.with_extension("transcript.txt");
let text = tokio::fs::read_to_string(&transcript_path)
.await
.map_err(|_| {
AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into())
})?;
.map_err(|_| AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()))?;
if text.trim().is_empty() {
continue;
@@ -240,7 +236,9 @@ pub(crate) async fn write_input_create_new(
/// Read `case_dir/document.md`. Returns `None` if no document exists.
pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok()
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE))
.await
.ok()
}
/// Reset a case to its raw audio: delete all derived artefacts
@@ -388,8 +386,7 @@ async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
None => Some(candidate),
// Order: deleted_at desc, then batch desc as tie-breaker.
Some(ref cur)
if candidate.1 > cur.1
|| (candidate.1 == cur.1 && candidate.0 > cur.0) =>
if candidate.1 > cur.1 || (candidate.1 == cur.1 && candidate.0 > cur.0) =>
{
Some(candidate)
}
+1 -1
View File
@@ -1,5 +1,5 @@
use axum::Json;
use serde_json::{json, Value};
use serde_json::{Value, json};
use crate::auth::AuthenticatedUser;
use crate::error::AppError;
+2 -2
View File
@@ -1,8 +1,8 @@
use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use axum::extract::State;
use serde_json::{Value, json};
use crate::config::Config;
+6 -3
View File
@@ -2,12 +2,12 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use askama::Template;
use axum::Form;
use axum::extract::State;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Form;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use rand::distributions::Alphanumeric;
use rand::Rng;
use rand::distributions::Alphanumeric;
use serde::Deserialize;
use tracing::{info, warn};
@@ -49,7 +49,10 @@ pub async fn handle_login_submit(
if !valid {
if user.is_none() {
// Dummy verify so timing is similar regardless of slug existence.
let _ = bcrypt::verify(&form.password, "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii");
let _ = bcrypt::verify(
&form.password,
"$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii",
);
}
warn!(slug = %form.slug, "login failed");
return render_login(Some("Login fehlgeschlagen")).map(IntoResponse::into_response);
+5 -2
View File
@@ -8,8 +8,8 @@ mod upload;
pub(crate) mod user_web;
pub(crate) mod web;
use axum::routing::{get, post};
use axum::Router;
use axum::routing::{get, post};
use crate::AppState;
@@ -19,7 +19,10 @@ pub fn api_router() -> Router<AppState> {
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
.route("/api/oneliners", get(oneliners::handle_oneliners))
.route("/web/login", get(login::handle_login_page).post(login::handle_login_submit))
.route(
"/web/login",
get(login::handle_login_page).post(login::handle_login_submit),
)
.route("/web/logout", post(login::handle_logout))
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
+3 -3
View File
@@ -20,10 +20,10 @@
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use axum::extract::Query;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use axum::extract::Query;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
+1 -1
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use axum::extract::{Multipart, State};
use axum::Json;
use axum::extract::{Multipart, State};
use doctate_common::timestamp::recorded_at_to_filename_stem;
use tokio::sync::mpsc::error::TrySendError;
use tracing::{info, warn};
+6 -8
View File
@@ -1,22 +1,22 @@
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::State;
use axum::response::Html;
use tracing::{info, warn};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
use crate::analyze::{recovery as analyze_recovery, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::PipelineState;
use crate::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, recovery as analyze_recovery};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::{CaseId, CaseIdPath};
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{scan_recordings, RecordingView};
use crate::routes::web::{RecordingView, scan_recordings};
use crate::transcribe::recovery as transcribe_recovery;
use crate::PipelineState;
struct UserCaseView {
case_id: String,
@@ -366,9 +366,7 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
/// a valid UUID. Returns the (case_id-string, absolute path) tuple so
/// the caller does not need to recompute either. Non-UUID names are
/// logged at WARN level; other rejections are silent (common case).
async fn filter_valid_case_dir(
entry: tokio::fs::DirEntry,
) -> Option<(String, std::path::PathBuf)> {
async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> {
let case_id = entry.file_name().into_string().ok()?;
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
+2 -8
View File
@@ -36,9 +36,7 @@ struct CasesTemplate {
cases: Vec<CaseView>,
}
pub async fn handle_case_list(
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
pub async fn handle_case_list(State(config): State<Arc<Config>>) -> Result<Html<String>, AppError> {
let cases = scan_cases(&config.data_path).await;
let template = CasesTemplate { cases };
template
@@ -82,11 +80,7 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> {
fn validate_filename(filename: &str) -> Result<(), AppError> {
let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
if !ok_suffix
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
{
if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(AppError::BadRequest("Invalid filename".into()));
}
Ok(())
+13 -8
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use tracing::{error, info, warn};
use super::{ffmpeg, ollama, whisper, TranscribeReceiver};
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
use crate::config::{Config, WhisperUserSettings};
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, WorkerBusy};
@@ -53,7 +53,15 @@ pub async fn run(
}
};
let text = match whisper::transcribe(&client, &config.whisper_url, remuxed.path(), timeout, &settings).await {
let text = match whisper::transcribe(
&client,
&config.whisper_url,
remuxed.path(),
timeout,
&settings,
)
.await
{
Ok(t) => t,
Err(e) => {
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
@@ -258,12 +266,9 @@ mod tests {
#[tokio::test]
async fn all_transcripts_joined_skips_empty_recordings() {
let dir = tempdir().unwrap();
tokio::fs::write(
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
"",
)
.await
.unwrap();
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), "")
.await
.unwrap();
tokio::fs::write(
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
"non-empty content",
+101 -39
View File
@@ -4,10 +4,10 @@ use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::http::{Request, StatusCode, header};
use doctate_server::analyze;
use doctate_server::config::{Config, User};
use serde_json::{json, Value};
use serde_json::{Value, json};
use tower::util::ServiceExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -165,7 +165,10 @@ async fn analyze_foreign_case_returns_404() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(foreign_case, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(foreign_case, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
@@ -175,7 +178,10 @@ async fn analyze_invalid_uuid_returns_400() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request("not-a-uuid", &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request("not-a-uuid", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -189,7 +195,10 @@ async fn analyze_case_without_recordings_returns_400() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -204,7 +213,10 @@ async fn analyze_case_with_missing_transcript_returns_400() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -218,7 +230,10 @@ async fn analyze_case_without_llm_returns_503() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
@@ -232,15 +247,26 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("Patient klagt über Knie."));
seed_recording(&case_dir, "10-05-00", Some("Korrektur: links, nicht rechts."));
seed_recording(
&case_dir,
"10-05-00",
Some("Korrektur: links, nicht rechts."),
);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(),
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases"
);
@@ -267,10 +293,17 @@ async fn analyze_case_second_time_returns_409() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let first = app.clone().oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let first = app
.clone()
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(first.status(), StatusCode::SEE_OTHER);
let second = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let second = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
}
@@ -286,7 +319,10 @@ async fn analyze_case_skips_blank_transcript_from_recordings() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let raw = std::fs::read_to_string(case_dir.join("analysis_input.json")).unwrap();
@@ -515,7 +551,10 @@ async fn recovery_skips_completed_analysis() {
let (tx, mut rx) = analyze::channel();
analyze::recovery::scan_and_enqueue(&tmp, &tx).await;
assert!(rx.try_recv().is_err(), "completed analysis must not re-enqueue");
assert!(
rx.try_recv().is_err(),
"completed analysis must not re-enqueue"
);
}
// ---------------------------------------------------------------------
@@ -543,7 +582,9 @@ async fn document_view_reads_document() {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("der Inhalt"), "expected content in body");
assert!(body_str.contains("Dokument"));
@@ -565,7 +606,10 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap();
let resp = app
.oneshot(analyze_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert!(
@@ -574,11 +618,11 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() {
);
let input_path = case_dir.join("analysis_input.json");
assert!(input_path.exists(), "analysis_input.json missing");
let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap();
let parsed: Value =
serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap();
assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2);
}
// ---------------------------------------------------------------------
// Soft-delete + Undo
// ---------------------------------------------------------------------
@@ -596,7 +640,11 @@ async fn delete_writes_marker_and_redirects() {
let resp = app.oneshot(delete_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(),
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/cases"
);
let marker = case_dir.join(".deleted");
@@ -604,7 +652,10 @@ async fn delete_writes_marker_and_redirects() {
let raw = std::fs::read_to_string(&marker).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
assert!(parsed["batch"].is_string(), "batch must be a UUID string");
assert!(parsed["deleted_at"].is_string(), "deleted_at must be a string");
assert!(
parsed["deleted_at"].is_string(),
"deleted_at must be a string"
);
}
#[tokio::test]
@@ -617,7 +668,11 @@ async fn deleted_case_returns_404_on_detail() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.clone().oneshot(delete_request(case_id, &cookie)).await.unwrap();
let resp = app
.clone()
.oneshot(delete_request(case_id, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let resp = app
@@ -647,12 +702,20 @@ async fn undo_delete_restores_latest_batch_only() {
let cookie = login(app.clone(), "dr_a").await;
// Two separate delete clicks → two distinct batches.
let _ = app.clone().oneshot(delete_request(case_a, &cookie)).await.unwrap();
let _ = app
.clone()
.oneshot(delete_request(case_a, &cookie))
.await
.unwrap();
// Batch IDs derive from `now_rfc3339()`, which rounds to whole
// seconds — so the sleep must cross a second boundary for the two
// deletes to land in distinct batches.
tokio::time::sleep(Duration::from_millis(1100)).await;
let _ = app.clone().oneshot(delete_request(case_b, &cookie)).await.unwrap();
let _ = app
.clone()
.oneshot(delete_request(case_b, &cookie))
.await
.unwrap();
let undo = app
.clone()
@@ -669,7 +732,10 @@ async fn undo_delete_restores_latest_batch_only() {
assert_eq!(undo.status(), StatusCode::SEE_OTHER);
// case_b (latest delete) restored, case_a still deleted.
assert!(!dir_b.join(".deleted").exists(), "case_b marker should be gone");
assert!(
!dir_b.join(".deleted").exists(),
"case_b marker should be gone"
);
assert!(dir_a.join(".deleted").exists(), "case_a marker must remain");
}
@@ -701,9 +767,14 @@ async fn bulk_delete_shares_one_batch_uuid() {
.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let m_a: Value = serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap();
let m_b: Value = serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap();
assert_eq!(m_a["batch"], m_b["batch"], "bulk-delete must share batch UUID");
let m_a: Value =
serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap();
let m_b: Value =
serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap();
assert_eq!(
m_a["batch"], m_b["batch"],
"bulk-delete must share batch UUID"
);
}
#[tokio::test]
@@ -814,15 +885,9 @@ async fn reset_case_clears_derived_and_unfails_audio() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(reset_request(case_id, &cookie))
.await
.unwrap();
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
"/web/cases"
);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/cases");
// Audio preserved, .failed renamed to .m4a.
assert!(dir.join("2026-04-15T09-00-00Z.m4a").exists());
@@ -848,10 +913,7 @@ async fn reset_case_requires_admin() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(reset_request(case_id, &cookie))
.await
.unwrap();
let resp = app.oneshot(reset_request(case_id, &cookie)).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// Nothing touched.
+21 -10
View File
@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::http::{Request, StatusCode, header};
use doctate_common::constants::API_KEY_HEADER;
use doctate_common::oneliners::ONELINERS_PATH;
use doctate_server::config::{Config, User};
@@ -32,8 +32,10 @@ fn test_config_with_users(users: Vec<User>) -> Arc<Config> {
std::process::id(),
uuid::Uuid::new_v4()
));
let api_keys: HashMap<String, String> =
users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect();
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
@@ -112,7 +114,11 @@ async fn delete_bumps_watermark() {
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
// First GET: capture baseline ETag (watermark empty → seeded on 0).
@@ -162,7 +168,11 @@ async fn undo_delete_bumps_watermark() {
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
// Delete first so undo has something to restore; grab the post-delete ETag.
@@ -217,7 +227,11 @@ async fn bulk_delete_bumps_watermark() {
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).expect("login should set cookie");
let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await;
@@ -232,10 +246,7 @@ async fn bulk_delete_bumps_watermark() {
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie)
.header(
header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
+62 -23
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::http::{Request, StatusCode, header};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
@@ -23,8 +23,10 @@ fn test_config_with_users(users: Vec<User>) -> Arc<Config> {
std::process::id(),
uuid::Uuid::new_v4()
));
let api_keys: HashMap<String, String> =
users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect();
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
@@ -69,14 +71,27 @@ async fn login_success_sets_session_cookie_and_redirects() {
let app = doctate_server::create_router(config);
let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16(), "got {:?}", resp.status());
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER.as_u16(),
"got {:?}",
resp.status()
);
let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap();
let loc = resp
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap();
assert_eq!(loc, "/web/cases");
let cookie = extract_session_cookie(&resp).expect("no session cookie");
assert!(cookie.starts_with("session="));
assert!(cookie.len() > "session=".len() + 20, "token looks too short: {cookie}");
assert!(
cookie.len() > "session=".len() + 20,
"token looks too short: {cookie}"
);
}
#[tokio::test]
@@ -96,7 +111,10 @@ async fn login_unknown_user_shows_error() {
let config = test_config_with_users(vec![make_user("dr_a", "secret")]);
let app = doctate_server::create_router(config);
let resp = app.oneshot(login_request("ghost", "anything")).await.unwrap();
let resp = app
.oneshot(login_request("ghost", "anything"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(extract_session_cookie(&resp).is_none());
}
@@ -107,25 +125,35 @@ async fn cases_without_cookie_redirects_to_login() {
let app = doctate_server::create_router(config);
let resp = app
.oneshot(Request::builder().uri("/web/cases").body(Body::empty()).unwrap())
.oneshot(
Request::builder()
.uri("/web/cases")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(),
resp.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
"/web/login"
);
}
#[tokio::test]
async fn cases_with_valid_cookie_shows_only_own_cases() {
let config = test_config_with_users(vec![
make_user("dr_a", "s"),
make_user("dr_b", "s"),
]);
let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]);
// Seed fixtures: dr_a has an open case, dr_b has one too.
let case_a = config.data_path.join("dr_a/11111111-1111-1111-1111-111111111111");
let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222");
let case_a = config
.data_path
.join("dr_a/11111111-1111-1111-1111-111111111111");
let case_b = config
.data_path
.join("dr_b/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_a).unwrap();
std::fs::create_dir_all(&case_b).unwrap();
std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
@@ -134,7 +162,11 @@ async fn cases_with_valid_cookie_shows_only_own_cases() {
let app = doctate_server::create_router(config);
// Log in as dr_a.
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).unwrap();
let resp = app
@@ -155,17 +187,20 @@ async fn cases_with_valid_cookie_shows_only_own_cases() {
#[tokio::test]
async fn case_detail_of_foreign_case_returns_404() {
let config = test_config_with_users(vec![
make_user("dr_a", "s"),
make_user("dr_b", "s"),
]);
let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222");
let config = test_config_with_users(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]);
let case_b = config
.data_path
.join("dr_b/22222222-2222-2222-2222-222222222222");
std::fs::create_dir_all(&case_b).unwrap();
std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap();
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).unwrap();
let resp = app
@@ -186,7 +221,11 @@ async fn logout_clears_session() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let app = doctate_server::create_router(config);
let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap();
let login = app
.clone()
.oneshot(login_request("dr_a", "s"))
.await
.unwrap();
let cookie = extract_session_cookie(&login).unwrap();
let logout = app
+21 -18
View File
@@ -82,9 +82,7 @@ fn get_with_if_none_match(uri: &str, etag: &str) -> Request<Body> {
.unwrap()
}
async fn body_json(
response: axum::http::Response<axum::body::Body>,
) -> serde_json::Value {
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();
@@ -142,7 +140,13 @@ async fn empty_user_dir_returns_200_empty_list() {
async fn recent_case_with_oneliner_shows_up() {
let (config, dp) = test_config();
let case_id = "550e8400-e29b-41d4-a716-000000000001";
seed_case(&dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus")).await;
seed_case(
&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();
@@ -161,7 +165,13 @@ async fn recent_case_with_oneliner_shows_up() {
#[tokio::test]
async fn case_without_oneliner_shows_null() {
let (config, dp) = test_config();
seed_case(&dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None).await;
seed_case(
&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;
@@ -202,10 +212,7 @@ async fn case_outside_default_but_inside_custom_window_included() {
.await;
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(),
)
.await;
let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await;
assert_eq!(body["window_hours"], 48);
assert_eq!(body["oneliners"].as_array().unwrap().len(), 1);
@@ -218,7 +225,9 @@ async fn hours_clamped_to_max() {
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=999999")).await.unwrap(),
app.oneshot(get("/api/oneliners?hours=999999"))
.await
.unwrap(),
)
.await;
assert_eq!(body["window_hours"], 168);
@@ -284,10 +293,7 @@ async fn etag_differs_between_different_hours() {
.oneshot(get("/api/oneliners?hours=1"))
.await
.unwrap();
let r16 = app
.oneshot(get("/api/oneliners?hours=16"))
.await
.unwrap();
let r16 = app.oneshot(get("/api/oneliners?hours=16")).await.unwrap();
let etag1 = header_str(&r1, "etag");
let etag16 = header_str(&r16, "etag");
@@ -407,10 +413,7 @@ async fn sorts_by_last_recording_not_created() {
.await;
let app = doctate_server::create_router(config);
let body = body_json(
app.oneshot(get("/api/oneliners?hours=48")).await.unwrap(),
)
.await;
let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).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");
+83 -28
View File
@@ -6,9 +6,9 @@ use std::sync::Arc;
use doctate_server::config::{Config, User, WhisperUserSettings};
use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError};
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
use doctate_server::transcribe::recovery::scan_and_enqueue;
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
use doctate_server::transcribe::whisper::{WhisperError, transcribe};
use serde_json::json;
use wiremock::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -24,9 +24,7 @@ async fn ffmpeg_remux_produces_valid_output() {
let input = fixture("sample.m4a");
assert!(input.exists(), "fixture missing: {}", input.display());
let output = remux_faststart(&input)
.await
.expect("remux failed");
let output = remux_faststart(&input).await.expect("remux failed");
let meta = std::fs::metadata(output.path()).expect("output missing");
assert!(meta.len() > 0, "output file is empty");
@@ -45,7 +43,10 @@ async fn ffmpeg_remux_produces_valid_output() {
.expect("ffprobe spawn failed");
assert!(probe.status.success(), "ffprobe failed");
let fmt = String::from_utf8_lossy(&probe.stdout);
assert!(fmt.contains("mp4") || fmt.contains("m4a"), "unexpected format: {fmt}");
assert!(
fmt.contains("mp4") || fmt.contains("m4a"),
"unexpected format: {fmt}"
);
}
#[tokio::test]
@@ -134,7 +135,10 @@ async fn whisper_client_times_out() {
.await
.expect_err("expected timeout");
assert!(matches!(err, WhisperError::Http(_)), "expected Http error, got {err:?}");
assert!(
matches!(err, WhisperError::Http(_)),
"expected Http error, got {err:?}"
);
}
#[tokio::test]
@@ -170,8 +174,14 @@ async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
let received = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&received[0].body);
assert!(body.contains("name=\"hotwords\""), "hotwords part missing");
assert!(body.contains("HOCM Valsalva"), "hotwords value missing: {body}");
assert!(body.contains("name=\"initial_prompt\""), "initial_prompt part missing");
assert!(
body.contains("HOCM Valsalva"),
"hotwords value missing: {body}"
);
assert!(
body.contains("name=\"initial_prompt\""),
"initial_prompt part missing"
);
assert!(body.contains("Kardiologie"), "initial_prompt value missing");
}
@@ -203,8 +213,14 @@ async fn whisper_client_omits_empty_optional_fields() {
// Inspect the captured request to confirm absence of the optional parts.
let received = server.received_requests().await.unwrap();
let body = String::from_utf8_lossy(&received[0].body);
assert!(!body.contains("name=\"hotwords\""), "hotwords part leaked: {body}");
assert!(!body.contains("name=\"initial_prompt\""), "initial_prompt part leaked: {body}");
assert!(
!body.contains("name=\"hotwords\""),
"hotwords part leaked: {body}"
);
assert!(
!body.contains("name=\"initial_prompt\""),
"initial_prompt part leaked: {body}"
);
}
#[tokio::test]
@@ -363,15 +379,24 @@ async fn ollama_oneliner_parses_chat_response() {
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")))
.respond_with(
ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")),
)
.expect(1)
.mount(&server)
.await;
let client = reqwest::Client::new();
let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5))
.await
.expect("call failed");
let out = generate_oneliner(
&client,
&server.uri(),
MODEL,
0,
"irrelevant",
Duration::from_secs(5),
)
.await
.expect("call failed");
assert_eq!(out, "HOCM mit Septum-Hypertrophie");
}
@@ -381,14 +406,23 @@ async fn ollama_oneliner_normalizes_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges")))
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(
"\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges",
)))
.mount(&server)
.await;
let client = reqwest::Client::new();
let out = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.unwrap();
let out = generate_oneliner(
&client,
&server.uri(),
MODEL,
0,
"x",
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(out, "Kniegelenk re., V.a. Meniskus");
}
@@ -402,9 +436,16 @@ async fn ollama_oneliner_returns_status_on_500() {
.await;
let client = reqwest::Client::new();
let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.expect_err("expected error");
let err = generate_oneliner(
&client,
&server.uri(),
MODEL,
0,
"x",
Duration::from_secs(5),
)
.await
.expect_err("expected error");
match err {
OllamaError::Status { status, body } => {
assert_eq!(status, 500);
@@ -430,9 +471,16 @@ async fn ollama_oneliner_sends_keep_alive_and_model() {
.await;
let client = reqwest::Client::new();
let _ = generate_oneliner(&client, &server.uri(), MODEL, 0, "irrelevant", Duration::from_secs(5))
.await
.expect("call failed");
let _ = generate_oneliner(
&client,
&server.uri(),
MODEL,
0,
"irrelevant",
Duration::from_secs(5),
)
.await
.expect("call failed");
// Mock::expect(1) + server drop verifies the matcher hit exactly once.
}
@@ -446,8 +494,15 @@ async fn ollama_oneliner_parse_error_on_missing_content() {
.await;
let client = reqwest::Client::new();
let err = generate_oneliner(&client, &server.uri(), MODEL, 0, "x", Duration::from_secs(5))
.await
.expect_err("expected parse error");
let err = generate_oneliner(
&client,
&server.uri(),
MODEL,
0,
"x",
Duration::from_secs(5),
)
.await
.expect_err("expected parse error");
assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}");
}
+4 -8
View File
@@ -6,7 +6,7 @@ use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
use doctate_server::paths::{write_delete_marker, DeleteMarker, DELETE_MARKER};
use doctate_server::paths::{DELETE_MARKER, DeleteMarker, write_delete_marker};
fn test_config() -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
@@ -29,11 +29,7 @@ fn test_config() -> Arc<Config> {
}
/// Build a multipart body with the given fields.
fn multipart_body(
case_id: &str,
recorded_at: &str,
audio: &[u8],
) -> (String, Vec<u8>) {
fn multipart_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
let boundary = "----testboundary";
let mut body = Vec::new();
@@ -417,7 +413,8 @@ 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 (boundary, body) =
multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording");
let response = app
.oneshot(
Request::builder()
@@ -442,4 +439,3 @@ async fn upload_resurrects_hard_deleted_case() {
let _ = std::fs::remove_dir_all(&data_path);
}
+4 -22
View File
@@ -40,12 +40,7 @@ async fn web_index_empty() {
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/")
.body(Body::empty())
.unwrap(),
)
.oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap())
.await
.unwrap();
@@ -65,19 +60,11 @@ async fn web_index_shows_cases() {
let open_case = data_path.join("dr_test").join(case1);
std::fs::create_dir_all(&open_case).unwrap();
std::fs::write(
open_case.join("2026-04-13T10-30-00Z.m4a"),
b"fake audio 1",
)
.unwrap();
std::fs::write(open_case.join("2026-04-13T10-30-00Z.m4a"), b"fake audio 1").unwrap();
let done_case = data_path.join("dr_test").join(case2);
std::fs::create_dir_all(&done_case).unwrap();
std::fs::write(
done_case.join("2026-04-12T09-00-00Z.m4a"),
b"fake audio 2",
)
.unwrap();
std::fs::write(done_case.join("2026-04-12T09-00-00Z.m4a"), b"fake audio 2").unwrap();
std::fs::write(
done_case.join("2026-04-12T09-00-00Z.transcript.txt"),
"Herzkatheter ohne Befund.",
@@ -87,12 +74,7 @@ async fn web_index_shows_cases() {
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/")
.body(Body::empty())
.unwrap(),
)
.oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap())
.await
.unwrap();