Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI. This enables features like automatic page reloads when case data changes in the background, improving the user experience by keeping the UI synchronized with the backend. Key components: - `events` module: Contains the `CaseEventKind` enum, `CaseEvent` struct, and `EventSender` type for managing the broadcast channel. - SSE endpoint (`/web/events`): Streams events to connected browsers. - Client-side JavaScript: Listens for events and triggers debounced page reloads. - Integration points: Workers and route handlers now emit events when relevant state changes occur.
This commit is contained in:
@@ -369,7 +369,14 @@ async fn analyze_worker_writes_document_via_wiremock() {
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
let handle = tokio::spawn(analyze::worker::run(rx, config, client, busy, vocab));
|
||||
let handle = tokio::spawn(analyze::worker::run(
|
||||
rx,
|
||||
config,
|
||||
client,
|
||||
busy,
|
||||
vocab,
|
||||
doctate_server::events::channel(),
|
||||
));
|
||||
|
||||
tx.send(analyze::AnalyzeJob {
|
||||
case_dir: case_dir.clone(),
|
||||
@@ -447,7 +454,14 @@ async fn analyze_worker_normalizes_llm_output() {
|
||||
let (tx, rx) = analyze::channel();
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let handle = tokio::spawn(analyze::worker::run(rx, config, client, busy, vocab));
|
||||
let handle = tokio::spawn(analyze::worker::run(
|
||||
rx,
|
||||
config,
|
||||
client,
|
||||
busy,
|
||||
vocab,
|
||||
doctate_server::events::channel(),
|
||||
));
|
||||
|
||||
tx.send(analyze::AnalyzeJob {
|
||||
case_dir: case_dir.clone(),
|
||||
@@ -998,7 +1012,14 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
let handle = tokio::spawn(analyze::worker::run(rx, config, client, busy, vocab));
|
||||
let handle = tokio::spawn(analyze::worker::run(
|
||||
rx,
|
||||
config,
|
||||
client,
|
||||
busy,
|
||||
vocab,
|
||||
doctate_server::events::channel(),
|
||||
));
|
||||
|
||||
tx.send(analyze::AnalyzeJob {
|
||||
case_dir: case_dir.clone(),
|
||||
|
||||
@@ -54,6 +54,7 @@ fn build_state() -> (AppState, Arc<Config>) {
|
||||
magic_link_store,
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
events_tx: doctate_server::events::channel(),
|
||||
};
|
||||
|
||||
(state, config)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Integration tests for the `/web/events` SSE route.
|
||||
//!
|
||||
//! We test two things end-to-end:
|
||||
//! 1. Without a session cookie, the route redirects to the login page
|
||||
//! (the web-auth extractor's standard rejection).
|
||||
//! 2. With a valid session, the route returns a streaming response with
|
||||
//! `Content-Type: text/event-stream`.
|
||||
//!
|
||||
//! Actual event-filtering logic (per-user scoping, admin sees-all) is
|
||||
//! covered at the unit level in `src/events.rs`; wiring the broadcast
|
||||
//! channel through an HTTP test would require reading a never-ending
|
||||
//! body, which these tests deliberately avoid.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
fn unique_data_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"doctate-sse-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
|
||||
fn make_user(slug: &str) -> User {
|
||||
User {
|
||||
slug: slug.into(),
|
||||
api_key: format!("key-{slug}"),
|
||||
// Password "s" — matches the login helper below.
|
||||
web_password: bcrypt::hash("s", 4).unwrap(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(data_path: PathBuf) -> Arc<Config> {
|
||||
let users = vec![make_user("alice")];
|
||||
let api_keys: HashMap<String, String> = users
|
||||
.iter()
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users,
|
||||
api_keys,
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Log in via POST /web/login and return the resulting `session=…` cookie
|
||||
/// header value. Mirrors the helper in the other integration tests.
|
||||
async fn login(app: axum::Router, slug: &str) -> String {
|
||||
let body = format!("slug={slug}&password=s");
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||||
let s = v.to_str().unwrap();
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return pair.to_string();
|
||||
}
|
||||
}
|
||||
panic!("no session cookie after login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_without_session_redirects_to_login() {
|
||||
let config = build_config(unique_data_path());
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/web/events")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
let location = resp
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(location, "/web/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn events_with_session_returns_sse_content_type() {
|
||||
let config = build_config(unique_data_path());
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "alice").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/web/events")
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
ct.starts_with("text/event-stream"),
|
||||
"expected text/event-stream, got {ct:?}"
|
||||
);
|
||||
}
|
||||
@@ -304,7 +304,15 @@ async fn worker_renames_audio_to_failed_on_whisper_error() {
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let vocab = std::sync::Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
||||
transcribe::worker::run(rx, config, client, busy, vocab).await;
|
||||
transcribe::worker::run(
|
||||
rx,
|
||||
config,
|
||||
client,
|
||||
busy,
|
||||
vocab,
|
||||
doctate_server::events::channel(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Audio must have been renamed so recovery skips it next time.
|
||||
assert!(!audio.exists(), "original .m4a still present");
|
||||
@@ -353,7 +361,15 @@ async fn transcribe_worker_normalizes_whisper_output() {
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let busy = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
transcribe::worker::run(rx, config, client, busy, vocab).await;
|
||||
transcribe::worker::run(
|
||||
rx,
|
||||
config,
|
||||
client,
|
||||
busy,
|
||||
vocab,
|
||||
doctate_server::events::channel(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let transcript_path = case_dir.join("2026-04-13T10-30-00Z.transcript.txt");
|
||||
assert!(transcript_path.exists(), "transcript missing");
|
||||
|
||||
Reference in New Issue
Block a user