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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
//! Test-time DB isolation — Django's `TestCase` / `TransactionTestCase`
//! analogs. Issue #39.
//!
//! ## Four-tier shape
//!
//! Django ships four test base classes with distinct DB semantics.
//! rustango is a function-style framework, so the analogs are
//! helpers that callers wrap around the test body:
//!
//! | Django class | rustango analog | Use when … |
//! |-------------------------|----------------------------------------|---------------------------------------------------------|
//! | `SimpleTestCase` | plain `#[tokio::test]` | no DB access — fastest. |
//! | `TestCase` | [`with_rollback`] | reads / writes that should be rolled back at end. |
//! | `TransactionTestCase` | [`with_truncate_after`] | code under test commits (signals, on_commit hooks). |
//! | `LiveServerTestCase` | [`crate::test_server::LiveServer`] | needs a real listening socket (Selenium, websockets). |
//!
//! ## Why a separate helper per tier?
//!
//! Each tier has a different "what happens between tests" guarantee:
//!
//! - `with_rollback` wraps the body in a transaction that ALWAYS
//! rolls back. Fastest. But invisible to code paths that check for
//! a committed state (signals, `on_commit` hooks, FK triggers
//! firing on real commit).
//! - `with_truncate_after` commits real rows during the body, then
//! truncates the listed tables after — so the body sees committed
//! state, and the next test starts clean. Slower than
//! `with_rollback` because every test pays the truncate cost.
//!
//! Django's `TestCase` wraps each test method in a transaction that
//! always rolls back, so mutations during one test never leak into
//! the next. Rust has no test classes; the analog is an explicit
//! helper that test code wraps around its body:
//!
//! ```ignore
//! use rustango::test_db::with_rollback;
//!
//! #[tokio::test]
//! async fn create_and_count() {
//! let pool = test_pool().await;
//! with_rollback(&pool, |tx| Box::pin(async move {
//! // Inserts here are visible to assertions inside the
//! // closure, but rolled back when it returns.
//! insert_tx(tx, &article_q("First")).await?;
//! insert_tx(tx, &article_q("Second")).await?;
//!
//! let count = count_tx::<Article>(tx).await?;
//! assert_eq!(count, 2);
//! Ok(())
//! })).await.unwrap();
//!
//! // The two articles are gone — rollback happened on return.
//! }
//! ```
//!
//! ## Why a separate helper instead of `atomic()`?
//!
//! [`crate::sql::atomic`] commits on `Ok` and rolls back on `Err`.
//! Tests want the rollback unconditionally so the schema stays
//! clean for the next test. [`with_rollback`] swaps the commit
//! branch for a rollback while preserving the closure's return
//! value.
//!
//! ## Caveats vs Django
//!
//! - **Per-test isolation only**: the rollback wraps a single
//! closure. Tests still share a process-wide DB connection pool;
//! the rollback only resets data this test inserted.
//! - **Concurrency**: parallel tests still race on shared rows
//! they didn't insert. Pair with a suite-wide `tokio::Mutex` if
//! the test touches process-global state (per the project's
//! global-state mutex convention).
//! - **`on_commit` callbacks**: they NEVER fire here, by design —
//! the tx always rolls back, so deferred work would be a phantom.
//! `with_rollback` also clears any callbacks the closure registered.
//! - **SAVEPOINTs**: nested calls behave as nested savepoints via
//! sqlx's transaction shape. Outer rollback discards inner work
//! even if inner committed.
//!
//! Issue #39 partial — full TestCase / TransactionTestCase /
//! SimpleTestCase / LiveServerTestCase hierarchy is a separate slice.
use Future;
use Pin;
use crate;
/// Run `f` inside a transaction that ALWAYS rolls back when the
/// closure returns, regardless of whether the closure returned
/// `Ok` or `Err`. The transaction-rollback step happens after the
/// closure's value is captured, so callers see the closure's
/// original result.
///
/// On `Err` the closure result is returned as-is. On `Ok` the
/// closure result is returned after the rollback completes
/// successfully; if the rollback itself fails (network blip /
/// connection drop) the closure's Ok is converted to that
/// driver error.
///
/// # Errors
/// - The closure's own error (transitively).
/// - `BEGIN` / `ROLLBACK` driver errors.
pub async
/// Sugar around [`with_rollback`] that wraps the body in
/// `Box::pin(async move { … })` so callers don't have to. Identical
/// semantics:
///
/// ```ignore
/// rustango::with_rollback!(&pool, |tx| {
/// insert_tx(tx, &q).await?;
/// // ... assertions ...
/// Ok(())
/// }).await
/// ```
/// Run `f` to completion, then truncate `tables` regardless of the
/// closure's result. Django's `TransactionTestCase` analog: the
/// closure's writes COMMIT (so it sees real post-commit state —
/// signals fire, `on_commit` hooks fire, FK triggers fire), and the
/// teardown step clears those tables so the next test starts clean.
///
/// Per-dialect strategy mirrors [`crate::migrate::manage`] `flush`:
/// - **Postgres**: one `TRUNCATE TABLE t1, t2, ... RESTART IDENTITY
/// CASCADE` statement. Atomic, FK-aware, sequence-resetting.
/// - **MySQL / SQLite**: per-table `DELETE FROM "<table>"` in
/// the listed order. Sequences are NOT reset.
///
/// The closure's return value is preserved unchanged. Truncate
/// failures surface as the new error ONLY when the closure
/// succeeded — otherwise the closure's error takes priority (so the
/// real failure isn't masked by a noisy teardown).
///
/// **Concurrency**: this helper commits real rows. Tests calling it
/// against the same tables must serialize (the project convention
/// is a suite-wide `tokio::sync::Mutex<()>`) — see the
/// "Tests on global state need a mutex" memory note. Truncate races
/// across parallel `cargo test` workers would otherwise wipe each
/// other's setup mid-flight.
///
/// **Passing tables explicitly**: callers list only the tables they
/// touch. Truncating every registered model table would couple every
/// test to every other app's tables — slow and brittle. If a test
/// really wants the "wipe everything" shape, `manage flush --yes`
/// does that.
///
/// ```ignore
/// use rustango::test_db::with_truncate_after;
///
/// #[tokio::test]
/// async fn create_article_fires_post_save_signal() {
/// let pool = test_pool().await;
/// let _g = SUITE_MUTEX.lock().await;
/// with_truncate_after(&pool, &["articles"], || async move {
/// create_article("First").await?; // COMMITS — post_save fires
/// assert_signal_fired();
/// Ok(())
/// }).await.unwrap();
/// // The article is gone — truncate happened on return.
/// }
/// ```
///
/// # Errors
/// - The closure's own error (transitively).
/// - Truncate driver errors (only when the closure succeeded).
pub async
/// Truncate every table in `tables`, dialect-aware. Public so callers
/// can reuse the per-dialect clear logic outside [`with_truncate_after`]
/// (custom test fixtures, manual teardown).
///
/// On Postgres this is one `TRUNCATE` statement; on MySQL/SQLite it's
/// per-table `DELETE FROM`. Returns the first driver error, but does
/// NOT short-circuit — every remaining table is still attempted on
/// the per-table path so partial cleanup happens even when one fails.
///
/// Passing an empty slice is a no-op (returns `Ok(())`).
///
/// # Errors
/// - The first driver error encountered (per-table path collects
/// subsequent errors but reports only the first).
pub async
/// Sugar around [`with_truncate_after`] mirroring the
/// [`with_rollback!`](crate::with_rollback) macro shape:
///
/// ```ignore
/// rustango::with_truncate_after!(&pool, &["articles", "comments"], {
/// create_article("First").await?;
/// create_comment("…").await?;
/// Ok(())
/// }).await
/// ```