refactor: rename soft-delete marker from .deleted to .closed

Internal rename: DELETE_MARKER -> CLOSE_MARKER, DeleteMarker ->
CloseMarker, is_deleted -> is_closed. The batch UUID is dropped;
undo now groups cases by their exact closed_at timestamp, which
bulk-close writes identically across its selection while per-case
close writes a unique stamp.

Behavior unchanged. No migration (project is pre-production);
legacy .deleted files in tmpdata were removed manually.
This commit is contained in:
2026-04-21 10:36:55 +02:00
parent a8389a89db
commit 9410d6daaa
13 changed files with 131 additions and 145 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ pub async fn try_enqueue_all_for_user(
if !case_path.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_path).await {
if crate::paths::is_closed(&case_path).await {
continue;
}
try_enqueue(&case_path, tx, config, events_tx).await;
+1 -1
View File
@@ -27,7 +27,7 @@ pub async fn enqueue_pending_for_user(user_root: &Path, tx: &AnalyzeSender) -> u
let mut sent = 0;
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await {
if !case_dir.is_dir() || crate::paths::is_closed(&case_dir).await {
continue;
}
if !case_dir.join(ANALYSIS_INPUT_FILE).exists() {
+22 -22
View File
@@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
/// Absolute on-disk location of a case directory.
///
@@ -14,39 +13,40 @@ pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
data_path.join(slug).join(case_id)
}
/// Filename of the soft-delete marker inside a case directory.
pub const DELETE_MARKER: &str = ".deleted";
/// Filename of the close marker inside a case directory. A case with
/// this marker is "closed": hidden from the default listing, reversible
/// via a new upload or the explicit reopen action, and eligible for
/// lazy auto-purge after `auto_delete_days`.
pub const CLOSE_MARKER: &str = ".closed";
/// Soft-delete marker payload. One file per deleted case; all cases removed
/// in the same user click share the same `batch` UUID. `deleted_at` is an
/// RFC3339 timestamp — used to find the most recent batch for "Undo last
/// delete".
/// Close-marker payload. `closed_at` is an RFC3339 timestamp and the
/// authoritative input for purge eligibility (`now - closed_at > auto_delete_days`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMarker {
pub batch: Uuid,
pub deleted_at: String,
pub struct CloseMarker {
pub closed_at: String,
}
/// True iff the case directory contains a `.deleted` marker file.
pub async fn is_deleted(case_dir: &Path) -> bool {
tokio::fs::try_exists(case_dir.join(DELETE_MARKER))
/// True iff the case directory contains a `.closed` marker file.
pub async fn is_closed(case_dir: &Path) -> bool {
tokio::fs::try_exists(case_dir.join(CLOSE_MARKER))
.await
.unwrap_or(false)
}
/// Read and parse the `.deleted` marker. Returns `None` if absent or malformed
/// (treated identically — a malformed marker still means "deleted" via
/// `is_deleted`, but cannot participate in batch-undo).
pub async fn read_delete_marker(case_dir: &Path) -> Option<DeleteMarker> {
let bytes = tokio::fs::read(case_dir.join(DELETE_MARKER)).await.ok()?;
/// Read and parse the `.closed` marker. Returns `None` if absent or
/// malformed (treated identically — a malformed marker still means
/// "closed" via `is_closed`, but cannot participate in timestamp-based
/// grouping or purge checks).
pub async fn read_close_marker(case_dir: &Path) -> Option<CloseMarker> {
let bytes = tokio::fs::read(case_dir.join(CLOSE_MARKER)).await.ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Write the `.deleted` marker as JSON. Overwrites any existing marker.
pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std::io::Result<()> {
/// Write the `.closed` marker as JSON. Overwrites any existing marker.
pub async fn write_close_marker(case_dir: &Path, marker: &CloseMarker) -> std::io::Result<()> {
let bytes = serde_json::to_vec(marker)
.map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?;
tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await
.map_err(|e| std::io::Error::other(format!("serialize close marker: {e}")))?;
tokio::fs::write(case_dir.join(CLOSE_MARKER), bytes).await
}
/// Read the persisted oneliner state for a case.
+9 -18
View File
@@ -3,9 +3,8 @@ use std::sync::Arc;
use axum::extract::State;
use axum::response::Redirect;
use axum_extra::extract::Form;
use doctate_common::timestamp::now_rfc3339;
use serde::Deserialize;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::{info, warn};
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
@@ -13,7 +12,7 @@ use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::events::{self, CaseEventKind, EventSender};
use crate::paths::{DeleteMarker, write_delete_marker};
use crate::paths::{CloseMarker, write_close_marker};
use crate::routes::case_actions::{
build_analysis_input, reset_case_artefacts, write_input_create_new,
};
@@ -140,16 +139,9 @@ async fn bulk_delete(
user_root: &std::path::Path,
case_ids: &[String],
) {
// One batch UUID + one timestamp shared across the entire bulk action,
// so undo can restore exactly this group.
let batch = uuid::Uuid::new_v4();
let deleted_at = match OffsetDateTime::now_utc().format(&Rfc3339) {
Ok(s) => s,
Err(e) => {
warn!(slug = %slug, error = %e, "bulk-delete: failed to format timestamp");
return;
}
};
// 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 {
@@ -163,11 +155,10 @@ async fn bulk_delete(
skipped += 1;
continue;
};
let marker = DeleteMarker {
batch,
deleted_at: deleted_at.clone(),
let marker = CloseMarker {
closed_at: closed_at.clone(),
};
if let Err(e) = write_delete_marker(&case_dir, &marker).await {
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");
skipped += 1;
continue;
@@ -175,7 +166,7 @@ async fn bulk_delete(
events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted);
ok += 1;
}
info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed");
info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-delete completed");
}
async fn bulk_reset(
+42 -47
View File
@@ -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::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
use crate::paths::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker};
use crate::routes::user_web::locate_case_or_404;
use crate::routes::web::validate_filename;
@@ -278,10 +278,10 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()>
/// POST /web/cases/{case_id}/delete
///
/// Soft-delete: writes a `.deleted` JSON marker into the case directory.
/// 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 `deleted_at` timestamp among the user's deleted cases.
/// most recent `closed_at` timestamp among the user's closed cases.
pub async fn handle_delete_case(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
@@ -291,17 +291,16 @@ pub async fn handle_delete_case(
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 marker = DeleteMarker {
batch: uuid::Uuid::new_v4(),
deleted_at: now_rfc3339(),
let marker = CloseMarker {
closed_at: now_rfc3339(),
};
write_delete_marker(&case_dir, &marker).await?;
write_close_marker(&case_dir, &marker).await?;
info!(
slug = %user.slug,
case_id = %case_id,
batch = %marker.batch,
"case soft-deleted"
closed_at = %marker.closed_at,
"case closed"
);
events::emit(
@@ -351,10 +350,11 @@ pub async fn handle_reset_case(
/// POST /web/cases/undo-delete
///
/// Restore every case in the most recent delete batch by removing its
/// `.deleted` marker. "Most recent" = highest `deleted_at` across all
/// markers under the user's data directory; ties broken by `batch`-UUID
/// lexicographic order. No-op if no markers exist.
/// 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<Arc<Config>>,
@@ -362,23 +362,22 @@ pub async fn handle_undo_delete(
) -> Result<Redirect, AppError> {
let user_root = config.data_path.join(&user.slug);
let latest = latest_delete_batch(&user_root).await;
let Some((batch, _)) = latest else {
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_batch(&user_root, batch, &events_tx, &user.slug).await;
info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed");
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 delete batch under `user_root`: returns
/// `(batch_uuid, count)` where count is the number of cases sharing that
/// batch. `None` if no `.deleted` markers exist. Single directory scan.
pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uuid, usize)> {
let (batch, _) = latest_delete_batch(user_root).await?;
/// 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 {
@@ -386,48 +385,44 @@ pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uu
if !case_path.is_dir() {
continue;
}
if let Some(marker) = read_delete_marker(&case_path).await
&& marker.batch == batch
if let Some(marker) = read_close_marker(&case_path).await
&& marker.closed_at == group_ts
{
count += 1;
}
}
Some((batch, count))
Some((group_ts, count))
}
/// Scan the user's data dir, return the `(batch, deleted_at)` of the
/// most-recently-deleted case across all markers. `None` if no markers.
async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
/// 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<String> {
let mut entries = tokio::fs::read_dir(user_root).await.ok()?;
let mut best: Option<(uuid::Uuid, String)> = None;
let mut best: Option<String> = None;
while let Ok(Some(entry)) = entries.next_entry().await {
if !entry.path().is_dir() {
continue;
}
let Some(marker) = read_delete_marker(&entry.path()).await else {
let Some(marker) = read_close_marker(&entry.path()).await else {
continue;
};
let candidate = (marker.batch, marker.deleted_at);
best = match best {
None => Some(candidate),
// Order: deleted_at desc, then batch desc as tie-breaker.
Some(ref cur)
if candidate.1 > cur.1 || (candidate.1 == cur.1 && candidate.0 > cur.0) =>
{
Some(candidate)
}
None => Some(marker.closed_at),
Some(cur) if marker.closed_at > cur => Some(marker.closed_at),
other => other,
};
}
best
}
/// Remove `.deleted` markers from every case whose marker carries `batch`.
/// Returns the number of restored cases. Emits a `CaseRestored` event per
/// case so subscribed browsers can update their listings.
async fn restore_batch(
/// 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,
batch: uuid::Uuid,
group_ts: &str,
events_tx: &EventSender,
user_slug: &str,
) -> usize {
@@ -441,14 +436,14 @@ async fn restore_batch(
if !case_path.is_dir() {
continue;
}
let Some(marker) = read_delete_marker(&case_path).await else {
let Some(marker) = read_close_marker(&case_path).await else {
continue;
};
if marker.batch != batch {
if marker.closed_at != group_ts {
continue;
}
if let Err(e) = tokio::fs::remove_file(case_path.join(DELETE_MARKER)).await {
warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker");
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(
+1 -1
View File
@@ -140,7 +140,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult {
if uuid::Uuid::parse_str(case_id).is_err() {
continue;
}
if crate::paths::is_deleted(&case_dir).await {
if crate::paths::is_closed(&case_dir).await {
continue;
}
+5 -5
View File
@@ -74,18 +74,18 @@ pub async fn handle_upload(
// Find or create the case directory.
let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?;
// Auto-reopen soft-deleted cases: a new upload clears the `.deleted`
// Auto-reopen closed cases: a new upload clears the `.closed`
// marker so the case reappears in /api/oneliners and the web UI.
// Hard-deleted cases (directory already gone) are handled transparently
// Hard-purged cases (directory already gone) are handled transparently
// by `resolve_case_dir` above — nothing to clear. No explicit ETag
// bump is needed: the subsequent m4a write changes `last_recording_at`,
// which flows into the `/api/oneliners` fingerprint automatically.
if crate::paths::is_deleted(&case_dir).await {
tokio::fs::remove_file(case_dir.join(crate::paths::DELETE_MARKER)).await?;
if crate::paths::is_closed(&case_dir).await {
tokio::fs::remove_file(case_dir.join(crate::paths::CLOSE_MARKER)).await?;
info!(
user = %user.slug,
case_id = %case_id,
"Reopened soft-deleted case via new upload"
"Reopened closed case via new upload"
);
events::emit(
&events_tx,
+6 -6
View File
@@ -319,7 +319,7 @@ 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_batch(&user_root)
let undo_count = crate::routes::case_actions::summarize_latest_close_group(&user_root)
.await
.map(|(_, n)| n)
.unwrap_or(0);
@@ -478,14 +478,14 @@ pub async fn handle_case_recordings(
}
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
/// if the directory does not exist OR is soft-deleted (`.deleted` marker
/// if the directory does not exist OR is closed (`.closed` marker
/// present). Both are treated as 404 / IDOR probe by callers.
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::path::PathBuf> {
let p = user_root.join(case_id);
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
return None;
}
if crate::paths::is_deleted(&p).await {
if crate::paths::is_closed(&p).await {
return None;
}
Some(p)
@@ -519,8 +519,8 @@ pub(crate) async fn locate_case_or_404(
/// 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. Soft-deleted cases (with
/// `.deleted` marker) are excluded.
/// 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> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
@@ -560,7 +560,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_deleted(&case_path).await {
if crate::paths::is_closed(&case_path).await {
return None;
}
Some((case_id, case_path))
+1 -1
View File
@@ -41,7 +41,7 @@ pub async fn handle_audio(
validate_filename(&filename)?;
let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id);
if crate::paths::is_deleted(&case_path).await {
if crate::paths::is_closed(&case_path).await {
return Err(AppError::NotFound("Audio file not found".into()));
}
let file_path = case_path.join(&filename);
+6 -5
View File
@@ -46,7 +46,7 @@ pub async fn enqueue_pending_for_user(
let mut pending: Vec<std::path::PathBuf> = Vec::new();
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await {
if !case_dir.is_dir() || crate::paths::is_closed(&case_dir).await {
continue;
}
let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else {
@@ -128,7 +128,7 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
};
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
if !case_dir.is_dir() || paths::is_closed(&case_dir).await {
continue;
}
let (state, _) = paths::read_oneliner_state(&case_dir).await;
@@ -308,15 +308,16 @@ mod tests {
}
#[tokio::test]
async fn cases_needing_oneliner_skips_deleted_cases() {
async fn cases_needing_oneliner_skips_closed_cases() {
let data = tempdir().unwrap();
let case = data.path().join("user").join("case1");
tokio::fs::create_dir_all(&case).await.unwrap();
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
.await
.unwrap();
// Empty JSON object is a valid delete marker (see paths::is_deleted).
tokio::fs::write(case.join(".deleted"), "{}").await.unwrap();
// Empty JSON object is treated as a valid close marker
// (see paths::is_closed — existence of the file is enough).
tokio::fs::write(case.join(".closed"), "{}").await.unwrap();
assert!(cases_needing_oneliner(data.path()).await.is_empty());
}
+20 -20
View File
@@ -628,14 +628,13 @@ async fn delete_writes_marker_and_redirects() {
.unwrap(),
"/web/cases"
);
let marker = case_dir.join(".deleted");
assert!(marker.exists(), ".deleted marker missing");
let marker = case_dir.join(".closed");
assert!(marker.exists(), ".closed marker missing");
let raw = std::fs::read_to_string(&marker).unwrap();
let parsed: Value = serde_json::from_str(&raw).unwrap();
assert!(parsed["batch"].is_string(), "batch must be a UUID string");
assert!(
parsed["deleted_at"].is_string(),
"deleted_at must be a string"
parsed["closed_at"].is_string(),
"closed_at must be a string"
);
}
@@ -670,7 +669,7 @@ async fn deleted_case_returns_404_on_detail() {
}
#[tokio::test]
async fn undo_delete_restores_latest_batch_only() {
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";
@@ -682,15 +681,16 @@ async fn undo_delete_restores_latest_batch_only() {
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
// Two separate delete clicks → two distinct batches.
// Two separate close clicks → two distinct close-groups (distinct
// `closed_at` timestamps).
let _ = app
.clone()
.oneshot(delete_request(case_a, &cookie))
.await
.unwrap();
// Batch IDs derive from `now_rfc3339()`, which rounds to whole
// seconds — so the sleep must cross a second boundary for the two
// deletes to land in distinct batches.
// `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()
@@ -712,16 +712,16 @@ async fn undo_delete_restores_latest_batch_only() {
.unwrap();
assert_eq!(undo.status(), StatusCode::SEE_OTHER);
// case_b (latest delete) restored, case_a still deleted.
// case_b (latest close) restored, case_a still closed.
assert!(
!dir_b.join(".deleted").exists(),
!dir_b.join(".closed").exists(),
"case_b marker should be gone"
);
assert!(dir_a.join(".deleted").exists(), "case_a marker must remain");
assert!(dir_a.join(".closed").exists(), "case_a marker must remain");
}
#[tokio::test]
async fn bulk_delete_shares_one_batch_uuid() {
async fn bulk_delete_shares_one_closed_at_timestamp() {
let config = config_with_llm(unique_tmp("bulk-d"), "http://unused".into());
let case_a = "11111111-1111-1111-1111-111111111111";
let case_b = "22222222-2222-2222-2222-222222222222";
@@ -749,12 +749,12 @@ async fn bulk_delete_shares_one_batch_uuid() {
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let m_a: Value =
serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap();
serde_json::from_str(&std::fs::read_to_string(dir_a.join(".closed")).unwrap()).unwrap();
let m_b: Value =
serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap();
serde_json::from_str(&std::fs::read_to_string(dir_b.join(".closed")).unwrap()).unwrap();
assert_eq!(
m_a["batch"], m_b["batch"],
"bulk-delete must share batch UUID"
m_a["closed_at"], m_b["closed_at"],
"bulk-delete must share one closed_at timestamp so undo can group them"
);
}
@@ -797,8 +797,8 @@ async fn recovery_skips_deleted_cases() {
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap();
std::fs::write(
case_dir.join(".deleted"),
r#"{"batch":"00000000-0000-0000-0000-000000000000","deleted_at":"2026-04-15T10:00:00Z"}"#,
case_dir.join(".closed"),
r#"{"closed_at":"2026-04-15T10:00:00Z"}"#,
)
.unwrap();
+1 -1
View File
@@ -75,7 +75,7 @@ async fn write_state(case_dir: &Path, state: &OnelinerState) {
}
async fn mark_deleted(case_dir: &Path) {
tokio::fs::write(case_dir.join(".deleted"), "{}")
tokio::fs::write(case_dir.join(".closed"), "{}")
.await
.unwrap();
}
+16 -17
View File
@@ -6,7 +6,7 @@ use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
use doctate_server::paths::{DELETE_MARKER, DeleteMarker, write_delete_marker};
use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker};
fn test_config() -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
@@ -247,11 +247,11 @@ async fn upload_second_recording_same_case() {
let _ = std::fs::remove_dir_all(&data_path);
}
/// Scenario A: a new upload to a *soft-deleted* case must silently
/// remove the `.deleted` marker and store the new audio. Reopens the
/// case so it reappears in /api/oneliners without any manual "undo".
/// Scenario A: a new upload to a *closed* case must silently remove
/// the `.closed` marker and store the new audio. Reopens the case so
/// it reappears in /api/oneliners without any manual "undo".
#[tokio::test]
async fn upload_reopens_soft_deleted_case() {
async fn upload_reopens_closed_case() {
let config = test_config();
let data_path = config.data_path.clone();
let app = doctate_server::create_router(config);
@@ -278,18 +278,17 @@ async fn upload_reopens_soft_deleted_case() {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Soft-delete the case by writing the marker directly — simulates
// the user hitting the delete button in the web UI without having
// to drive the /web/cases/{id}/delete handler here. Direct write
// also means the watermark is NOT bumped here; that way the next
// GET /api/oneliners pins down the pre-reopen ETag cleanly.
// Close the case by writing the marker directly — simulates the
// user hitting the close button in the web UI without having to
// drive the /web/cases/{id}/delete handler here. Direct write also
// means the watermark is NOT bumped here; that way the next GET
// /api/oneliners pins down the pre-reopen ETag cleanly.
let case_dir = data_path.join("dr_test").join(case_id);
let marker = DeleteMarker {
batch: uuid::Uuid::new_v4(),
deleted_at: "2026-04-13T11:00:00Z".into(),
let marker = CloseMarker {
closed_at: "2026-04-13T11:00:00Z".into(),
};
write_delete_marker(&case_dir, &marker).await.unwrap();
assert!(case_dir.join(DELETE_MARKER).exists());
write_close_marker(&case_dir, &marker).await.unwrap();
assert!(case_dir.join(CLOSE_MARKER).exists());
// Capture the ETag before the reopen.
let etag_before = {
@@ -335,8 +334,8 @@ async fn upload_reopens_soft_deleted_case() {
// Marker is gone → case is visible again.
assert!(
!case_dir.join(DELETE_MARKER).exists(),
".deleted marker must be removed after re-upload"
!case_dir.join(CLOSE_MARKER).exists(),
".closed marker must be removed after re-upload"
);
// Both audios are on disk — the old one was not touched.
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());