diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 520da2b0..2c296223 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -653,6 +653,8 @@ async fn overwrite_and_delete_item( .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: the deleted source queued its undelete task + server.notify_task_queue(); Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) } @@ -807,6 +809,9 @@ async fn move_item( .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: a move to another account deletes the source, which queued + // its undelete task + server.notify_task_queue(); Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } diff --git a/crates/groupware/src/file/storage.rs b/crates/groupware/src/file/storage.rs index d139d058..b9181587 100644 --- a/crates/groupware/src/file/storage.rs +++ b/crates/groupware/src/file/storage.rs @@ -129,6 +129,10 @@ impl DestroyArchive> { .commit_batch(batch) .await .caused_by(trc::location!())?; + // inbuxa: deleted files queue the task that notes them for + // undelete; wake the task manager rather than leave the task to + // its next periodic look at the queue + server.notify_task_queue(); } Ok(()) diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index e73e5aa2..619a3b77 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -495,6 +495,8 @@ impl FileNodeCopy for Server { .await .and_then(|ids| ids.last_change_id(account_id)) .caused_by(trc::location!())?; + // inbuxa: replaced files queued their undelete tasks + self.notify_task_queue(); response.new_state = State::Exact(change_id); } diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index d280f660..394a3668 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -764,6 +764,8 @@ impl FileNodeSet for Server { .await .and_then(|ids| ids.last_change_id(account_id)) .caused_by(trc::location!())?; + // inbuxa: destroyed files queued their undelete tasks + self.notify_task_queue(); response.new_state = State::Exact(change_id).into(); } diff --git a/tests/src/utils/storage.rs b/tests/src/utils/storage.rs index 22038e00..796ffbde 100644 --- a/tests/src/utils/storage.rs +++ b/tests/src/utils/storage.rs @@ -341,6 +341,39 @@ pub async fn wait_for_tasks(server: &Server, skip_not_due: bool, skip_permanent_ } } +/// inbuxa: waits for the task queue to empty, giving up after `within`, and +/// returns the task still queued then. The task manager only looks at the +/// queue again when woken or every few minutes, so a write that queues a +/// task without waking it shows up here as a task that sits. +pub async fn task_left_after(server: &Server, within: std::time::Duration) -> Option { + let give_up_at = std::time::Instant::now() + within; + loop { + let mut pending = None; + server + .core + .storage + .data + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: 0 })), + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), + ) + .ascending(), + |_, value| { + pending = Some(Task::deserialize(value)?); + Ok(false) + }, + ) + .await + .unwrap(); + + if pending.is_none() || std::time::Instant::now() > give_up_at { + return pending; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + pub async fn assert_is_empty(server: &Server, include_registry: bool) { // Wait for pending index tasks wait_for_tasks(server, false, false).await; diff --git a/tests/src/webdav/file_tasks.rs b/tests/src/webdav/file_tasks.rs new file mode 100644 index 00000000..7799540c --- /dev/null +++ b/tests/src/webdav/file_tasks.rs @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// inbuxa: deleting a file queues the task that archives it for undelete +// (UD-1). Each way of deleting one must wake the task manager, or that task +// sits until its next periodic look at the queue, minutes later. + +use crate::utils::{server::TestServer, storage::task_left_after}; +use hyper::StatusCode; +use serde_json::json; +use std::time::Duration; + +const BASE: &str = "/dav/file/john%40example.com"; + +pub async fn test(test: &TestServer) { + println!("Running file deletion task tests..."); + let account = test.account("john@example.com"); + let client = account.webdav_client(); + test.wait_for_tasks().await; + + // WebDAV DELETE + let path = format!("{BASE}/tasks-1.txt"); + put(test, &path).await; + client + .request("DELETE", &path, "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV DELETE").await; + + // WebDAV COPY over an existing file deletes the file it replaces + let (from, to) = (format!("{BASE}/tasks-2.txt"), format!("{BASE}/tasks-3.txt")); + put(test, &from).await; + put(test, &to).await; + client + .request_with_headers("COPY", &from, [("destination", to.as_str())], "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV COPY over a file").await; + + // WebDAV MOVE over an existing file deletes the source + client + .request_with_headers("MOVE", &from, [("destination", to.as_str())], "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV MOVE over a file").await; + client + .request("DELETE", &to, "") + .await + .with_status(StatusCode::NO_CONTENT); + assert_tasks_run(test, "WebDAV DELETE").await; + + // JMAP FileNode/set destroy + let response = account + .jmap_method_calls(json!([ + ["Blob/upload", { + "accountId": account.id_string(), + "create": { "file": { "data": [{ "data:asText": "tasks" }] } } + }, "0"], + ["FileNode/set", { + "accountId": account.id_string(), + "create": { "i0": { "name": "tasks-4.txt", "parentId": null, "blobId": "#file" } } + }, "1"] + ])) + .await + .into_inner(); + let file_id = response + .pointer("/methodResponses/1/1/created/i0/id") + .and_then(|id| id.as_str()) + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let response = account + .jmap_method_calls(json!([ + ["FileNode/set", { + "accountId": account.id_string(), + "destroy": [&file_id] + }, "0"] + ])) + .await + .into_inner(); + assert_eq!( + response.pointer("/methodResponses/0/1/destroyed/0"), + Some(&json!(file_id)), + "{response}" + ); + assert_tasks_run(test, "JMAP FileNode/set destroy").await; +} + +async fn put(test: &TestServer, path: &str) { + test.account("john@example.com") + .webdav_client() + .request_with_headers("PUT", path, [("content-type", "text/plain")], "tasks") + .await + .with_status(StatusCode::CREATED); +} + +async fn assert_tasks_run(test: &TestServer, what: &str) { + if let Some(task) = task_left_after(&test.server, Duration::from_secs(10)).await { + panic!("{what}: a task was still queued after 10 s: {task:?}"); + } +} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index c9f495e0..dfd0903a 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServerBuilder; @@ -32,6 +34,7 @@ pub mod cal_query; pub mod cal_scheduling; pub mod card_query; pub mod copy_move; +pub mod file_tasks; // inbuxa: file deletes wake the task manager pub mod lock; pub mod mkcol; pub mod multiget; @@ -183,6 +186,7 @@ pub async fn webdav_tests() { } basic::test(&test).await; put_get::test(&test).await; + file_tasks::test(&test).await; mkcol::test(&test).await; copy_move::test(&test, assisted_discovery).await; prop::test(&test, assisted_discovery).await;