feat: show-closed toggle, closed badge, reopen button, bulk purge

GET /web/cases now honours ?show_closed=1: the toggle link flips the
listing between open-only (default) and open+closed (muted styling,
grey "geschlossen" badge, countdown "wird in N Tagen entfernt" when
auto_delete_days > 0). Each closed row shows a Lucide rotate-ccw
reopen button in the same slot where the close button sits on open
cases; selection checkboxes are suppressed so bulk-close cannot touch
closed cases.

GET /web/cases/:id honours ?show_closed=1 too: without the query a
closed case still 404s (keeps the default listing clean), with it the
detail page renders the reopen form in the header. The case_page
template swaps the icon based on is_closed.

The purge-closed bulk button from the earlier commit finally gets a
UI: it appears below the listing when show_closed=1 is active AND at
least one closed case is visible, with a JS confirm() + the
server-side confirm=yes guard. Two integration tests (listing +
detail) cover the happy path.
This commit is contained in:
2026-04-21 10:58:00 +02:00
parent f358da215d
commit 23e828df45
4 changed files with 356 additions and 33 deletions
+166 -20
View File
@@ -3,9 +3,10 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::State;
use axum::extract::{Query, State};
use axum::response::Html;
use doctate_common::oneliners::OnelinerState;
use serde::Deserialize;
use tracing::{info, warn};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
@@ -73,6 +74,15 @@ struct UserCaseView {
/// Requires: not currently analyzing, all non-failed recordings
/// transcribed, and an LLM is configured.
can_analyze: bool,
/// True iff the case carries a `.closed` marker. Drives the muted
/// styling, the Close/Reopen button swap, and the purge countdown.
is_closed: bool,
/// Remaining days before auto-purge. `Some(n)` only when the case
/// is closed AND `auto_delete_days > 0`; `None` otherwise (so the
/// template can render "wird in N Tagen entfernt" vs. plain
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
/// sweep render as "0" briefly.
days_until_purge: Option<u32>,
}
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
@@ -154,6 +164,30 @@ struct MyCasesTemplate {
total: usize,
/// True iff the session user is an admin — toggles full case_id display.
is_admin: bool,
/// Reflects the `?show_closed=1` query param. Controls the toggle
/// link state and makes the bulk-purge button + per-case reopen
/// buttons visible.
show_closed: bool,
/// True iff at least one closed case is currently visible. Needed
/// to gate the bulk-purge bar so it does not appear on an
/// all-open list rendered with `show_closed=1`.
any_closed: bool,
}
/// Query params for GET /web/cases and /web/cases/{case_id}.
#[derive(Debug, Default, Deserialize)]
pub struct CaseListQuery {
/// `?show_closed=1` expands the list to include closed cases and
/// swaps per-case close buttons with reopen buttons. Any other
/// value (missing, "0", empty) keeps the default "open only" view.
#[serde(default)]
show_closed: Option<String>,
}
impl CaseListQuery {
fn include_closed(&self) -> bool {
matches!(self.show_closed.as_deref(), Some("1"))
}
}
#[derive(Template)]
@@ -179,6 +213,11 @@ struct CasePageTemplate {
analyzing: bool,
has_document: bool,
is_admin: bool,
/// True when the case carries a `.closed` marker. Toggles the
/// header action button between close and reopen, and appends
/// a "?show_closed=1" to the form action so a freshly reopened
/// case still lands on the detail page.
is_closed: bool,
}
#[derive(Template)]
@@ -293,6 +332,7 @@ pub async fn handle_my_cases(
State(events_tx): State<EventSender>,
State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>,
Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline
@@ -316,14 +356,26 @@ pub async fn handle_my_cases(
// reusing the user's retention policy from users.toml. Runs before
// the listing scan so any auto-close/auto-purge actions are
// reflected in the same response.
if let Some(u) = config.users.iter().find(|u| u.slug == user.slug) {
crate::retention::sweep_user_retention(&user_root, &user.slug, &u.retention, &events_tx)
.await;
}
let retention = config
.users
.iter()
.find(|u| u.slug == user.slug)
.map(|u| u.retention.clone())
.unwrap_or_default();
crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
let show_closed = query.include_closed();
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await;
let cases = scan_user_cases(
&config,
&user.slug,
busy,
show_closed,
retention.auto_delete_days,
)
.await;
let total = cases.len();
let any_closed = cases.iter().any(|c| c.is_closed);
let groups = group_by_utc_date(cases);
let is_admin = user.is_admin();
MyCasesTemplate {
@@ -331,6 +383,8 @@ pub async fn handle_my_cases(
groups,
total,
is_admin,
show_closed,
any_closed,
}
.render()
.map(Html)
@@ -343,6 +397,7 @@ pub async fn handle_my_cases(
/// shows status (analyzing / placeholder for empty / recordings-summary).
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
/// recordings sub-page.
#[allow(clippy::too_many_arguments)]
pub async fn handle_case_page(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
@@ -351,6 +406,7 @@ pub async fn handle_case_page(
State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>,
CaseIdPath(case_id): CaseIdPath,
Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline
@@ -364,13 +420,24 @@ pub async fn handle_case_page(
)
.await;
let case_dir = locate_case_or_404(
let show_closed = query.include_closed();
let case_dir = if show_closed {
locate_closed_case_or_404(
&user_root,
&case_id,
&user.slug,
"case page (show_closed=1)",
)
.await?
} else {
locate_case_or_404(
&user_root,
&case_id,
&user.slug,
"case page (possible IDOR probe)",
)
.await?;
.await?
};
// Auto-analysis trigger for deep-link navigation: same evaluation as
// the list view. Document render below still wins the race if the
// worker happens to finish synchronously.
@@ -402,6 +469,8 @@ pub async fn handle_case_page(
.filter(|s| !s.is_empty());
let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();
let is_closed = crate::paths::is_closed(&case_dir).await;
CasePageTemplate {
case_id: case_id_str,
case_id_short,
@@ -416,6 +485,7 @@ pub async fn handle_case_page(
analyzing: flags.analyzing,
has_document: flags.has_document,
is_admin,
is_closed,
}
.render()
.map(Html)
@@ -518,14 +588,47 @@ pub(crate) async fn locate_case_or_404(
}
}
/// Like `locate_case_or_404` but accepts closed cases. Only the detail
/// view uses this, and only when the user navigated there with
/// `?show_closed=1`. A non-existent directory still 404s as expected.
pub(crate) async fn locate_closed_case_or_404(
user_root: &Path,
case_id: &CaseId,
user_slug: &str,
context: &str,
) -> Result<std::path::PathBuf, AppError> {
let p = user_root.join(case_id.to_string());
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
warn!(
slug = %user_slug,
case_id = %case_id,
context = %context,
"case not found (show_closed)",
);
return Err(AppError::NotFound("Case not found".into()));
}
Ok(p)
}
/// Scan all of the given user's cases. Returned vec is sorted by most
/// recent recording filename (lexicographic ≈ chronological since
/// timestamps are ISO-8601), newest first. Closed cases (with
/// `.closed` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<UserCaseView> {
/// timestamps are ISO-8601), newest first.
///
/// `include_closed` flips the default "closed cases are hidden"
/// behaviour for the show-closed view. `auto_delete_days` is used to
/// compute the per-case `days_until_purge` countdown; pass 0 to
/// disable the countdown (maps to `None` on the view).
async fn scan_user_cases(
config: &Config,
slug: &str,
worker_busy: bool,
include_closed: bool,
auto_delete_days: u32,
) -> Vec<UserCaseView> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
let mut cases = Vec::new();
let now = OffsetDateTime::now_utc();
let mut entries = match tokio::fs::read_dir(&user_root).await {
Ok(r) => r,
@@ -533,11 +636,18 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Some((case_id, case_path)) = filter_valid_case_dir(entry).await else {
let Some((case_id, case_path)) = filter_valid_case_dir(entry, include_closed).await else {
continue;
};
if let Some(view) =
compute_case_view(case_id, &case_path, llm_configured, worker_busy).await
if let Some(view) = compute_case_view(
case_id,
&case_path,
llm_configured,
worker_busy,
auto_delete_days,
now,
)
.await
{
cases.push(view);
}
@@ -547,11 +657,14 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
cases
}
/// Accept an entry only if it is a non-deleted directory whose name is
/// 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)> {
/// Accept an entry only if it is a directory whose name is a valid UUID.
/// Closed cases are rejected unless `include_closed` is set; then the
/// caller (the show-closed listing) will downstream-render them with
/// muted styling and a badge.
async fn filter_valid_case_dir(
entry: tokio::fs::DirEntry,
include_closed: bool,
) -> 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");
@@ -561,7 +674,7 @@ async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, st
if !case_path.is_dir() {
return None;
}
if crate::paths::is_closed(&case_path).await {
if !include_closed && crate::paths::is_closed(&case_path).await {
return None;
}
Some((case_id, case_path))
@@ -574,6 +687,8 @@ async fn compute_case_view(
case_path: &Path,
llm_configured: bool,
worker_busy: bool,
auto_delete_days: u32,
now: OffsetDateTime,
) -> Option<UserCaseView> {
let recordings = scan_recordings(case_path).await;
if recordings.is_empty() {
@@ -598,6 +713,8 @@ async fn compute_case_view(
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured;
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
Some(UserCaseView {
@@ -610,9 +727,38 @@ async fn compute_case_view(
analyzing,
has_document,
can_analyze,
is_closed,
days_until_purge,
})
}
/// Read the close marker (if any) and compute the purge countdown.
/// Returns `(is_closed, days_until_purge)`. `days_until_purge` is
/// `None` whenever the purge sweep is disabled (`auto_delete_days = 0`)
/// or the case is still open — the template then suppresses the
/// countdown and falls back to the bare "geschlossen" badge.
async fn closed_state(
case_path: &Path,
auto_delete_days: u32,
now: OffsetDateTime,
) -> (bool, Option<u32>) {
let Some(marker) = crate::paths::read_close_marker(case_path).await else {
// No marker (open case) OR malformed marker. Malformed should
// have been caught by `is_closed` upstream; we treat it as open
// here to avoid a bogus countdown on a half-written file.
let open_but_marker_present = crate::paths::is_closed(case_path).await;
return (open_but_marker_present, None);
};
if auto_delete_days == 0 {
return (true, None);
}
let age_days = OffsetDateTime::parse(&marker.closed_at, &Rfc3339)
.map(|t| (now - t).whole_days().max(0))
.unwrap_or(0);
let remaining = (auto_delete_days as i64).saturating_sub(age_days).max(0);
(true, Some(remaining as u32))
}
/// Extract only the text from a `Ready` state; other states (Empty,
/// Error, missing file) collapse to `None`. Used by the recordings
/// sub-page, which only needs a plain title string as heading.
+32
View File
@@ -261,6 +261,37 @@
>{% else %}<span class="status-badge open">offen</span>{% endif
%}
</span>
{% if is_closed %}
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/reopen"
>
<button
type="submit"
class="delete-btn"
title="Fall wiedereröffnen"
aria-label="Fall wiedereröffnen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
</svg>
</button>
</form>
{% else %}
<form
class="delete-form"
method="post"
@@ -293,6 +324,7 @@
</svg>
</button>
</form>
{% endif %}
</h1>
{% match recorded_at_iso %} {% when Some with (iso) %}
<p class="case-time">
+34 -8
View File
@@ -36,6 +36,14 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
.case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
.case-row .actions .reopen-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
.case-row .actions .reopen-btn:hover { color: #4a90e2; background: #eef4fb; }
.case-row.closed { opacity: 0.65; background: #fafafa; }
.case-row.closed .body a { color: #777; }
.case-row .closed-badge { display: inline-block; margin-left: 0.5em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; background: #ddd; color: #444; }
.closed-toggle { display: block; margin: 0.5em 0 1em; font-size: 0.9em; color: #4a90e2; text-decoration: none; }
.closed-toggle:hover { text-decoration: underline; }
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
.label.analyzing { background: #eee; color: #555; }
@@ -43,8 +51,9 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
.bulk-bar .undo { margin-left: auto; }
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
.bulk-bar.purge { background: #fff0f0; border: 1px solid #e24a4a; }
.bulk-bar.purge button { background: #e24a4a; color: white; border: none; }
.bulk-bar.purge button:hover { background: #c43030; }
.header-right { display: flex; align-items: center; gap: 0.8em; }
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
@@ -67,8 +76,14 @@ try {
</div>
</header>
{% if show_closed %}
<a class="closed-toggle" href="/web/cases">← Nur offene Fälle anzeigen</a>
{% else %}
<a class="closed-toggle" href="/web/cases?show_closed=1">Geschlossene Fälle einblenden</a>
{% endif %}
{% if total == 0 %}
<section><p class="empty">Keine Fälle.</p></section>
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
{% for g in groups %}
@@ -76,19 +91,23 @@ try {
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}</span></h2>
<ul class="case-list">
{% for case in g.cases %}
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
<div class="check">{% if !case.is_closed %}<input type="checkbox" name="case_id" value="{{ case.case_id }}">{% endif %}</div>
<div class="body">
<a href="/web/cases/{{ case.case_id }}">
<a href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}">
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %}<span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</div>
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
</a>
</div>
<div class="actions">
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
{% if !case.is_closed && is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
{% endif %}
</div>
</li>
{% endfor %}
@@ -103,6 +122,13 @@ try {
<button type="submit" name="action" value="close">Schließen</button>
</div>
</form>
{% if show_closed && any_closed %}
<form class="bulk-bar purge" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
<button type="submit">Alle geschlossenen unwiderruflich löschen</button>
</form>
{% endif %}
{% endif %}
<script>
{% include "partials/time_format.js" %}
+119
View File
@@ -732,6 +732,125 @@ async fn reopen_removes_marker_and_restores_case() {
assert_eq!(detail.status(), StatusCode::OK);
}
#[tokio::test]
async fn show_closed_query_includes_closed_cases() {
let config = config_with_llm(unique_tmp("show-closed"), "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("a closed one"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
// Close first.
let _ = app
.clone()
.oneshot(close_request(case_id, &cookie))
.await
.unwrap();
// Default listing must NOT show it.
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/web/cases")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
!body.contains(case_id),
"default /web/cases must hide closed cases"
);
// With show_closed=1 it must appear, with the closed badge.
let resp = app
.oneshot(
Request::builder()
.uri("/web/cases?show_closed=1")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains(case_id),
"show_closed=1 must list closed cases"
);
assert!(
body.contains("geschlossen"),
"closed badge must render in show_closed mode"
);
assert!(
body.contains("reopen"),
"reopen button must be rendered for closed cases"
);
}
#[tokio::test]
async fn closed_case_detail_with_show_closed_returns_200() {
let config = config_with_llm(unique_tmp("show-detail"), "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("x"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let _ = app
.clone()
.oneshot(close_request(case_id, &cookie))
.await
.unwrap();
// Without query → 404 (covered by closed_case_returns_404_on_detail).
// With ?show_closed=1 → 200 with the reopen form.
let resp = app
.oneshot(
Request::builder()
.uri(format!("/web/cases/{case_id}?show_closed=1"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
body.contains(&format!("/web/cases/{case_id}/reopen")),
"detail page of a closed case must offer a reopen form"
);
assert!(
!body.contains(&format!("/web/cases/{case_id}/close\"")),
"detail page of a closed case must NOT offer a close form"
);
}
#[tokio::test]
async fn reopen_is_noop_on_open_case() {
let config = config_with_llm(unique_tmp("reopen-2"), "http://unused".into());