Skip to main content

durable_lambda_testing/
assertions.rs

1//! Test assertion helpers for durable Lambda testing.
2//!
3//! Provide convenience assertions for inspecting checkpoint calls
4//! recorded by [`MockBackend`](crate::mock_backend::MockBackend).
5
6use std::sync::Arc;
7
8use tokio::sync::Mutex;
9
10use crate::mock_backend::{CheckpointCall, OperationRecord};
11
12/// Assert the exact number of checkpoint calls made.
13///
14/// # Panics
15///
16/// Panics if the actual count doesn't match `expected`.
17///
18/// # Examples
19///
20/// ```no_run
21/// # async fn example() {
22/// use durable_lambda_testing::prelude::*;
23///
24/// let (mut ctx, calls, _ops) = MockDurableContext::new()
25///     .with_step_result("s1", r#"1"#)
26///     .build()
27///     .await;
28///
29/// // replay step — no checkpoints
30/// let _: Result<i32, String> = ctx.step("s1", || async { Ok(0) }).await.unwrap();
31///
32/// assert_checkpoint_count(&calls, 0).await;
33/// # }
34/// ```
35pub async fn assert_checkpoint_count(calls: &Arc<Mutex<Vec<CheckpointCall>>>, expected: usize) {
36    let captured = calls.lock().await;
37    assert_eq!(
38        captured.len(),
39        expected,
40        "expected {expected} checkpoint calls, got {}",
41        captured.len()
42    );
43}
44
45/// Assert that no checkpoint calls were made (pure replay test).
46///
47/// Equivalent to `assert_checkpoint_count(calls, 0)`.
48///
49/// # Panics
50///
51/// Panics if any checkpoint calls were recorded.
52///
53/// # Examples
54///
55/// ```no_run
56/// # async fn example() {
57/// use durable_lambda_testing::prelude::*;
58///
59/// let (mut ctx, calls, _ops) = MockDurableContext::new()
60///     .with_step_result("validate", r#"true"#)
61///     .build()
62///     .await;
63///
64/// let _: Result<bool, String> = ctx.step("validate", || async { Ok(false) }).await.unwrap();
65///
66/// assert_no_checkpoints(&calls).await;
67/// # }
68/// ```
69pub async fn assert_no_checkpoints(calls: &Arc<Mutex<Vec<CheckpointCall>>>) {
70    assert_checkpoint_count(calls, 0).await;
71}
72
73/// Assert the recorded operation sequence matches the expected `"type:name"` strings.
74///
75/// Each string should be in `"type:name"` format (e.g., `"step:validate"`,
76/// `"wait:cooldown"`). The assertion checks both the count and exact order.
77///
78/// # Panics
79///
80/// Panics with a diff showing the first divergence if the sequences don't match.
81///
82/// # Examples
83///
84/// ```no_run
85/// # async fn example() {
86/// use durable_lambda_testing::prelude::*;
87///
88/// let (mut ctx, _calls, ops) = MockDurableContext::new()
89///     .build()
90///     .await;
91///
92/// let _: Result<i32, String> = ctx.step("validate", || async { Ok(42) }).await.unwrap();
93///
94/// assert_operations(&ops, &["step:validate"]).await;
95/// # }
96/// ```
97pub async fn assert_operations(operations: &Arc<Mutex<Vec<OperationRecord>>>, expected: &[&str]) {
98    let recorded = operations.lock().await;
99    let actual: Vec<String> = recorded.iter().map(|r| r.to_type_name()).collect();
100    let expected: Vec<&str> = expected.to_vec();
101
102    if actual.len() != expected.len() {
103        panic!(
104            "Operation sequence length mismatch:\n  Expected {} operations: {:?}\n  Actual {} operations:   {:?}",
105            expected.len(),
106            expected,
107            actual.len(),
108            actual,
109        );
110    }
111
112    for (i, (actual_op, expected_op)) in actual.iter().zip(expected.iter()).enumerate() {
113        if actual_op != expected_op {
114            panic!(
115                "Operation sequence mismatch at position {i}:\n  Expected: {expected:?}\n  Actual:   {actual:?}\n  First difference: expected \"{expected_op}\" but got \"{actual_op}\"",
116            );
117        }
118    }
119}
120
121/// Assert the recorded operation names match (ignoring types).
122///
123/// A simplified version of [`assert_operations`] that checks only the
124/// operation names without the `"type:"` prefix.
125///
126/// # Panics
127///
128/// Panics with a diff if the name sequences don't match.
129///
130/// # Examples
131///
132/// ```no_run
133/// # async fn example() {
134/// use durable_lambda_testing::prelude::*;
135///
136/// let (mut ctx, _calls, ops) = MockDurableContext::new()
137///     .build()
138///     .await;
139///
140/// let _: Result<i32, String> = ctx.step("validate", || async { Ok(42) }).await.unwrap();
141///
142/// assert_operation_names(&ops, &["validate"]).await;
143/// # }
144/// ```
145pub async fn assert_operation_names(
146    operations: &Arc<Mutex<Vec<OperationRecord>>>,
147    expected: &[&str],
148) {
149    let recorded = operations.lock().await;
150    let actual: Vec<&str> = recorded.iter().map(|r| r.name.as_str()).collect();
151    let expected: Vec<&str> = expected.to_vec();
152
153    if actual.len() != expected.len() {
154        panic!(
155            "Operation name sequence length mismatch:\n  Expected {} operations: {:?}\n  Actual {} operations:   {:?}",
156            expected.len(),
157            expected,
158            actual.len(),
159            actual,
160        );
161    }
162
163    for (i, (actual_name, expected_name)) in actual.iter().zip(expected.iter()).enumerate() {
164        if actual_name != expected_name {
165            panic!(
166                "Operation name mismatch at position {i}:\n  Expected: {expected:?}\n  Actual:   {actual:?}\n  First difference: expected \"{expected_name}\" but got \"{actual_name}\"",
167            );
168        }
169    }
170}
171
172/// Assert the total count of recorded operations.
173///
174/// # Panics
175///
176/// Panics if the actual count doesn't match `expected`.
177///
178/// # Examples
179///
180/// ```no_run
181/// # async fn example() {
182/// use durable_lambda_testing::prelude::*;
183///
184/// let (mut ctx, _calls, ops) = MockDurableContext::new()
185///     .build()
186///     .await;
187///
188/// let _: Result<i32, String> = ctx.step("validate", || async { Ok(42) }).await.unwrap();
189///
190/// assert_operation_count(&ops, 1).await;
191/// # }
192/// ```
193pub async fn assert_operation_count(
194    operations: &Arc<Mutex<Vec<OperationRecord>>>,
195    expected: usize,
196) {
197    let recorded = operations.lock().await;
198    assert_eq!(
199        recorded.len(),
200        expected,
201        "expected {expected} operations, got {}",
202        recorded.len()
203    );
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::mock_context::MockDurableContext;
210    use durable_lambda_core::context::DurableContext;
211
212    // --- Task 4.1: Single step recording ---
213
214    #[tokio::test]
215    async fn test_record_single_step_operation() {
216        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
217        let _: Result<i32, String> = ctx.step("validate", || async { Ok(42) }).await.unwrap();
218
219        let recorded = ops.lock().await;
220        assert_eq!(recorded.len(), 1);
221        assert_eq!(recorded[0].name, "validate");
222        assert_eq!(recorded[0].operation_type, "step");
223        assert_eq!(recorded[0].to_type_name(), "step:validate");
224    }
225
226    // --- Task 4.2: Multi-step workflow sequence ---
227
228    #[tokio::test]
229    async fn test_record_multi_step_workflow_preserves_order() {
230        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
231        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
232        let _: Result<i32, String> = ctx.step("charge", || async { Ok(2) }).await.unwrap();
233        let _: Result<i32, String> = ctx.step("confirm", || async { Ok(3) }).await.unwrap();
234
235        let recorded = ops.lock().await;
236        assert_eq!(recorded.len(), 3);
237        assert_eq!(recorded[0].to_type_name(), "step:validate");
238        assert_eq!(recorded[1].to_type_name(), "step:charge");
239        assert_eq!(recorded[2].to_type_name(), "step:confirm");
240    }
241
242    // --- Task 4.3: assert_operations passes for matching ---
243
244    #[tokio::test]
245    async fn test_assert_operations_passes_for_matching_sequence() {
246        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
247        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
248        let _: Result<i32, String> = ctx.step("charge", || async { Ok(2) }).await.unwrap();
249
250        assert_operations(&ops, &["step:validate", "step:charge"]).await;
251    }
252
253    // --- Task 4.4: assert_operations panics for mismatch ---
254
255    #[tokio::test]
256    #[should_panic(expected = "Operation sequence mismatch")]
257    async fn test_assert_operations_panics_for_wrong_order() {
258        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
259        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
260        let _: Result<i32, String> = ctx.step("charge", || async { Ok(2) }).await.unwrap();
261
262        // Wrong order should panic
263        assert_operations(&ops, &["step:charge", "step:validate"]).await;
264    }
265
266    #[tokio::test]
267    #[should_panic(expected = "Operation sequence length mismatch")]
268    async fn test_assert_operations_panics_for_wrong_count() {
269        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
270        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
271
272        assert_operations(&ops, &["step:validate", "step:extra"]).await;
273    }
274
275    // --- Task 4.5: assert_operation_names ---
276
277    #[tokio::test]
278    async fn test_assert_operation_names_passes_for_matching() {
279        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
280        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
281        let _: Result<i32, String> = ctx.step("charge", || async { Ok(2) }).await.unwrap();
282
283        assert_operation_names(&ops, &["validate", "charge"]).await;
284    }
285
286    #[tokio::test]
287    #[should_panic(expected = "Operation name mismatch")]
288    async fn test_assert_operation_names_panics_for_mismatch() {
289        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
290        let _: Result<i32, String> = ctx.step("validate", || async { Ok(1) }).await.unwrap();
291
292        assert_operation_names(&ops, &["wrong_name"]).await;
293    }
294
295    // --- Task 4.6: assert_operation_count ---
296
297    #[tokio::test]
298    async fn test_assert_operation_count_passes() {
299        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
300        let _: Result<i32, String> = ctx.step("s1", || async { Ok(1) }).await.unwrap();
301        let _: Result<i32, String> = ctx.step("s2", || async { Ok(2) }).await.unwrap();
302
303        assert_operation_count(&ops, 2).await;
304    }
305
306    #[tokio::test]
307    #[should_panic(expected = "expected 5 operations")]
308    async fn test_assert_operation_count_panics_for_mismatch() {
309        let (_ctx, _calls, ops) = MockDurableContext::new().build().await;
310        assert_operation_count(&ops, 5).await;
311    }
312
313    // --- Task 4.7: Child context nesting ---
314
315    #[tokio::test]
316    async fn test_child_context_operations_recorded_in_sequence() {
317        let (mut ctx, _calls, ops) = MockDurableContext::new().build().await;
318
319        let _: Result<i32, String> = ctx.step("before", || async { Ok(1) }).await.unwrap();
320
321        let _: i32 = ctx
322            .child_context("sub", |mut child_ctx: DurableContext| async move {
323                let r: Result<i32, String> = child_ctx.step("inner", || async { Ok(42) }).await?;
324                Ok(r.unwrap())
325            })
326            .await
327            .unwrap();
328
329        let _: Result<i32, String> = ctx.step("after", || async { Ok(3) }).await.unwrap();
330
331        // Child context operations appear in flat sequence: before, sub (start), inner, after
332        let recorded = ops.lock().await;
333        // At minimum, "before" and "after" should be recorded.
334        // The child context and inner step should also produce checkpoint START calls.
335        assert!(
336            recorded.len() >= 3,
337            "expected at least 3 operations, got {}",
338            recorded.len()
339        );
340        assert_eq!(recorded[0].to_type_name(), "step:before");
341        // The last recorded operation should be "after"
342        assert_eq!(recorded.last().unwrap().to_type_name(), "step:after");
343    }
344
345    // --- Task 4.1 extra: replay mode produces no operation records ---
346
347    #[tokio::test]
348    async fn test_replay_mode_produces_no_operation_records() {
349        let (mut ctx, _calls, ops) = MockDurableContext::new()
350            .with_step_result("validate", "42")
351            .build()
352            .await;
353
354        let _: Result<i32, String> = ctx
355            .step("validate", || async { panic!("not executed") })
356            .await
357            .unwrap();
358
359        // Replay skips checkpoints, so no operations are recorded
360        assert_operation_count(&ops, 0).await;
361    }
362}