diff --git a/README.md b/README.md index 8031c00..6265f1e 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Moving a file or folder within the same volume is an offline cryptographic opera | | Telemetry | ✅ | Pluggable request tracking (`ITelemetry` analogue). | | **Drive / Volume** | Volume Resolution | ✅ | Auto-resolves volume lists; auto-creates the `my-files` root volume on first login. | | | Node Listing | ✅ | `get_node`, `enumerate_folder_children`, `enumerate_trash` with key resolution. | -| | File Operations | ✅ | Rename, trash, restore, delete, empty trash. | +| | File Operations | ✅ | Rename, trash, restore, delete, empty trash — batch calls report one outcome per node (`trash_nodes`), or stream them as each batch lands (`trash_nodes_streaming`). | | | Move Operations | ✅ | Same-volume move. Batch moves are chunked automatically. | | | Cross-volume Move | ❌ | Not supported (throwing `NotImplementedException` in the C# public API too). | | **Uploads** | Block Uploading | ✅ | Encrypts and uploads chunks (4 MiB default) to block storage. | @@ -275,7 +275,7 @@ Moving a file or folder within the same volume is an offline cryptographic opera | **Photos** | Photos Timeline | ✅ | `ProtonPhotosClient` maps photostream, timeline enumeration, and photo downloads. | | | Photo Uploads | ✅ | Uploading photos with `PhotoUploadMetadata` (capture time, tags, grouping). | | | Albums (read) | ✅ | List albums (`enumerate_album_node_uids`) and their photos (`enumerate_album`); album nodes carry `Node::album`. | -| | Photo Tags | ✅ | Add/remove classification tags (`update_photos`); `Favorite` on photos in our own timeline. | +| | Photo Tags | ✅ | Add/remove classification tags (`update_photos`, or `update_photos_streaming` for per-photo outcomes as they finish); `Favorite` on photos in our own timeline. | | | Shared Photos | ✅ | Photos/albums shared *with* us (`enumerate_shared_with_me_node_uids`, `enumerate_shared_with_me_album_uids`). | | | Favorite Shared Photos | ❌ | Needs the photo re-encrypted for our timeline root; not ported. | | | Album Writes | ❌ | Creating albums and adding/removing photos is not ported. | diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index 0847844..43df284 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `941aafa55fb179c8ee347c9d0b4660a3fc893f09` -- **Date**: 2026-07-31 +- **Pinned**: `df6b6bfde620c12868cd5a444d027f8d01974b75` +- **Date**: 2026-08-10 > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > exist in its history. Triage by commit subject/date when a pin goes stale. @@ -33,3 +33,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. | | 2026-07-31 | `e0ad37e3` → `941aafa5` | `42526703`, `f2fa871b`, `814ae781`, `c528a376` | ported: **node-name validation** (`validate_node_name`: empty or >255 chars rejected before create/rename; counted in `char`s — C# counts UTF-16 units, and neither is the server's own accounting); **album read ops** (`AlbumDto` + `enumerate_album_node_uids` / `enumerate_album`, `GET photos/volumes/{vid}/albums` and `…/albums/{lid}/children?Sort=Captured&Desc=1`, both paged on `AnchorID`; `LinkDetailsDto::folder_properties` reads an album's hash key out of the `Album` block, which also makes `parent_hash_key` work for album children); **photo + album node metadata** (`Node::photo` / `Node::album` — capture time, content hash, main/related photos, tags, album inclusions; photo count, cover photo, last activity — added as optional `Node` fields rather than `NodeKind` variants so persisted node blobs keep deserializing, mirroring how `membership` landed); **photo tag updates** (`update_photos`, `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` via the new `ApiHttpClient::delete_with_body`, `POST …/favorite` for photos on our own photos volume, one outcome per input update like `move_nodes`); **shared-with-me photos/albums** (`ShareTargetType::is_photos_item` over `v2/sharedwithme` + the dedicated `GET photos/albums/shared-with-me`, whose rows carry their own `VolumeID`). deferred: `03b1cb7f` **favoriting shared photos** — needs `PhotosCrypto` + `PhotoTransferPayloadBuilder` (name/passphrase session keys rewrapped to the timeline root, related-photo fan-out, batches of 20); our `rewrap_message_to` / `recover_message_session_key` make it feasible, but until it lands `update_photos` fails that one update explicitly instead of silently no-oping. not ported: `4c1dd783` (`IsSharedPublicly` → `IsSharedByUrl` rename — `proton-drive-linux` consumes `is_shared_publicly`), `1477ea45` (C# user-facing error strings; our error text is our own), the second half of `42526703` (server-supplied names validated into a per-node `InvalidNameError` — we have no node error list to put it in). no-op: `82d605b7` (C# `FifoFlexibleSemaphore` cancel race — tokio's `Semaphore` is cancel-safe and drops queued waiters), `8bdd9420` (throw on resume-after-dispose; Rust ownership makes it unreachable), `ca11470f` + `c947fce7` (CExports/interop only). Album *writes* (create, add/remove photos) remain unported — upstream has no C# public API for them either. Live validation pending: every photos endpoint here needs a photos-volume account. | | 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. | +| 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask>>` into `IAsyncEnumerable`, and `UpdatePhotosAsync` into `IAsyncEnumerable`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index fe9fb52..6e9899f 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -1,6 +1,6 @@ //! The high-level Drive client and its read operations. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; use std::io::{Cursor, Read}; use std::num::NonZeroUsize; @@ -2882,55 +2882,131 @@ impl ProtonDriveClient { /// Move `uids` to the trash. Mirrors C# `NodeOperations.TrashAsync` /// (`POST v2/volumes/{vid}/trash_multiple`). Live validation pending. - pub async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<()> { + /// + /// One outcome per node the server reported on, like [`move_nodes`](Self::move_nodes): + /// a node the batch envelope rejects does not fail the others. The outer + /// `Err` is reserved for a request that never produced an envelope. + /// [`trash_nodes_streaming`](Self::trash_nodes_streaming) reports the same + /// outcomes one at a time as each batch lands. + pub async fn trash_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("trash_nodes"); timer.attr("node_count", uids.len()); - for (volume_id, link_ids) in group_by_volume(uids) { - for chunk in link_ids.chunks(MAX_BATCH_COUNT) { - let path = format!("v2/volumes/{volume_id}/trash_multiple"); - let body = MultipleLinksRequest { link_ids: chunk }; - let response: AggregateLinksResponse = self.http.post(&path, &body).await?; - check_aggregate("trash", response)?; - } - } + let outcomes = self + .node_action_stream(NodeAction::Trash, uids) + .try_collect() + .await?; timer.success(); - Ok(()) + Ok(outcomes) + } + + /// [`trash_nodes`](Self::trash_nodes) as a stream: each node's outcome is + /// yielded as soon as the batch carrying it comes back, instead of after + /// every batch. Mirrors the C# `IAsyncEnumerable`. + /// A failed request yields one `Err` and ends the stream. + pub fn trash_nodes_streaming<'a>( + &'a self, + uids: &[NodeUid], + ) -> impl futures::Stream)>> + 'a { + self.node_action_stream(NodeAction::Trash, uids) } /// Restore `uids` from the trash. Mirrors C# /// `NodeOperations.RestoreFromTrashAsync` /// (`PUT v2/volumes/{vid}/trash/restore_multiple`). Live validation pending. - pub async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<()> { + /// Per-node outcomes, like [`trash_nodes`](Self::trash_nodes). + pub async fn restore_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("restore_nodes"); timer.attr("node_count", uids.len()); - for (volume_id, link_ids) in group_by_volume(uids) { - for chunk in link_ids.chunks(MAX_BATCH_COUNT) { - let path = format!("v2/volumes/{volume_id}/trash/restore_multiple"); - let body = MultipleLinksRequest { link_ids: chunk }; - let response: AggregateLinksResponse = self.http.put(&path, &body).await?; - check_aggregate("restore", response)?; - } - } + let outcomes = self + .node_action_stream(NodeAction::Restore, uids) + .try_collect() + .await?; timer.success(); - Ok(()) + Ok(outcomes) + } + + /// [`restore_nodes`](Self::restore_nodes) as a stream of per-node outcomes. + pub fn restore_nodes_streaming<'a>( + &'a self, + uids: &[NodeUid], + ) -> impl futures::Stream)>> + 'a { + self.node_action_stream(NodeAction::Restore, uids) } /// Permanently delete `uids` (which must already be in the trash). Mirrors /// C# `NodeOperations.DeleteFromTrashAsync` /// (`POST v2/volumes/{vid}/trash/delete_multiple`). Live validation pending. - pub async fn delete_nodes(&self, uids: &[NodeUid]) -> Result<()> { + /// Per-node outcomes, like [`trash_nodes`](Self::trash_nodes). + pub async fn delete_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("delete_nodes"); timer.attr("node_count", uids.len()); - for (volume_id, link_ids) in group_by_volume(uids) { - for chunk in link_ids.chunks(MAX_BATCH_COUNT) { - let path = format!("v2/volumes/{volume_id}/trash/delete_multiple"); - let body = MultipleLinksRequest { link_ids: chunk }; - let response: AggregateLinksResponse = self.http.post(&path, &body).await?; - check_aggregate("delete", response)?; - } - } + let outcomes = self + .node_action_stream(NodeAction::Delete, uids) + .try_collect() + .await?; timer.success(); - Ok(()) + Ok(outcomes) + } + + /// [`delete_nodes`](Self::delete_nodes) as a stream of per-node outcomes. + pub fn delete_nodes_streaming<'a>( + &'a self, + uids: &[NodeUid], + ) -> impl futures::Stream)>> + 'a { + self.node_action_stream(NodeAction::Delete, uids) + } + + /// The shared body of the trash-family batch operations: group by volume, + /// chunk each group at [`MAX_BATCH_COUNT`], and yield the per-link outcomes + /// of every batch as it returns. Mirrors the C# async iterators, including + /// their mapping — only the links the aggregate envelope reports on produce + /// an outcome. + fn node_action_stream<'a>( + &'a self, + action: NodeAction, + uids: &[NodeUid], + ) -> impl futures::Stream)>> + 'a { + let batches: VecDeque<(VolumeId, Vec)> = group_by_volume(uids) + .into_iter() + .flat_map(|(volume_id, link_ids)| { + link_ids + .chunks(MAX_BATCH_COUNT) + .map(|chunk| (volume_id.clone(), chunk.to_vec())) + .collect::>() + }) + .collect(); + + stream::unfold( + (batches, VecDeque::new()), + move |(mut batches, mut pending): (VecDeque<_>, VecDeque<_>)| async move { + loop { + if let Some(outcome) = pending.pop_front() { + return Some((Ok(outcome), (batches, pending))); + } + let (volume_id, link_ids) = batches.pop_front()?; + let path = action.path(&volume_id); + let body = MultipleLinksRequest { + link_ids: &link_ids, + }; + let response: Result = match action { + NodeAction::Restore => self.http.put(&path, &body).await, + NodeAction::Trash | NodeAction::Delete => { + self.http.post(&path, &body).await + } + }; + match response { + Ok(response) => pending.extend( + aggregate_outcomes(response) + .into_iter() + .map(|(link_id, o)| (NodeUid::new(volume_id.clone(), link_id), o)), + ), + // A request that never produced an envelope says nothing + // about its nodes: report it and stop. + Err(e) => return Some((Err(e), (VecDeque::new(), VecDeque::new()))), + } + } + }, + ) } /// Permanently empty the main volume's trash. Mirrors C# @@ -5685,6 +5761,50 @@ impl ProtonDriveClient { Ok(outcomes) } + /// [`update_photos`](Self::update_photos) as a stream: one outcome per + /// photo, yielded as that photo's update finishes instead of after all of + /// them (C# `PhotoOperations.UpdatePhotosAsync`'s + /// `IAsyncEnumerable`). The single `Err` item is the + /// photos-root resolution that makes the whole call impossible; it ends the + /// stream. + pub(crate) fn update_photos_streaming<'a>( + &'a self, + updates: &[PhotoTagsUpdate], + ) -> impl futures::Stream)>> + 'a { + let updates: VecDeque = updates.iter().cloned().collect(); + // `None` until the first item resolves the photos volume once, exactly + // as the collecting variant does before its loop. + let own_photos_volume: Option> = None; + + stream::unfold( + (updates, own_photos_volume), + move |(mut updates, own_photos_volume): (VecDeque<_>, Option>)| async move { + let update = updates.pop_front()?; + let own_photos_volume = match own_photos_volume { + Some(resolved) => resolved, + None => match self.ensure_photos().await { + Ok(true) => self + .cache + .lock() + .await + .photos_root + .clone() + .map(|root| root.volume_id), + Ok(false) => None, + Err(e) => return Some((Err(e), (VecDeque::new(), Some(None)))), + }, + }; + let outcome = self + .apply_tag_update(&update, own_photos_volume.as_ref()) + .await; + Some(( + Ok((update.node_uid.clone(), outcome)), + (updates, Some(own_photos_volume)), + )) + }, + ) + } + /// One photo's tag update (C# `PhotoOperations.ApplyTagUpdateAsync`). async fn apply_tag_update( &self, @@ -6520,6 +6640,25 @@ fn join_node_path<'a>(names: impl Iterator) -> String { path } +/// Which trash-family batch endpoint a node action hits. C# has one async +/// iterator per action; the bodies differ only in route and verb. +#[derive(Debug, Clone, Copy)] +enum NodeAction { + Trash, + Restore, + Delete, +} + +impl NodeAction { + fn path(self, volume_id: &VolumeId) -> String { + match self { + Self::Trash => format!("v2/volumes/{volume_id}/trash_multiple"), + Self::Restore => format!("v2/volumes/{volume_id}/trash/restore_multiple"), + Self::Delete => format!("v2/volumes/{volume_id}/trash/delete_multiple"), + } + } +} + /// Group node uids by volume, preserving order, so each volume's links are /// batched into a single request family (C# groups by `VolumeId`). fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { @@ -6613,26 +6752,6 @@ fn aggregate_outcomes(response: AggregateLinksResponse) -> Vec<(LinkId, Result<( .collect() } -fn check_aggregate(op: &str, response: AggregateLinksResponse) -> Result<()> { - let failures: Vec = aggregate_outcomes(response) - .into_iter() - .filter_map(|(link_id, outcome)| match outcome { - Ok(()) => None, - Err(ProtonError::Api(e)) => Some(format!("{link_id} ({:?})", e.code)), - Err(e) => Some(format!("{link_id} ({e})")), - }) - .collect(); - if failures.is_empty() { - Ok(()) - } else { - Err(ProtonError::invalid_operation(format!( - "{op} failed for {} link(s): {}", - failures.len(), - failures.join(", ") - ))) - } -} - /// Build the photo seal attributes from a completed block write + caller /// metadata. Mirrors C# `RevisionWriter.CreatePhotosRevisionUpdateRequest`: /// `CaptureTime` defaults to now when unset, and `ContentHash` is the lowercase @@ -6964,12 +7083,11 @@ fn drive_items(page: &SharedWithMeResponse) -> Vec { mod tests { use super::{ CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, - MAX_NODE_NAME_LENGTH, aggregate_outcomes, alternate_names, assemble_manifest, - check_aggregate, context_share_path, drive_items, epoch_to_iso8601, - is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path, - path_segments, run_context_share_mutation, share_membership_from_dto, - small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event, - validate_node_name, + MAX_NODE_NAME_LENGTH, NodeAction, aggregate_outcomes, alternate_names, assemble_manifest, + context_share_path, drive_items, epoch_to_iso8601, is_expired_upload_token, + is_listable_revision_state, is_upload_timeout, join_node_path, path_segments, + run_context_share_mutation, share_membership_from_dto, small_upload_applicable, + take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto, @@ -7009,6 +7127,23 @@ mod tests { } } + #[test] + fn node_action_routes_match_upstream() { + let volume = VolumeId::new("vol-1"); + assert_eq!( + NodeAction::Trash.path(&volume), + "v2/volumes/vol-1/trash_multiple" + ); + assert_eq!( + NodeAction::Restore.path(&volume), + "v2/volumes/vol-1/trash/restore_multiple" + ); + assert_eq!( + NodeAction::Delete.path(&volume), + "v2/volumes/vol-1/trash/delete_multiple" + ); + } + #[test] fn aggregate_outcomes_keeps_per_link_codes() { let outcomes = aggregate_outcomes(mixed_aggregate(ResponseCode::DoesNotExist)); @@ -7029,19 +7164,6 @@ mod tests { } } - #[test] - fn check_aggregate_reports_only_the_failed_links() { - check_aggregate("move", mixed_aggregate(ResponseCode::Success)) - .expect("all-success envelope must pass"); - - let err = check_aggregate("move", mixed_aggregate(ResponseCode::DoesNotExist)) - .expect_err("a failed link must fail the batch"); - let message = err.to_string(); - assert!(message.contains("move failed for 1 link(s)"), "{message}"); - assert!(message.contains("bad-link"), "{message}"); - assert!(!message.contains("ok-link"), "{message}"); - } - #[test] fn shared_with_me_items_keep_share_ids_order_and_drive_targets() { let link = |link_id: &str, share_id: &str, target_type| SharedWithMeLinkDto { diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index aa3a8d5..f527f2d 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -304,6 +304,17 @@ impl ProtonPhotosClient { ) -> Result)>> { self.drive.update_photos(updates).await } + + /// [`update_photos`](Self::update_photos) as a stream: each photo's outcome + /// arrives as that photo's update finishes, mirroring the C# + /// `IAsyncEnumerable`. The `Err` item is a failure that + /// makes the whole call impossible and ends the stream. + pub fn update_photos_streaming<'a>( + &'a self, + updates: &[PhotoTagsUpdate], + ) -> impl futures::Stream)>> + 'a { + self.drive.update_photos_streaming(updates) + } } #[cfg(test)] diff --git a/crates/proton-drive-rs/tests/common/mod.rs b/crates/proton-drive-rs/tests/common/mod.rs index bcd5b67..9fdc519 100644 --- a/crates/proton-drive-rs/tests/common/mod.rs +++ b/crates/proton-drive-rs/tests/common/mod.rs @@ -17,6 +17,7 @@ use hmac::{Hmac, KeyInit, Mac}; use proton_drive_rs::ProtonDriveClient; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::error::ProtonError; +use proton_sdk::ids::NodeUid; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use serde::{Deserialize, Serialize}; use sha1::Sha1; @@ -362,3 +363,27 @@ pub fn unique_suffix() -> String { .as_nanos(); format!("{nanos}") } + +/// Panic on any per-node failure in a batch outcome list (`trash_nodes`, +/// `restore_nodes`, `delete_nodes`, `move_nodes`): those calls report one +/// outcome per node instead of failing the whole call, so a test that expects +/// success has to look at every outcome. +#[allow(dead_code)] +pub fn expect_all_ok(what: &str, outcomes: &[(NodeUid, Result<(), ProtonError>)]) { + for (uid, outcome) in outcomes { + if let Err(e) = outcome { + panic!("{what} failed for {uid}: {e}"); + } + } +} + +/// Report per-node failures on a best-effort cleanup path without failing the +/// test that is tearing down. +#[allow(dead_code)] +pub fn log_outcomes(what: &str, outcomes: &[(NodeUid, Result<(), ProtonError>)]) { + for (uid, outcome) in outcomes { + if let Err(e) = outcome { + eprintln!("[cleanup] {what} failed for {uid}: {e}"); + } + } +} diff --git a/crates/proton-drive-rs/tests/live_drive_ops.rs b/crates/proton-drive-rs/tests/live_drive_ops.rs index 8dcc102..995c880 100644 --- a/crates/proton-drive-rs/tests/live_drive_ops.rs +++ b/crates/proton-drive-rs/tests/live_drive_ops.rs @@ -15,12 +15,16 @@ use proton_sdk::ids::{LinkId, NodeUid}; /// Trash then permanently delete the given nodes; best-effort, logs on failure. async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) { - if let Err(e) = client.trash_nodes(uids).await { - eprintln!("[cleanup] trash failed: {e}"); - return; + match client.trash_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => { + eprintln!("[cleanup] trash failed: {e}"); + return; + } } - if let Err(e) = client.delete_nodes(uids).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } } @@ -77,10 +81,11 @@ async fn folder_create_rename_trash_restore() { assert_eq!(node.name, renamed, "rename must take effect"); // trash - client + let outcomes = client .trash_nodes(std::slice::from_ref(&uid)) .await .expect("trash"); + common::expect_all_ok("trash", &outcomes); let node = get(client, &uid, "after trash").await; assert!(node.trashed, "node must report trashed"); let trash = client @@ -90,10 +95,11 @@ async fn folder_create_rename_trash_restore() { assert!(trash.contains(&uid), "trash listing must include the node"); // restore - client + let outcomes = client .restore_nodes(std::slice::from_ref(&uid)) .await .expect("restore"); + common::expect_all_ok("restore", &outcomes); let node = get(client, &uid, "after restore").await; assert!(!node.trashed, "node must no longer be trashed"); @@ -121,10 +127,11 @@ async fn folder_empty_trash() { .await .expect("create_folder"); - client + let outcomes = client .trash_nodes(std::slice::from_ref(&uid)) .await .expect("trash"); + common::expect_all_ok("trash", &outcomes); assert!( client .enumerate_trash_node_uids() diff --git a/crates/proton-drive-rs/tests/live_events.rs b/crates/proton-drive-rs/tests/live_events.rs index b12d25e..8e65e4c 100644 --- a/crates/proton-drive-rs/tests/live_events.rs +++ b/crates/proton-drive-rs/tests/live_events.rs @@ -21,12 +21,16 @@ use proton_sdk::ids::{DriveEventId, NodeUid}; /// Trash then permanently delete the given nodes; best-effort, logs on failure. async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) { - if let Err(e) = client.trash_nodes(uids).await { - eprintln!("[cleanup] trash failed: {e}"); - return; + match client.trash_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => { + eprintln!("[cleanup] trash failed: {e}"); + return; + } } - if let Err(e) = client.delete_nodes(uids).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } } @@ -230,10 +234,11 @@ async fn trashing_surfaces_is_trashed() { // Seed after creation so the trash is the change we look for. let cursor = seed(client, &scope).await; - client + let outcomes = client .trash_nodes(std::slice::from_ref(&folder)) .await .expect("trash folder"); + common::expect_all_ok("trash", &outcomes); let found = poll_for(client, &scope, &cursor, |e| { matches!( @@ -248,8 +253,9 @@ async fn trashing_surfaces_is_trashed() { ); // Best-effort permanent delete (already trashed). - if let Err(e) = client.delete_nodes(std::slice::from_ref(&folder)).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(std::slice::from_ref(&folder)).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } } @@ -273,17 +279,19 @@ async fn permanent_delete_surfaces_node_deleted() { .create_folder(&root.uid, &format!("evt-delete-{suffix}"), None) .await .expect("create folder"); - client + let outcomes = client .trash_nodes(std::slice::from_ref(&folder)) .await .expect("trash folder"); + common::expect_all_ok("trash", &outcomes); // Seed after trashing so the permanent delete is the awaited change. let cursor = seed(client, &scope).await; - client + let outcomes = client .delete_nodes(std::slice::from_ref(&folder)) .await .expect("delete folder"); + common::expect_all_ok("delete", &outcomes); let found = poll_for( client, diff --git a/crates/proton-drive-rs/tests/live_sharing.rs b/crates/proton-drive-rs/tests/live_sharing.rs index 817e681..74c4ca6 100644 --- a/crates/proton-drive-rs/tests/live_sharing.rs +++ b/crates/proton-drive-rs/tests/live_sharing.rs @@ -26,12 +26,16 @@ use proton_sdk::ids::NodeUid; /// Trash then permanently delete the given node; best-effort, logs on failure. async fn cleanup(client: &ProtonDriveClient, uid: &NodeUid) { - if let Err(e) = client.trash_nodes(std::slice::from_ref(uid)).await { - eprintln!("[cleanup] trash failed: {e}"); - return; + match client.trash_nodes(std::slice::from_ref(uid)).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => { + eprintln!("[cleanup] trash failed: {e}"); + return; + } } - if let Err(e) = client.delete_nodes(std::slice::from_ref(uid)).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(std::slice::from_ref(uid)).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } } diff --git a/crates/proton-drive-rs/tests/live_transfer_ops.rs b/crates/proton-drive-rs/tests/live_transfer_ops.rs index aaa2ff4..ffe278d 100644 --- a/crates/proton-drive-rs/tests/live_transfer_ops.rs +++ b/crates/proton-drive-rs/tests/live_transfer_ops.rs @@ -17,12 +17,16 @@ use proton_sdk::ids::NodeUid; /// Trash then permanently delete the given nodes; best-effort, logs on failure. async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) { - if let Err(e) = client.trash_nodes(uids).await { - eprintln!("[cleanup] trash failed: {e}"); - return; + match client.trash_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => { + eprintln!("[cleanup] trash failed: {e}"); + return; + } } - if let Err(e) = client.delete_nodes(uids).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } } diff --git a/crates/proton-drive-rs/tests/live_upload_download.rs b/crates/proton-drive-rs/tests/live_upload_download.rs index 76cd4f6..6f39e58 100644 --- a/crates/proton-drive-rs/tests/live_upload_download.rs +++ b/crates/proton-drive-rs/tests/live_upload_download.rs @@ -49,12 +49,16 @@ impl OpCounter { /// Trash then permanently delete the given nodes; best-effort, logs on failure. async fn cleanup(client: &proton_drive_rs::ProtonDriveClient, uids: &[NodeUid]) { - if let Err(e) = client.trash_nodes(uids).await { - eprintln!("[cleanup] trash failed: {e}"); - return; + match client.trash_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => { + eprintln!("[cleanup] trash failed: {e}"); + return; + } } - if let Err(e) = client.delete_nodes(uids).await { - eprintln!("[cleanup] delete failed: {e}"); + match client.delete_nodes(uids).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), } }