Formatting
This commit is contained in:
@@ -14,9 +14,9 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_client_core::{
|
||||
pick_footer_status, run_startup_cleanup, write_sidecar, CaseMarker, CaseStore, FooterStatus,
|
||||
CaseMarker, CaseStore, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, FooterStatus,
|
||||
PendingUpload, ServerSync, SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
|
||||
DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION,
|
||||
pick_footer_status, run_startup_cleanup, write_sidecar,
|
||||
};
|
||||
use doctate_common::timestamp::{
|
||||
extract_hhmm, now_rfc3339, parse_rfc3339, recorded_at_to_filename_stem,
|
||||
@@ -157,10 +157,7 @@ impl DoctateApp {
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.oneliner_poll_interval_seconds),
|
||||
oneliner_window_hours: self
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.oneliner_window_hours),
|
||||
oneliner_window_hours: self.config.as_ref().and_then(|c| c.oneliner_window_hours),
|
||||
};
|
||||
if let Err(e) = crate::config::save(&cfg) {
|
||||
error!(error = %e, "config save failed");
|
||||
@@ -415,9 +412,7 @@ impl DoctateApp {
|
||||
match &self.state {
|
||||
AppState::NotConfigured => {
|
||||
ui.label("Server URL:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0),
|
||||
);
|
||||
ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0));
|
||||
ui.add_space(4.0);
|
||||
ui.label("API Key:");
|
||||
ui.add(
|
||||
@@ -463,11 +458,7 @@ impl DoctateApp {
|
||||
|
||||
fn render_case_list(&self, ui: &mut egui::Ui) -> Option<UiAction> {
|
||||
let snapshot = self.snapshot_rx.borrow().clone();
|
||||
let window = self
|
||||
.config
|
||||
.as_ref()
|
||||
.map(Config::window_hours)
|
||||
.unwrap_or(72);
|
||||
let window = self.config.as_ref().map(Config::window_hours).unwrap_or(72);
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64);
|
||||
|
||||
let visible: Vec<&CaseMarker> = snapshot
|
||||
|
||||
@@ -39,6 +39,9 @@ mod tests {
|
||||
// Sanity check on the ProjectDirs wiring — can't assert the
|
||||
// exact path (varies per host), but the filename is deterministic.
|
||||
let path = default_config_path().expect("config path available in test env");
|
||||
assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("client.toml"));
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|s| s.to_str()),
|
||||
Some("client.toml")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,9 @@ async fn finalize(
|
||||
// non-empty. Real corruption will surface server-side at decode.
|
||||
let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await {
|
||||
Ok(Ok(status)) => match tokio::fs::metadata(&output_path).await {
|
||||
Ok(meta) if meta.len() > 0 => RecorderEvent::Finished { output: output_path },
|
||||
Ok(meta) if meta.len() > 0 => RecorderEvent::Finished {
|
||||
output: output_path,
|
||||
},
|
||||
Ok(_) => RecorderEvent::Failed {
|
||||
error: format!("ffmpeg exit {status} and empty output"),
|
||||
},
|
||||
|
||||
@@ -23,9 +23,7 @@ pub enum AppState {
|
||||
started_at: Instant,
|
||||
},
|
||||
/// Terminal error; user must dismiss or fix config.
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
||||
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::sync::{Mutex, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -93,8 +93,7 @@ impl CaseStore {
|
||||
})?;
|
||||
|
||||
let initial = read_all_markers(&dir).await?;
|
||||
let map: HashMap<Uuid, CaseMarker> =
|
||||
initial.into_iter().map(|m| (m.case_id, m)).collect();
|
||||
let map: HashMap<Uuid, CaseMarker> = initial.into_iter().map(|m| (m.case_id, m)).collect();
|
||||
let snapshot = sorted_snapshot(&map);
|
||||
let (tx, rx) = watch::channel(snapshot);
|
||||
|
||||
@@ -278,7 +277,9 @@ impl CaseStore {
|
||||
for id in &to_remove {
|
||||
let path = self.dir.join(format!("{id}.json"));
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(_) => info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)"),
|
||||
Ok(_) => {
|
||||
info!(case_id = %id, "marker removed (server reconcile: case missing from snapshot)")
|
||||
}
|
||||
Err(e) => warn!(case_id = %id, error = %e, "failed to remove reconciled marker"),
|
||||
}
|
||||
state.remove(id);
|
||||
@@ -298,10 +299,7 @@ impl CaseStore {
|
||||
/// Returns the number of markers removed. Intended as an
|
||||
/// app-startup pass so the data-minimization rule holds: metadata
|
||||
/// ages out after a few days even without an explicit UI action.
|
||||
pub async fn cleanup_stale(
|
||||
&self,
|
||||
cutoff: OffsetDateTime,
|
||||
) -> Result<usize, CaseStoreError> {
|
||||
pub async fn cleanup_stale(&self, cutoff: OffsetDateTime) -> Result<usize, CaseStoreError> {
|
||||
let cutoff_str = format_utc(cutoff)?;
|
||||
let mut state = self.state.lock().await;
|
||||
let to_remove: Vec<Uuid> = state
|
||||
@@ -313,7 +311,9 @@ impl CaseStore {
|
||||
for id in &to_remove {
|
||||
let path = self.dir.join(format!("{id}.json"));
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(_) => tracing::info!(case_id = %id, path = %path.display(), "stale marker removed"),
|
||||
Ok(_) => {
|
||||
tracing::info!(case_id = %id, path = %path.display(), "stale marker removed")
|
||||
}
|
||||
Err(e) => warn!(case_id = %id, error = %e, "failed to remove stale marker"),
|
||||
}
|
||||
state.remove(id);
|
||||
@@ -328,13 +328,12 @@ impl CaseStore {
|
||||
/// (new API key = potentially different user).
|
||||
pub async fn clear_all(&self) -> Result<(), CaseStoreError> {
|
||||
let mut state = self.state.lock().await;
|
||||
let mut entries =
|
||||
tokio::fs::read_dir(&self.dir)
|
||||
.await
|
||||
.map_err(|e| CaseStoreError::Io {
|
||||
path: self.dir.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
let mut entries = tokio::fs::read_dir(&self.dir)
|
||||
.await
|
||||
.map_err(|e| CaseStoreError::Io {
|
||||
path: self.dir.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
@@ -378,10 +377,12 @@ fn sorted_vec(state: &HashMap<Uuid, CaseMarker>) -> Vec<CaseMarker> {
|
||||
|
||||
async fn read_all_markers(dir: &Path) -> Result<Vec<CaseMarker>, CaseStoreError> {
|
||||
let mut out = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| CaseStoreError::Io {
|
||||
path: dir.to_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
let mut entries = tokio::fs::read_dir(dir)
|
||||
.await
|
||||
.map_err(|e| CaseStoreError::Io {
|
||||
path: dir.to_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
@@ -400,10 +401,12 @@ async fn read_all_markers(dir: &Path) -> Result<Vec<CaseMarker>, CaseStoreError>
|
||||
}
|
||||
|
||||
async fn read_marker_file(path: &Path) -> Result<CaseMarker, CaseStoreError> {
|
||||
let bytes = tokio::fs::read(path).await.map_err(|e| CaseStoreError::Io {
|
||||
path: path.to_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
let bytes = tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|e| CaseStoreError::Io {
|
||||
path: path.to_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
@@ -465,7 +468,10 @@ mod tests {
|
||||
assert!(rx.borrow().is_empty());
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let marker = store.create_local(id, t("2026-04-18T10:32:00Z")).await.unwrap();
|
||||
let marker = store
|
||||
.create_local(id, t("2026-04-18T10:32:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(marker.case_id, id);
|
||||
assert!(!marker.synced_to_server);
|
||||
assert!(marker.oneliner.is_none());
|
||||
@@ -486,8 +492,14 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store.mark_activity(id, t("2026-04-18T10:45:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.mark_activity(id, t("2026-04-18T10:45:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = store.list().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
@@ -501,7 +513,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.mark_activity(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.mark_activity(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.list().await.len(), 1);
|
||||
}
|
||||
|
||||
@@ -529,7 +544,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let snap = server_snapshot(vec![server_entry(
|
||||
id,
|
||||
@@ -551,7 +569,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let snap = server_snapshot(vec![]);
|
||||
store.merge_server_snapshot(&snap).await.unwrap();
|
||||
@@ -566,16 +587,19 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
let synced_snap = server_snapshot(vec![server_entry(
|
||||
id,
|
||||
Some("old"),
|
||||
"2026-04-18T10:00:00Z",
|
||||
)]);
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
let synced_snap =
|
||||
server_snapshot(vec![server_entry(id, Some("old"), "2026-04-18T10:00:00Z")]);
|
||||
store.merge_server_snapshot(&synced_snap).await.unwrap();
|
||||
|
||||
// Next poll: server window moved, case fell out.
|
||||
store.merge_server_snapshot(&server_snapshot(vec![])).await.unwrap();
|
||||
store
|
||||
.merge_server_snapshot(&server_snapshot(vec![]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = store.list().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
@@ -588,15 +612,17 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store.mark_activity(id, t("2026-04-18T11:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.mark_activity(id, t("2026-04-18T11:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Server says last activity was 10:00 (stale) — local 11:00 must win.
|
||||
let snap = server_snapshot(vec![server_entry(
|
||||
id,
|
||||
Some("x"),
|
||||
"2026-04-18T10:00:00Z",
|
||||
)]);
|
||||
let snap = server_snapshot(vec![server_entry(id, Some("x"), "2026-04-18T10:00:00Z")]);
|
||||
store.merge_server_snapshot(&snap).await.unwrap();
|
||||
|
||||
let list = store.list().await;
|
||||
@@ -607,8 +633,14 @@ mod tests {
|
||||
async fn clear_all_removes_everything() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store.create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create_local(Uuid::new_v4(), t("2026-04-18T11:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.list().await.len(), 2);
|
||||
|
||||
store.clear_all().await.unwrap();
|
||||
@@ -685,7 +717,10 @@ mod tests {
|
||||
let old_unsynced = Uuid::new_v4();
|
||||
|
||||
// old_synced: created 4 days ago, then merged in → synced=true, old
|
||||
store.create_local(old_synced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(old_synced, t("2026-04-14T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||
case_id: old_synced.to_string(),
|
||||
@@ -698,7 +733,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// young_synced: created today, synced
|
||||
store.create_local(young_synced, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(young_synced, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.merge_server_snapshot(&server_snapshot(vec![OnelinerEntry {
|
||||
case_id: young_synced.to_string(),
|
||||
@@ -711,7 +749,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// old_unsynced: created 4 days ago, NEVER merged in → pending upload
|
||||
store.create_local(old_unsynced, t("2026-04-14T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(old_unsynced, t("2026-04-14T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Cutoff: 72h before the "today" markers, i.e. 2026-04-15.
|
||||
let cutoff = t("2026-04-15T10:00:00Z");
|
||||
@@ -729,15 +770,24 @@ mod tests {
|
||||
|
||||
// Disk: old_synced's file deleted.
|
||||
let old_path = tmp.path().join(format!("{old_synced}.json"));
|
||||
assert!(!old_path.exists(), "marker file for old_synced must be deleted");
|
||||
assert!(
|
||||
!old_path.exists(),
|
||||
"marker file for old_synced must be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_stale_is_noop_when_nothing_old() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
store.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
let removed = store.cleanup_stale(t("2026-01-01T00:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(Uuid::new_v4(), t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
let removed = store
|
||||
.cleanup_stale(t("2026-01-01T00:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(store.list().await.len(), 1);
|
||||
}
|
||||
@@ -761,7 +811,10 @@ mod tests {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
// Seed a synced marker with recent activity.
|
||||
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id).await.unwrap();
|
||||
|
||||
// Snapshot authoritative for [2026-04-15T10Z, 2026-04-18T10Z],
|
||||
@@ -784,7 +837,10 @@ mod tests {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
// Pending: synced_to_server = false (default after create_local).
|
||||
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
||||
let removed = store.reconcile_with_server_snapshot(&snap).await.unwrap();
|
||||
@@ -804,7 +860,10 @@ mod tests {
|
||||
|
||||
// last_activity_at = 2026-04-10 (8 days ago); window = 72h anchor 2026-04-18T10Z
|
||||
// → window_start = 2026-04-15T10Z. Marker is outside.
|
||||
store.create_local(id, t("2026-04-10T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-10T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id).await.unwrap();
|
||||
|
||||
let snap = snapshot_as_of("2026-04-18T10:00:00Z", vec![]);
|
||||
@@ -823,8 +882,14 @@ mod tests {
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id_a = Uuid::new_v4();
|
||||
let id_b = Uuid::new_v4();
|
||||
store.create_local(id_a, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store.create_local(id_b, t("2026-04-18T09:30:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id_a, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create_local(id_b, t("2026-04-18T09:30:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id_a).await.unwrap();
|
||||
store.mark_synced(id_b).await.unwrap();
|
||||
|
||||
@@ -852,7 +917,10 @@ mod tests {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
// Marker last_activity_at 2026-04-18T09:00Z, synced.
|
||||
store.create_local(id, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id).await.unwrap();
|
||||
|
||||
// Server reports as_of 2026-04-18T10:00Z (1h after marker,
|
||||
@@ -866,7 +934,10 @@ mod tests {
|
||||
|
||||
// Seed a second case for the symmetric assertion.
|
||||
let id2 = Uuid::new_v4();
|
||||
store.create_local(id2, t("2026-04-18T09:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id2, t("2026-04-18T09:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id2).await.unwrap();
|
||||
|
||||
// Now the server reports a much later as_of. The marker's
|
||||
@@ -885,7 +956,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!store.list().await[0].synced_to_server);
|
||||
|
||||
store.mark_synced(id).await.unwrap();
|
||||
@@ -908,7 +982,10 @@ mod tests {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let id = Uuid::new_v4();
|
||||
store.create_local(id, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(id, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store.mark_synced(id).await.unwrap();
|
||||
store.mark_synced(id).await.unwrap(); // second call: no disk churn
|
||||
assert!(store.list().await[0].synced_to_server);
|
||||
@@ -920,8 +997,14 @@ mod tests {
|
||||
let (store, _rx) = CaseStore::open(tmp.path().to_owned()).await.unwrap();
|
||||
let older = Uuid::new_v4();
|
||||
let newer = Uuid::new_v4();
|
||||
store.create_local(older, t("2026-04-18T08:00:00Z")).await.unwrap();
|
||||
store.create_local(newer, t("2026-04-18T10:00:00Z")).await.unwrap();
|
||||
store
|
||||
.create_local(older, t("2026-04-18T08:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create_local(newer, t("2026-04-18T10:00:00Z"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = store.list().await;
|
||||
assert_eq!(list[0].case_id, newer);
|
||||
|
||||
@@ -59,8 +59,8 @@ pub fn pick_footer_status(
|
||||
WorkerState::Polling => FooterStatus::Synchronizing,
|
||||
WorkerState::Idle => {
|
||||
let failure_more_recent = match (last_success_age, last_failure_age) {
|
||||
(Some(s), Some(f)) => f < s, // smaller age = younger
|
||||
(None, Some(_)) => true, // only a failure on record
|
||||
(Some(s), Some(f)) => f < s, // smaller age = younger
|
||||
(None, Some(_)) => true, // only a failure on record
|
||||
_ => false,
|
||||
};
|
||||
if failure_more_recent {
|
||||
@@ -88,7 +88,14 @@ mod tests {
|
||||
queue_len: usize,
|
||||
last_success_age: Option<Duration>,
|
||||
) -> FooterStatus {
|
||||
pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD)
|
||||
pick_footer_status(
|
||||
finalizing,
|
||||
worker_state,
|
||||
queue_len,
|
||||
last_success_age,
|
||||
None,
|
||||
THRESHOLD,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,7 +135,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn synchronizing_when_polling_and_no_queue() {
|
||||
let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1)));
|
||||
let s = status(
|
||||
false,
|
||||
WorkerState::Polling,
|
||||
0,
|
||||
Some(Duration::from_millis(1)),
|
||||
);
|
||||
assert_eq!(s, FooterStatus::Synchronizing);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,14 @@ pub mod startup;
|
||||
pub mod upload;
|
||||
|
||||
pub use case_store::{CaseMarker, CaseStore, CaseStoreError};
|
||||
pub use config::{Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS};
|
||||
pub use footer_status::{pick_footer_status, FooterStatus};
|
||||
pub use config::{
|
||||
Config, ConfigError, DEFAULT_ONELINER_WINDOW_HOURS, DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
};
|
||||
pub use footer_status::{FooterStatus, pick_footer_status};
|
||||
pub use pending_cleanup::cleanup_orphan_audio;
|
||||
pub use server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
|
||||
pub use snapshot_cache::{CachedSnapshot, SnapshotCache};
|
||||
pub use startup::{run_startup_cleanup, DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION};
|
||||
pub use upload::{scan_pending_dir, sidecar_path_for, write_sidecar, PendingUpload, UploadEvent, UploadIoError};
|
||||
pub use startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
|
||||
pub use upload::{
|
||||
PendingUpload, UploadEvent, UploadIoError, scan_pending_dir, sidecar_path_for, write_sidecar,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ use doctate_common::constants::{
|
||||
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
||||
UPLOAD_PATH,
|
||||
};
|
||||
use doctate_common::oneliners::{OnelinersResponse, ONELINERS_PATH};
|
||||
use doctate_common::oneliners::{ONELINERS_PATH, OnelinersResponse};
|
||||
use reqwest::StatusCode;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::AbortHandle;
|
||||
@@ -36,7 +36,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::case_store::CaseStore;
|
||||
use crate::snapshot_cache::{CachedSnapshot, SnapshotCache};
|
||||
use crate::upload::{scan_pending_dir, sidecar_path_for, PendingUpload, UploadEvent};
|
||||
use crate::upload::{PendingUpload, UploadEvent, scan_pending_dir, sidecar_path_for};
|
||||
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(2);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
@@ -233,8 +233,7 @@ async fn run_loop(
|
||||
let mut files = scan_pending_dir(&pending_dir);
|
||||
files.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at)); // FIFO
|
||||
let queue_len = files.len();
|
||||
let pending_ids: Arc<HashSet<Uuid>> =
|
||||
Arc::new(files.iter().map(|u| u.case_id).collect());
|
||||
let pending_ids: Arc<HashSet<Uuid>> = Arc::new(files.iter().map(|u| u.case_id).collect());
|
||||
let next = files.into_iter().next();
|
||||
|
||||
if let Some(upload) = next {
|
||||
@@ -565,7 +564,10 @@ mod tests {
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let outcome = post_upload(&http, &cfg(&server), &upload).await;
|
||||
assert!(matches!(outcome, UploadOutcome::Succeeded(AckStatus::Received)));
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
UploadOutcome::Succeeded(AckStatus::Received)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -710,7 +712,8 @@ mod tests {
|
||||
sample_pending(&pending_dir, case_id, "2026-04-18T10-00-00Z");
|
||||
|
||||
let (store, cache) = make_store_and_cache(&tmp).await;
|
||||
store.create_local(case_id, time::OffsetDateTime::now_utc())
|
||||
store
|
||||
.create_local(case_id, time::OffsetDateTime::now_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -914,10 +917,11 @@ mod tests {
|
||||
async fn fifo_order_by_recorded_at() {
|
||||
let server = MockServer::start().await;
|
||||
// Record which case_ids we saw, in order.
|
||||
let seen: Arc<tokio::sync::Mutex<Vec<Uuid>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let seen: Arc<tokio::sync::Mutex<Vec<Uuid>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let seen_for_mock = seen.clone();
|
||||
let responder = wiremock::ResponseTemplate::new(200)
|
||||
.set_body_json(ack_body(AckStatus::Received));
|
||||
let responder =
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received));
|
||||
// Simpler: just let both POSTs succeed; infer order from event
|
||||
// stream on our side.
|
||||
let _ = seen_for_mock;
|
||||
|
||||
@@ -123,7 +123,10 @@ mod tests {
|
||||
let loaded = cache.load().await.expect("cache present");
|
||||
assert_eq!(loaded.etag, s.etag);
|
||||
assert_eq!(loaded.response.oneliners.len(), 1);
|
||||
assert_eq!(loaded.response.oneliners[0].oneliner.as_deref(), Some("Kniegelenk"));
|
||||
assert_eq!(
|
||||
loaded.response.oneliners[0].oneliner.as_deref(),
|
||||
Some("Kniegelenk")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -43,8 +43,8 @@ pub async fn run_startup_cleanup(
|
||||
marker_retention: Duration,
|
||||
orphan_audio_retention: Duration,
|
||||
) {
|
||||
let marker_cutoff = OffsetDateTime::now_utc()
|
||||
- time::Duration::seconds(marker_retention.as_secs() as i64);
|
||||
let marker_cutoff =
|
||||
OffsetDateTime::now_utc() - time::Duration::seconds(marker_retention.as_secs() as i64);
|
||||
match store.cleanup_stale(marker_cutoff).await {
|
||||
Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"),
|
||||
Ok(_) => {}
|
||||
@@ -61,8 +61,8 @@ pub async fn run_startup_cleanup(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::upload::{write_sidecar, PendingUpload};
|
||||
use filetime::{set_file_mtime, FileTime};
|
||||
use crate::upload::{PendingUpload, write_sidecar};
|
||||
use filetime::{FileTime, set_file_mtime};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -114,8 +114,8 @@ mod tests {
|
||||
run_startup_cleanup(
|
||||
&store,
|
||||
&pending_dir,
|
||||
Duration::from_secs(3600), // markers older than 1h: nuke
|
||||
Duration::from_secs(3600), // orphans older than 1h: nuke
|
||||
Duration::from_secs(3600), // markers older than 1h: nuke
|
||||
Duration::from_secs(3600), // orphans older than 1h: nuke
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ pub use constants::{
|
||||
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
|
||||
UPLOAD_PATH,
|
||||
};
|
||||
pub use oneliners::{OnelinerEntry, OnelinersResponse, ONELINERS_PATH};
|
||||
pub use oneliners::{ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
||||
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
|
||||
|
||||
@@ -129,7 +129,10 @@ mod tests {
|
||||
fn extract_hhmm_shape_is_two_colon_two() {
|
||||
let s = extract_hhmm("2026-04-18T10:32:00Z");
|
||||
assert_eq!(s.len(), 5, "expected HH:MM, got {s}");
|
||||
assert!(s.chars().nth(2) == Some(':'), "expected colon at index 2: {s}");
|
||||
assert!(
|
||||
s.chars().nth(2) == Some(':'),
|
||||
"expected colon at index 2: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,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};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user