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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
//! #1346 (AC5) — the atomic-save correctness gate for nested (`has_many`) forms.
//!
//! A parent (`orders`) and its children (`line_items`, FK `order_id →
//! orders.id`) must be persisted **atomically**: either the whole order and
//! every line item lands, or nothing does. A half-saved order — a parent row
//! with only some of its children, or (worse) orphaned children — is never an
//! acceptable outcome.
//!
//! [`save_order_with_items`] is the canonical save path: it opens **one**
//! [`Db::tx`], inserts the order, reads back its generated `id`, stamps each
//! line item's `order_id` with that id, and inserts the children — all on the
//! single `conn` the closure is handed, with **raw diesel inserts** (never a
//! generated `Repo::create`, which would open its own `Db::tx` and trip the
//! nested-transaction guard).
//!
//! The negative test drives a save whose second child violates the
//! `quantity > 0` CHECK constraint, so the insert errors mid-loop and the whole
//! transaction rolls back. The correctness gate then asserts **zero** rows in
//! *both* tables — no orphaned order, no partial line items. The positive test
//! proves the happy path commits every child linked to the parent's id.
//!
//! Run with (requires Docker for the Postgres testcontainer):
//!
//! cargo test -p autumn-web --features "db,test-support" \
//! --test integration_tests nested_form_atomic_save -- --include-ignored
#![cfg(all(feature = "db", feature = "test-support"))]
use autumn_web::prelude::*;
use autumn_web::test::{TestApp, TestDb};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use scoped_futures::ScopedFutureExt;
// ── Schema ─────────────────────────────────────────────────────────
mod schema {
diesel::table! {
atomic_orders (id) {
id -> Int8,
name -> Text,
}
}
diesel::table! {
atomic_line_items (id) {
id -> Int8,
order_id -> Int8,
sku -> Text,
quantity -> Int4,
}
}
}
use schema::{atomic_line_items, atomic_orders};
#[derive(Queryable, Selectable)]
#[diesel(table_name = atomic_orders)]
struct OrderRow {
id: i64,
#[allow(dead_code)]
name: String,
}
#[derive(Queryable, Selectable)]
#[diesel(table_name = atomic_line_items)]
struct LineItemRow {
#[allow(dead_code)]
id: i64,
order_id: i64,
#[allow(dead_code)]
sku: String,
#[allow(dead_code)]
quantity: i32,
}
// ── The atomic save path (AC5) ─────────────────────────────────────
/// Persist an order and its line items in a **single** transaction.
///
/// Opens one [`Db::tx`], inserts the parent, reads back its generated `id`,
/// stamps each child's `order_id` with that id, and inserts every child with a
/// raw diesel insert on the same `conn`. Any error inside the closure (here, a
/// `quantity > 0` CHECK violation) propagates out and rolls the whole
/// transaction back, so a failed save leaves neither the order nor any line
/// item behind.
async fn save_order_with_items(
db: &mut Db,
order_name: String,
// Each child as `(sku, quantity)` — no `order_id` yet; the parent's id is
// read back inside the tx and stamped onto every row.
items: Vec<(String, i32)>,
) -> AutumnResult<i64> {
db.tx(|conn| {
async move {
// 1. Insert the parent, reading back its generated primary key.
let order_id: i64 = diesel::insert_into(atomic_orders::table)
.values(atomic_orders::name.eq(&order_name))
.returning(atomic_orders::id)
.get_result(conn)
.await?;
// 2. Stamp each child's FK with the freshly-read parent id and
// insert it. A row that violates the `quantity > 0` CHECK raises
// a DB error, which propagates out of the closure and rolls the
// whole tx back — including the parent insert above.
for (sku, quantity) in &items {
diesel::insert_into(atomic_line_items::table)
.values((
atomic_line_items::order_id.eq(order_id),
atomic_line_items::sku.eq(sku.as_str()),
atomic_line_items::quantity.eq(*quantity),
))
.execute(conn)
.await?;
}
Ok::<_, diesel::result::Error>(order_id)
}
.scope_boxed()
})
.await
}
// ── Handlers ───────────────────────────────────────────────────────
//
// `Db::tx` needs a `Db`, and the only supported way to obtain one in an
// integration test is through the extractor — so the save is driven through a
// route on a non-transactional `TestApp` (`with_db`), where a committed tx
// really persists and a rolled-back tx really leaves nothing.
/// Save an order whose **second** line item is invalid (`quantity = 0`), so the
/// insert trips the CHECK constraint and the whole tx rolls back.
#[post("/orders/rollback")]
async fn rollback_handler(mut db: Db) -> AutumnResult<Json<i64>> {
let id = save_order_with_items(
&mut db,
"Rollback Order".to_string(),
vec![
("A-1".to_string(), 2),
("BAD".to_string(), 0), // violates `quantity > 0`
("C-3".to_string(), 5),
],
)
.await?;
Ok(Json(id))
}
/// Save an order whose line items are all valid, so the tx commits.
#[post("/orders/commit")]
async fn commit_handler(mut db: Db) -> AutumnResult<Json<i64>> {
let id = save_order_with_items(
&mut db,
"Committed Order".to_string(),
vec![
("A-1".to_string(), 2),
("B-2".to_string(), 3),
("C-3".to_string(), 5),
],
)
.await?;
Ok(Json(id))
}
// ── Setup ──────────────────────────────────────────────────────────
/// Serializes the two tests: both assert global row counts on the *shared*
/// tables, so they must not interleave (each truncates at the start of its
/// critical section).
static TABLES_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn setup_tables(db: &TestDb) {
db.execute_sql(
"CREATE TABLE IF NOT EXISTS atomic_orders (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
)",
)
.await;
db.execute_sql(
"CREATE TABLE IF NOT EXISTS atomic_line_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES atomic_orders(id),
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0)
)",
)
.await;
// Clean slate so the global-count assertions below are meaningful.
db.execute_sql("TRUNCATE atomic_line_items, atomic_orders RESTART IDENTITY CASCADE")
.await;
}
// ── Tests ──────────────────────────────────────────────────────────
/// AC5 correctness gate: a failing child rolls the entire order back, leaving
/// **zero** rows in both tables — no orphaned order, no partial line items.
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn failing_child_rolls_back_entire_order() {
let db = TestDb::shared().await;
let _guard = TABLES_LOCK.lock().await;
setup_tables(db).await;
let client = TestApp::new()
.routes(routes![rollback_handler])
.with_db(db.pool())
.build();
// The save fails partway (invalid second child), so the handler surfaces a
// 500 and the transaction is rolled back.
client
.post("/orders/rollback")
.send()
.await
.assert_status(500);
// The gate: nothing was persisted in EITHER table.
let mut conn = db.pool().get().await.expect("db connection");
let order_count: i64 = atomic_orders::table
.count()
.get_result(&mut *conn)
.await
.expect("count orders");
let item_count: i64 = atomic_line_items::table
.count()
.get_result(&mut *conn)
.await
.expect("count line items");
assert_eq!(
order_count, 0,
"a rolled-back save must leave zero orphaned order rows"
);
assert_eq!(
item_count, 0,
"a rolled-back save must leave zero orphaned/partial line-item rows"
);
}
/// The happy path commits: the order is persisted with all N children, each
/// linked to the parent's generated id.
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn all_valid_children_commit_linked_to_order() {
let db = TestDb::shared().await;
let _guard = TABLES_LOCK.lock().await;
setup_tables(db).await;
let client = TestApp::new()
.routes(routes![commit_handler])
.with_db(db.pool())
.build();
client.post("/orders/commit").send().await.assert_ok();
let mut conn = db.pool().get().await.expect("db connection");
let orders: Vec<OrderRow> = atomic_orders::table
.select(OrderRow::as_select())
.load(&mut *conn)
.await
.expect("load orders");
assert_eq!(orders.len(), 1, "the order must be committed exactly once");
let order_id = orders[0].id;
let items: Vec<LineItemRow> = atomic_line_items::table
.select(LineItemRow::as_select())
.load(&mut *conn)
.await
.expect("load line items");
assert_eq!(items.len(), 3, "all three children must be committed");
assert!(
items.iter().all(|it| it.order_id == order_id),
"every child must be linked to the parent's generated id"
);
}