From 396565a5716b2c16d1f7fb304487134f11a3d4d9 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 21 Apr 2026 10:43:39 +0200 Subject: [PATCH] refactor: replace delete/undo flow with close/reopen endpoints - POST /web/cases/:id/delete renamed to /close; emits CaseClosed. - New POST /web/cases/:id/reopen removes the marker; emits CaseReopened. - Bulk action "delete" renamed to "close"; the shared closed_at timestamp still groups the selection for future UI features. - POST /web/cases/undo-delete removed along with summarize_latest_close_group, latest_close_timestamp and restore_close_group. Per-case reopen makes the group-undo banner obsolete. - CaseEventKind::CaseDeleted -> CaseClosed, CaseRestored -> CaseReopened. - Templates updated to Lucide trash-2 icon, German label "Fall schliessen". - Tests migrated; delete_watermark_test.rs moved to close_watermark_test.rs. --- server/src/events.rs | 4 +- server/src/routes/bulk.rs | 22 +-- server/src/routes/case_actions.rs | 171 ++++++------------ server/src/routes/mod.rs | 12 +- server/src/routes/upload.rs | 2 +- server/src/routes/user_web.rs | 8 - server/templates/case_page.html | 16 +- server/templates/my_cases.html | 11 +- server/tests/analyze_test.rs | 109 ++++++----- server/tests/case_page_test.rs | 8 +- ...ermark_test.rs => close_watermark_test.rs} | 54 +++--- 11 files changed, 176 insertions(+), 241 deletions(-) rename server/tests/{delete_watermark_test.rs => close_watermark_test.rs} (81%) diff --git a/server/src/events.rs b/server/src/events.rs index 111a53d..f1a3cdb 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -30,8 +30,8 @@ pub enum CaseEventKind { OnelinerUpdated, AnalysisQueued, DocumentReady, - CaseDeleted, - CaseRestored, + CaseClosed, + CaseReopened, CaseReset, } diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index 58cf3d1..e9c07bd 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -27,9 +27,9 @@ pub struct BulkForm { case_ids: Vec, } -/// POST /web/cases/bulk — apply `action` (analyze | delete) to every -/// selected case. Errors per case are logged but do not abort the batch: -/// a single bad/missing case must not block the rest. +/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to +/// every selected case. Errors per case are logged but do not abort the +/// batch: a single bad/missing case must not block the rest. pub async fn handle_bulk_action( user: AuthenticatedWebUser, State(config): State>, @@ -51,7 +51,7 @@ pub async fn handle_bulk_action( ) .await } - "delete" => bulk_delete(&events_tx, &user.slug, &user_root, &form.case_ids).await, + "close" => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await, "reset" => { if !user.is_admin() { warn!(slug = %user.slug, "bulk-reset: not admin"); @@ -133,25 +133,23 @@ async fn bulk_analyze( info!(slug = %slug, ok, skipped, "bulk-analyze completed"); } -async fn bulk_delete( +async fn bulk_close( events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { - // Shared `closed_at` across the whole bulk action: identical timestamp - // groups the cases so a subsequent undo can restore exactly this click. let closed_at = now_rfc3339(); let mut ok = 0usize; let mut skipped = 0usize; for case_id in case_ids { if uuid::Uuid::parse_str(case_id).is_err() { - warn!(slug = %slug, case_id = %case_id, "bulk-delete: invalid case_id"); + warn!(slug = %slug, case_id = %case_id, "bulk-close: invalid case_id"); skipped += 1; continue; } let Some(case_dir) = locate_case(user_root, case_id).await else { - warn!(slug = %slug, case_id = %case_id, "bulk-delete: case not found"); + warn!(slug = %slug, case_id = %case_id, "bulk-close: case not found"); skipped += 1; continue; }; @@ -159,14 +157,14 @@ async fn bulk_delete( closed_at: closed_at.clone(), }; if let Err(e) = write_close_marker(&case_dir, &marker).await { - warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-delete: write marker failed"); + warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-close: write marker failed"); skipped += 1; continue; } - events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted); + events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed); ok += 1; } - info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-delete completed"); + info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-close completed"); } async fn bulk_reset( diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 8719652..dc0287d 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -22,7 +22,7 @@ use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; -use crate::paths::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker}; +use crate::paths::{CLOSE_MARKER, CloseMarker, write_close_marker}; use crate::routes::user_web::locate_case_or_404; use crate::routes::web::validate_filename; @@ -276,20 +276,20 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> Ok(()) } -/// POST /web/cases/{case_id}/delete +/// POST /web/cases/{case_id}/close /// /// Close the case: writes a `.closed` JSON marker into the case directory. /// The case immediately disappears from listings and detail views (404). -/// Reversible via `handle_undo_delete` while the marker still has the -/// most recent `closed_at` timestamp among the user's closed cases. -pub async fn handle_delete_case( +/// Reversible via `handle_reopen_case` (explicit button) or by uploading +/// a new recording for the same case (automatic via `handle_upload`). +pub async fn handle_close_case( user: AuthenticatedWebUser, State(config): State>, State(events_tx): State, CaseIdPath(case_id): CaseIdPath, ) -> Result { let user_root = config.data_path.join(&user.slug); - let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "delete").await?; + let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?; let marker = CloseMarker { closed_at: now_rfc3339(), @@ -300,14 +300,60 @@ pub async fn handle_delete_case( slug = %user.slug, case_id = %case_id, closed_at = %marker.closed_at, - "case closed" + "case closed (manual)" ); events::emit( &events_tx, &user.slug, case_id.to_string(), - CaseEventKind::CaseDeleted, + CaseEventKind::CaseClosed, + ); + + Ok(Redirect::to("/web/cases")) +} + +/// POST /web/cases/{case_id}/reopen +/// +/// Reopen a closed case: removes the `.closed` marker. The case is +/// immediately back in the default listing. A reopened case is +/// indistinguishable from one that was never closed — no status +/// remainders on disk. Idempotent: an already-open case is a no-op. +pub async fn handle_reopen_case( + user: AuthenticatedWebUser, + State(config): State>, + State(events_tx): State, + CaseIdPath(case_id): CaseIdPath, +) -> Result { + let user_root = config.data_path.join(&user.slug); + let case_dir = user_root.join(case_id.to_string()); + + if !crate::paths::is_closed(&case_dir).await { + info!( + slug = %user.slug, + case_id = %case_id, + "reopen requested but case is not closed" + ); + return Ok(Redirect::to("/web/cases")); + } + + match tokio::fs::remove_file(case_dir.join(CLOSE_MARKER)).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(AppError::Internal(format!("remove close marker: {e}"))), + } + + info!( + slug = %user.slug, + case_id = %case_id, + "case reopened (manual)" + ); + + events::emit( + &events_tx, + &user.slug, + case_id.to_string(), + CaseEventKind::CaseReopened, ); Ok(Redirect::to("/web/cases")) @@ -348,115 +394,6 @@ pub async fn handle_reset_case( Ok(Redirect::to(&resolve_return_path(&headers))) } -/// POST /web/cases/undo-delete -/// -/// Restore every case in the most recent close "group" by removing its -/// `.closed` marker. A group is defined as all cases sharing the exact -/// same `closed_at` RFC3339 timestamp — bulk-close writes one shared -/// timestamp across its selection, single-case close writes its own. -/// No-op if no markers exist. -pub async fn handle_undo_delete( - user: AuthenticatedWebUser, - State(config): State>, - State(events_tx): State, -) -> Result { - let user_root = config.data_path.join(&user.slug); - - let Some(group_ts) = latest_close_timestamp(&user_root).await else { - info!(slug = %user.slug, "undo-delete: nothing to undo"); - return Ok(Redirect::to("/web/cases")); - }; - - let restored = restore_close_group(&user_root, &group_ts, &events_tx, &user.slug).await; - info!(slug = %user.slug, closed_at = %group_ts, restored, "undo-delete completed"); - - Ok(Redirect::to("/web/cases")) -} - -/// Summarize the most recent close group under `user_root`: returns -/// `(closed_at, count)` where `count` is the number of cases sharing that -/// exact timestamp. `None` if no `.closed` markers exist. Single dir scan. -pub(crate) async fn summarize_latest_close_group(user_root: &Path) -> Option<(String, usize)> { - let group_ts = latest_close_timestamp(user_root).await?; - let mut count = 0usize; - let mut entries = tokio::fs::read_dir(user_root).await.ok()?; - while let Ok(Some(entry)) = entries.next_entry().await { - let case_path = entry.path(); - if !case_path.is_dir() { - continue; - } - if let Some(marker) = read_close_marker(&case_path).await - && marker.closed_at == group_ts - { - count += 1; - } - } - Some((group_ts, count)) -} - -/// Scan the user's data dir and return the maximum `closed_at` across -/// all `.closed` markers. `None` if no markers exist. Used to identify -/// the "most-recently-closed" group for undo. -async fn latest_close_timestamp(user_root: &Path) -> Option { - let mut entries = tokio::fs::read_dir(user_root).await.ok()?; - let mut best: Option = None; - while let Ok(Some(entry)) = entries.next_entry().await { - if !entry.path().is_dir() { - continue; - } - let Some(marker) = read_close_marker(&entry.path()).await else { - continue; - }; - best = match best { - None => Some(marker.closed_at), - Some(cur) if marker.closed_at > cur => Some(marker.closed_at), - other => other, - }; - } - best -} - -/// Remove `.closed` markers from every case whose `closed_at` equals -/// `group_ts`. Returns the number of restored cases. Emits a -/// `CaseRestored` event per case so subscribed browsers can update -/// their listings. -async fn restore_close_group( - user_root: &Path, - group_ts: &str, - events_tx: &EventSender, - user_slug: &str, -) -> usize { - let mut count = 0; - let mut entries = match tokio::fs::read_dir(user_root).await { - Ok(r) => r, - Err(_) => return 0, - }; - while let Ok(Some(entry)) = entries.next_entry().await { - let case_path = entry.path(); - if !case_path.is_dir() { - continue; - } - let Some(marker) = read_close_marker(&case_path).await else { - continue; - }; - if marker.closed_at != group_ts { - continue; - } - if let Err(e) = tokio::fs::remove_file(case_path.join(CLOSE_MARKER)).await { - warn!(case_dir = ?case_path, error = %e, "failed to remove .closed marker"); - continue; - } - events::emit( - events_tx, - user_slug, - events::case_id_of(&case_path), - CaseEventKind::CaseRestored, - ); - count += 1; - } - count -} - #[derive(Deserialize)] pub struct DeleteRecordingForm { pub filename: String, diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index abb5a3a..9e9f583 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -43,17 +43,17 @@ pub fn api_router() -> Router { post(case_actions::handle_analyze_case), ) .route( - "/web/cases/{case_id}/delete", - post(case_actions::handle_delete_case), + "/web/cases/{case_id}/close", + post(case_actions::handle_close_case), + ) + .route( + "/web/cases/{case_id}/reopen", + post(case_actions::handle_reopen_case), ) .route( "/web/cases/{case_id}/reset", post(case_actions::handle_reset_case), ) - .route( - "/web/cases/undo-delete", - post(case_actions::handle_undo_delete), - ) .route("/web/cases/bulk", post(bulk::handle_bulk_action)) .route( "/web/audio/{user}/{case_id}/{filename}", diff --git a/server/src/routes/upload.rs b/server/src/routes/upload.rs index dc56307..f6d5b4e 100644 --- a/server/src/routes/upload.rs +++ b/server/src/routes/upload.rs @@ -91,7 +91,7 @@ pub async fn handle_upload( &events_tx, &user.slug, &case_id, - CaseEventKind::CaseRestored, + CaseEventKind::CaseReopened, ); } diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2f24b2f..2dd5d62 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -152,9 +152,6 @@ struct MyCasesTemplate { slug: String, groups: Vec, total: usize, - /// Number of cases that "Undo last delete" would restore. 0 hides the - /// undo button entirely. - undo_count: usize, /// True iff the session user is an admin — toggles full case_id display. is_admin: bool, } @@ -319,16 +316,11 @@ pub async fn handle_my_cases( let cases = scan_user_cases(&config, &user.slug, busy).await; let total = cases.len(); let groups = group_by_utc_date(cases); - let undo_count = crate::routes::case_actions::summarize_latest_close_group(&user_root) - .await - .map(|(_, n)| n) - .unwrap_or(0); let is_admin = user.is_admin(); MyCasesTemplate { slug: user.slug, groups, total, - undo_count, is_admin, } .render() diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 3a05c4f..5faeb6e 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -264,13 +264,13 @@
diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 72d8a90..4c3692c 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -67,13 +67,6 @@ try { -{% if undo_count > 0 %} -
-{{ undo_count }} zuletzt gelöschte(r) Fall(/Fälle). - -
-{% endif %} - {% if total == 0 %}

Keine Fälle.

{% else %} @@ -95,7 +88,7 @@ try {
{% if is_admin %} {% endif %} - +
{% endfor %} @@ -107,7 +100,7 @@ try { {% if is_admin %} {% endif %} - + {% endif %} diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 24009b2..f0f81e6 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -122,10 +122,19 @@ fn analyze_request(case_id: &str, cookie: &str) -> Request { .unwrap() } -fn delete_request(case_id: &str, cookie: &str) -> Request { +fn close_request(case_id: &str, cookie: &str) -> Request { Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/delete")) + .uri(format!("/web/cases/{case_id}/close")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +fn reopen_request(case_id: &str, cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri(format!("/web/cases/{case_id}/reopen")) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap() @@ -605,12 +614,12 @@ async fn analyze_deletes_old_document_and_writes_fresh_input() { } // --------------------------------------------------------------------- -// Soft-delete + Undo +// Close / Reopen // --------------------------------------------------------------------- #[tokio::test] -async fn delete_writes_marker_and_redirects() { - let config = config_with_llm(unique_tmp("del-1"), "http://unused".into()); +async fn close_writes_marker_and_hides_case() { + let config = config_with_llm(unique_tmp("close-1"), "http://unused".into()); 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("ok")); @@ -618,7 +627,7 @@ async fn delete_writes_marker_and_redirects() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(delete_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() @@ -639,8 +648,8 @@ async fn delete_writes_marker_and_redirects() { } #[tokio::test] -async fn deleted_case_returns_404_on_detail() { - let config = config_with_llm(unique_tmp("del-2"), "http://unused".into()); +async fn closed_case_returns_404_on_detail() { + let config = config_with_llm(unique_tmp("close-2"), "http://unused".into()); 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("ok")); @@ -650,7 +659,7 @@ async fn deleted_case_returns_404_on_detail() { let resp = app .clone() - .oneshot(delete_request(case_id, &cookie)) + .oneshot(close_request(case_id, &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); @@ -669,60 +678,66 @@ async fn deleted_case_returns_404_on_detail() { } #[tokio::test] -async fn undo_delete_restores_latest_group_only() { - let config = config_with_llm(unique_tmp("undo-1"), "http://unused".into()); - let case_a = "11111111-1111-1111-1111-111111111111"; - let case_b = "22222222-2222-2222-2222-222222222222"; - let dir_a = seed_case(&config.data_path, "dr_a", case_a); - let dir_b = seed_case(&config.data_path, "dr_a", case_b); - seed_recording(&dir_a, "10-00-00", Some("a")); - seed_recording(&dir_b, "10-05-00", Some("b")); +async fn reopen_removes_marker_and_restores_case() { + let config = config_with_llm(unique_tmp("reopen-1"), "http://unused".into()); + 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("ok")); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - // Two separate close clicks → two distinct close-groups (distinct - // `closed_at` timestamps). + // Close, then reopen via the explicit endpoint. let _ = app .clone() - .oneshot(delete_request(case_a, &cookie)) - .await - .unwrap(); - // `closed_at` is `now_rfc3339()` with whole-second granularity — the - // sleep must cross a second boundary so the two closes land in - // distinct groups. - tokio::time::sleep(Duration::from_millis(1100)).await; - let _ = app - .clone() - .oneshot(delete_request(case_b, &cookie)) + .oneshot(close_request(case_id, &cookie)) .await .unwrap(); + assert!(case_dir.join(".closed").exists()); - let undo = app + let resp = app .clone() + .oneshot(reopen_request(case_id, &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert!( + !case_dir.join(".closed").exists(), + ".closed marker must be gone after reopen" + ); + + // Case is addressable again at the detail route — no lingering state. + let detail = app .oneshot( Request::builder() - .method("POST") - .uri("/web/cases/undo-delete") + .uri(format!("/web/cases/{case_id}")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(undo.status(), StatusCode::SEE_OTHER); - - // case_b (latest close) restored, case_a still closed. - assert!( - !dir_b.join(".closed").exists(), - "case_b marker should be gone" - ); - assert!(dir_a.join(".closed").exists(), "case_a marker must remain"); + assert_eq!(detail.status(), StatusCode::OK); } #[tokio::test] -async fn bulk_delete_shares_one_closed_at_timestamp() { - let config = config_with_llm(unique_tmp("bulk-d"), "http://unused".into()); +async fn reopen_is_noop_on_open_case() { + let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into()); + 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("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(reopen_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert!(!case_dir.join(".closed").exists()); +} + +#[tokio::test] +async fn bulk_close_shares_one_closed_at_timestamp() { + let config = config_with_llm(unique_tmp("bulk-c"), "http://unused".into()); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; let dir_a = seed_case(&config.data_path, "dr_a", case_a); @@ -733,7 +748,7 @@ async fn bulk_delete_shares_one_closed_at_timestamp() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let body = format!("action=delete&case_id={case_a}&case_id={case_b}"); + let body = format!("action=close&case_id={case_a}&case_id={case_b}"); let resp = app .oneshot( Request::builder() @@ -754,7 +769,7 @@ async fn bulk_delete_shares_one_closed_at_timestamp() { serde_json::from_str(&std::fs::read_to_string(dir_b.join(".closed")).unwrap()).unwrap(); assert_eq!( m_a["closed_at"], m_b["closed_at"], - "bulk-delete must share one closed_at timestamp so undo can group them" + "bulk-close must share one closed_at timestamp across its selection" ); } @@ -791,8 +806,8 @@ async fn bulk_analyze_processes_each_selected_case() { } #[tokio::test] -async fn recovery_skips_deleted_cases() { - let tmp = unique_tmp("rec-del"); +async fn recovery_skips_closed_cases() { + let tmp = unique_tmp("rec-closed"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap(); @@ -805,7 +820,7 @@ async fn recovery_skips_deleted_cases() { let (tx, mut rx) = analyze::channel(); analyze::recovery::scan_and_enqueue(&tmp, &tx).await; - assert!(rx.try_recv().is_err(), "deleted case must not be enqueued"); + assert!(rx.try_recv().is_err(), "closed case must not be enqueued"); } // --------------------------------------------------------------------- diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 343ce19..7cc4870 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -239,8 +239,8 @@ async fn case_page_empty_case_shows_placeholder() { "empty-case placeholder missing" ); assert!( - body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)), - "delete form missing on empty case" + body.contains(&format!(r#"action="/web/cases/{case_id}/close""#)), + "close form missing on empty case" ); } @@ -476,7 +476,7 @@ async fn case_recordings_has_no_action_buttons() { "recordings page must not expose the analyze action" ); assert!( - !body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)), - "recordings page must not expose the delete action" + !body.contains(&format!(r#"action="/web/cases/{case_id}/close""#)), + "recordings page must not expose the close action" ); } diff --git a/server/tests/delete_watermark_test.rs b/server/tests/close_watermark_test.rs similarity index 81% rename from server/tests/delete_watermark_test.rs rename to server/tests/close_watermark_test.rs index aa20dd5..0031edf 100644 --- a/server/tests/delete_watermark_test.rs +++ b/server/tests/close_watermark_test.rs @@ -1,8 +1,8 @@ -//! Listen-relevante Mutationen (Einzel-Delete, Undo, Bulk-Delete) müssen -//! den pro-User Watermark bumpen. Sonst bleibt der ETag von +//! Listen-relevante Mutationen (Close, Reopen, Bulk-Close) 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 +//! bereits geschlossen hat (ursprünglicher Bugreport: "Polymyalgia //! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits //! gelöscht). @@ -28,7 +28,7 @@ fn make_user(slug: &str, password_plain: &str) -> User { fn test_config_with_users(users: Vec) -> Arc { let data_path = std::env::temp_dir().join(format!( - "doctate-delete-wm-test-{}-{}", + "doctate-close-wm-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); @@ -106,7 +106,7 @@ async fn capture_etag( } #[tokio::test] -async fn delete_bumps_watermark() { +async fn close_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"; @@ -126,13 +126,13 @@ async fn delete_bumps_watermark() { assert_eq!(s1, StatusCode::OK); let etag_before = etag_before.expect("server must set ETag"); - // Soft-delete via the web handler. + // Close via the web handler. let del = app .clone() .oneshot( Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/delete")) + .uri(format!("/web/cases/{case_id}/close")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), @@ -142,7 +142,7 @@ async fn delete_bumps_watermark() { assert_eq!( del.status().as_u16() / 100, 3, - "delete should redirect (3xx)" + "close should redirect (3xx)" ); // Same If-None-Match now must NOT match — watermark was bumped. @@ -155,12 +155,12 @@ async fn delete_bumps_watermark() { 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" + "close handler must change the per-user ETag" ); } #[tokio::test] -async fn undo_delete_bumps_watermark() { +async fn reopen_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"; @@ -175,49 +175,49 @@ async fn undo_delete_bumps_watermark() { .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. + // Close first so reopen has something to restore; grab the post-close ETag. app.clone() .oneshot( Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/delete")) + .uri(format!("/web/cases/{case_id}/close")) .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"); + let (_, etag_after_close) = capture_etag(&app, "key-dr_a", None).await; + let etag_after_close = etag_after_close.expect("ETag must be set"); - // Undo. - let undo = app + // Reopen. + let reopen = app .clone() .oneshot( Request::builder() .method("POST") - .uri("/web/cases/undo-delete") + .uri(format!("/web/cases/{case_id}/reopen")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); - assert_eq!(undo.status().as_u16() / 100, 3, "undo should redirect"); + assert_eq!(reopen.status().as_u16() / 100, 3, "reopen 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; + // Reopen must bump again → post-close ETag becomes stale. + let (s, etag_after_reopen) = capture_etag(&app, "key-dr_a", Some(&etag_after_close)).await; assert_eq!( s, StatusCode::OK, - "undo must change the ETag so clients re-fetch" + "reopen 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); + let etag_after_reopen = etag_after_reopen.expect("200 response must set ETag"); + assert_ne!(etag_after_reopen, etag_after_close); } #[tokio::test] -async fn bulk_delete_bumps_watermark() { +async fn bulk_close_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"; @@ -237,8 +237,8 @@ async fn bulk_delete_bumps_watermark() { 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}"); + // Form-encoded bulk close: action=close&case_id=a&case_id=b. + let form = format!("action=close&case_id={case_a}&case_id={case_b}"); let bulk = app .clone() .oneshot( @@ -255,7 +255,7 @@ async fn bulk_delete_bumps_watermark() { 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"); + assert_eq!(s, StatusCode::OK, "bulk-close must bump the ETag"); let etag_after = etag_after.expect("200 response must set ETag"); assert_ne!(etag_after, etag_before); }