Files
doctate/server/tests/sse_integration.rs
T
Brummel 1d702e2d85 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.
2026-04-19 23:33:53 +02:00

137 lines
3.9 KiB
Rust

//! 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:?}"
);
}