1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// SPDX-License-Identifier: GPL-3.0-or-later
//! Regression test for the compaction-once-before-loop change (commit d18d6ae7).
//!
//! When a `Move` fails and falls back to `ReplaceWith([Create(dst), Delete(src)])`,
//! a pending `Update` to the *target* calendar (same uid) is still sitting
//! further back in the queue. The old per-iteration `compact()` would merge
//! the reinserted `Create(dst)` with that `Update(dst)` into a single
//! `Create`; the new code processes them separately. The `Update` carries a
//! stale etag and would 412 — producing a spurious conflict copy — *except*
//! that etag propagation (`sync.rs` pop block) overwrites the stale etag
//! with the fresh one returned by the `Create` PUT before the `Update` is
//! processed.
//!
//! This test pins that guarantee: with the server returning an ETag on the
//! Create PUT, the subsequent Update must arrive with the propagated (fresh)
//! etag and succeed, so no conflict copy is ever created and the journal
//! drains cleanly.
use cfait::client::RustyClient;
use cfait::context::TestContext;
use cfait::journal::{Action, Journal};
use cfait::model::Task;
use mockito::{Matcher, Server};
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn failed_move_with_pending_update_no_conflict_copy() {
let ctx = Arc::new(TestContext::new());
let mut server = Server::new_async().await;
let url = server.url();
let uid = "move-pending-update";
let src_cal = format!("{}/cal1/", url);
let dst_cal = format!("{}/cal2/", url);
let src_path = format!("/cal1/{uid}.ics");
let dst_path = format!("/cal2/{uid}.ics");
let fresh_etag = "\"fresh-from-create\"";
// 1. MOVE fails (non-412, e.g. 500) → triggers ReplaceWith fallback.
// Two attempts: first overwrite=F, then retry with overwrite=T (412 path
// is also retried in handle_move), both fail so ReplaceWith fires.
server
.mock("MOVE", Matcher::Any)
.with_status(500)
.expect_at_least(1)
.create_async()
.await;
// 2. DELETE the source (from ReplaceWith). 204 success.
let mock_delete = server
.mock("DELETE", src_path.as_str())
.with_status(204)
.create_async()
.await;
// 3. CREATE at destination (from ReplaceWith). Returns the fresh ETag so
// etag propagation can overwrite the stale Update etag. Use If-None-Match
// matcher to distinguish from the Update PUT.
let mock_create = server
.mock("PUT", dst_path.as_str())
.match_header("If-None-Match", "*")
.with_status(201)
.with_header("ETag", fresh_etag)
.create_async()
.await;
// 4. UPDATE at destination — the pending edit that was queued *before* the
// sync. With etag propagation it must arrive with the fresh etag (not the
// stale one), so it succeeds and no conflict copy is produced.
let mock_update = server
.mock("PUT", dst_path.as_str())
.match_header("If-Match", fresh_etag)
.with_status(204)
.with_header("ETag", fresh_etag)
.create_async()
.await;
// 5. If a conflict copy were produced, the client would issue a *second*
// Create PUT to a different path (/cal2/<uuid>.ics) with If-None-Match:*.
// Fail loudly if that ever happens.
let mock_conflict_copy = server
.mock(
"PUT",
Matcher::Regex(r"^/cal2/[0-9a-f-]+\.ics$".to_string()),
)
.with_status(418)
.expect(0)
.create_async()
.await;
let client = RustyClient::new(ctx.clone(), &url, "u", "p", true, None).unwrap();
// Queue a Move and a pending Update to the destination calendar. The
// Update's etag is deliberately stale — the server's current etag will be
// fresh_etag (returned by the Create).
let mut task = Task::new("Moving Task", &HashMap::new(), None);
task.uid = uid.to_string();
task.href = format!("{url}{src_path}");
task.calendar_href = src_cal.clone();
task.etag = "\"old-src-etag\"".to_string();
let mut moved_edit = task.clone();
moved_edit.calendar_href = dst_cal.clone();
moved_edit.href = format!("{url}{dst_path}");
moved_edit.etag = "\"stale-dst-etag\"".to_string();
moved_edit.summary = "Moving Task (edited)".to_string();
Journal::push(ctx.as_ref(), Action::Move(task, dst_cal.clone())).unwrap();
Journal::push(ctx.as_ref(), Action::Update(moved_edit)).unwrap();
let res = client.sync_journal().await;
assert!(res.is_ok(), "sync_journal failed: {:?}", res.err());
mock_delete.assert();
mock_create.assert();
mock_update.assert();
mock_conflict_copy.assert();
// Journal must be fully drained — no conflict copy left behind, no spiral.
assert!(
Journal::load(ctx.as_ref()).is_empty(),
"journal must be empty after failed move + pending update"
);
}