//! Listen-relevante Mutationen (Einzel-Delete, Undo, Bulk-Delete) müssen //! den pro-User Watermark bumpen. Sonst bleibt der ETag von //! `/api/oneliners` stehen, jeder Poll liefert 304, und der Client //! läuft dauerhaft mit einem Marker für einen Fall, den der Server //! bereits gelöscht hat (ursprünglicher Bugreport: "Polymyalgia //! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits //! gelöscht). use std::collections::HashMap; use std::sync::Arc; use axum::body::Body; 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}; use tower::util::ServiceExt; fn make_user(slug: &str, password_plain: &str) -> User { User { slug: slug.into(), api_key: format!("key-{slug}"), web_password: bcrypt::hash(password_plain, 4).unwrap(), role: "doctor".into(), whisper: Default::default(), } } fn test_config_with_users(users: Vec) -> Arc { let data_path = std::env::temp_dir().join(format!( "doctate-delete-wm-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); let api_keys: HashMap = users .iter() .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); Arc::new(Config { data_path, users, api_keys, ..Config::test_default() }) } fn extract_session_cookie(resp: &axum::response::Response) -> Option { for v in resp.headers().get_all(header::SET_COOKIE).iter() { let s = v.to_str().ok()?; if let Some(pair) = s.split(';').next() && pair.starts_with("session=") { return Some(pair.to_string()); } } None } fn login_request(slug: &str, password: &str) -> Request { let body = format!("slug={slug}&password={password}"); Request::builder() .method("POST") .uri("/web/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } fn oneliners_request(api_key: &str, if_none_match: Option<&str>) -> Request { let mut b = Request::builder() .method("GET") .uri(ONELINERS_PATH) .header(API_KEY_HEADER, api_key); if let Some(tag) = if_none_match { b = b.header(header::IF_NONE_MATCH, tag); } b.body(Body::empty()).unwrap() } /// Create `///.m4a` so the case is /// visible to `enumerate_recent_oneliners` (window defaults to 16h; /// the fake mtime is "now" because write happens right now). fn seed_case(data_path: &std::path::Path, slug: &str, case_id: &str) { let case_dir = data_path.join(slug).join(case_id); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a"), b"fake audio").unwrap(); } async fn capture_etag( app: &axum::Router, api_key: &str, if_none_match: Option<&str>, ) -> (StatusCode, Option) { let resp = app .clone() .oneshot(oneliners_request(api_key, if_none_match)) .await .unwrap(); let status = resp.status(); let etag = resp .headers() .get(header::ETAG) .and_then(|v| v.to_str().ok()) .map(str::to_owned); (status, etag) } #[tokio::test] async fn delete_bumps_watermark() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "550e8400-e29b-41d4-a716-446655440000"; seed_case(&data_path, "dr_a", case_id); let app = doctate_server::create_router(config); 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). let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await; assert_eq!(s1, StatusCode::OK); let etag_before = etag_before.expect("server must set ETag"); // Soft-delete via the web handler. let del = app .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/delete")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( del.status().as_u16() / 100, 3, "delete should redirect (3xx)" ); // Same If-None-Match now must NOT match — watermark was bumped. let (s2, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await; assert_eq!( s2, StatusCode::OK, "watermark bump must invalidate cached ETag", ); let etag_after = etag_after.expect("200 response must set ETag"); assert_ne!( etag_after, etag_before, "delete handler must change the per-user ETag" ); } #[tokio::test] async fn undo_delete_bumps_watermark() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_id = "660e8400-e29b-41d4-a716-446655440000"; seed_case(&data_path, "dr_a", case_id); let app = doctate_server::create_router(config); 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. app.clone() .oneshot( Request::builder() .method("POST") .uri(format!("/web/cases/{case_id}/delete")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let (_, etag_after_delete) = capture_etag(&app, "key-dr_a", None).await; let etag_after_delete = etag_after_delete.expect("ETag must be set"); // Undo. let undo = app .clone() .oneshot( Request::builder() .method("POST") .uri("/web/cases/undo-delete") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(undo.status().as_u16() / 100, 3, "undo should redirect"); // Undo must bump again → post-delete ETag becomes stale. let (s, etag_after_undo) = capture_etag(&app, "key-dr_a", Some(&etag_after_delete)).await; assert_eq!( s, StatusCode::OK, "undo must change the ETag so clients re-fetch" ); let etag_after_undo = etag_after_undo.expect("200 response must set ETag"); assert_ne!(etag_after_undo, etag_after_delete); } #[tokio::test] async fn bulk_delete_bumps_watermark() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); let data_path = config.data_path.clone(); let case_a = "770e8400-e29b-41d4-a716-446655440000"; let case_b = "880e8400-e29b-41d4-a716-446655440000"; seed_case(&data_path, "dr_a", case_a); seed_case(&data_path, "dr_a", case_b); let app = doctate_server::create_router(config); 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; let etag_before = etag_before.expect("ETag must be set"); // Form-encoded bulk delete: action=delete&case_id=a&case_id=b. let form = format!("action=delete&case_id={case_a}&case_id={case_b}"); let bulk = app .clone() .oneshot( Request::builder() .method("POST") .uri("/web/cases/bulk") .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(form)) .unwrap(), ) .await .unwrap(); assert_eq!(bulk.status().as_u16() / 100, 3, "bulk should redirect"); let (s, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await; assert_eq!(s, StatusCode::OK, "bulk-delete must bump the ETag"); let etag_after = etag_after.expect("200 response must set ETag"); assert_ne!(etag_after, etag_before); }