Skip to main content

rustlavel_queue/
fake.rs

1//! `Queue::fake()` — a queue that records instead of running.
2//!
3//! Application tests need this far more than they need a real queue. What a
4//! controller test wants to know is "did registering a user dispatch the
5//! welcome email?", not "does the welcome email work?" — that is the job's own
6//! test. So the fake accepts pushes, remembers them, and never hands anything
7//! to a worker.
8//!
9//! ```ignore
10//! let queue = fake();
11//! client.post("/register", &[("email", "ada@example.com")]).await.assert_ok();
12//! queue.assert_pushed("send-welcome-email");
13//! ```
14//!
15//! The assertions panic with a message that lists what *was* pushed, because
16//! "assertion failed" on its own leaves you re-running the test with print
17//! statements.
18
19use crate::job::{BoxFuture, FailedJob, QueuedJob, ReservedJob};
20use crate::queue::{Queue, record_pushed};
21use rustlavel_core::{Error, Result};
22use std::sync::Mutex;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::time::Duration;
25
26/// The Rust spelling of Laravel's `Queue::fake()`.
27pub fn fake() -> FakeQueue {
28    FakeQueue::new()
29}
30
31/// A queue that records dispatches and runs nothing.
32#[derive(Default)]
33pub struct FakeQueue {
34    pushed: Mutex<Vec<QueuedJob>>,
35    next_id: AtomicU64,
36}
37
38impl FakeQueue {
39    pub fn new() -> Self {
40        FakeQueue { pushed: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) }
41    }
42
43    /// Every job pushed, in order.
44    pub fn all(&self) -> Vec<QueuedJob> {
45        self.pushed.lock().expect("the fake queue was poisoned").clone()
46    }
47
48    /// The jobs pushed under one name — for asserting on a payload.
49    pub fn pushed(&self, name: &str) -> Vec<QueuedJob> {
50        self.all().into_iter().filter(|job| job.name == name).collect()
51    }
52
53    /// How many jobs were pushed in total.
54    pub fn count(&self) -> usize {
55        self.pushed.lock().expect("the fake queue was poisoned").len()
56    }
57
58    /// Forget everything, so one test can reuse a fake across phases.
59    pub fn reset(&self) {
60        self.pushed.lock().expect("the fake queue was poisoned").clear();
61    }
62
63    /// Assert a job with this name was dispatched at least once.
64    #[track_caller]
65    pub fn assert_pushed(&self, name: &str) -> &Self {
66        let found = self.pushed(name);
67        assert!(
68            !found.is_empty(),
69            "expected `{name}` to have been pushed, but {}",
70            self.summary()
71        );
72        self
73    }
74
75    /// Assert a job with this name was dispatched exactly `times` times.
76    #[track_caller]
77    pub fn assert_pushed_times(&self, name: &str, times: usize) -> &Self {
78        let found = self.pushed(name).len();
79        assert_eq!(
80            found, times,
81            "expected `{name}` to have been pushed {times} time(s), but it was pushed {found} \
82             time(s); {}",
83            self.summary()
84        );
85        self
86    }
87
88    /// Assert a job with this name was never dispatched.
89    #[track_caller]
90    pub fn assert_not_pushed(&self, name: &str) -> &Self {
91        self.assert_pushed_times(name, 0)
92    }
93
94    /// Assert nothing at all was dispatched.
95    #[track_caller]
96    pub fn assert_nothing_pushed(&self) -> &Self {
97        assert!(self.count() == 0, "expected nothing to have been pushed, but {}", self.summary());
98        self
99    }
100
101    /// Assert a job was dispatched onto a particular queue.
102    #[track_caller]
103    pub fn assert_pushed_on(&self, queue: &str, name: &str) -> &Self {
104        let found = self.pushed(name);
105        assert!(
106            found.iter().any(|job| job.queue == queue),
107            "expected `{name}` to have been pushed on `{queue}`, but it went to {:?}; {}",
108            found.iter().map(|job| job.queue.as_str()).collect::<Vec<_>>(),
109            self.summary()
110        );
111        self
112    }
113
114    /// Assert a job was dispatched with a delay of at least `delay`.
115    #[track_caller]
116    pub fn assert_pushed_later(&self, name: &str, delay: Duration) -> &Self {
117        let found = self.pushed(name);
118        assert!(
119            found.iter().any(|job| job.delay >= delay),
120            "expected `{name}` to have been delayed by at least {delay:?}, but the delays were \
121             {:?}; {}",
122            found.iter().map(|job| job.delay).collect::<Vec<_>>(),
123            self.summary()
124        );
125        self
126    }
127
128    /// What actually happened, for a failure message.
129    fn summary(&self) -> String {
130        let all = self.all();
131        if all.is_empty() {
132            return "nothing was pushed at all".to_string();
133        }
134
135        let names: Vec<String> =
136            all.iter().map(|job| format!("{} on `{}`", job.name, job.queue)).collect();
137        format!("what was pushed: {}", names.join(", "))
138    }
139}
140
141impl Queue for FakeQueue {
142    fn driver(&self) -> &'static str {
143        "fake"
144    }
145
146    fn push(&self, job: QueuedJob) -> BoxFuture<'_, Result<String>> {
147        Box::pin(async move {
148            let id = self.next_id.fetch_add(1, Ordering::SeqCst).to_string();
149            self.pushed.lock().expect("the fake queue was poisoned").push(job.clone());
150
151            // Still reported on the bus: a Telescope test should see the same
152            // events an application would produce.
153            record_pushed(self.driver(), &job, &id);
154            Ok(id)
155        })
156    }
157
158    /// Always empty. A fake that handed jobs back would run them, which is the
159    /// one thing it exists not to do.
160    fn pop<'a>(&'a self, _queue: &'a str) -> BoxFuture<'a, Result<Option<ReservedJob>>> {
161        Box::pin(async { Ok(None) })
162    }
163
164    fn size<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
165        Box::pin(async move {
166            Ok(self.all().iter().filter(|job| job.queue == queue).count() as u64)
167        })
168    }
169
170    fn delete<'a>(&'a self, _job: &'a ReservedJob) -> BoxFuture<'a, Result<()>> {
171        Box::pin(async { Ok(()) })
172    }
173
174    fn release<'a>(&'a self, _job: &'a ReservedJob, _delay: Duration) -> BoxFuture<'a, Result<()>> {
175        Box::pin(async {
176            Err(Error::msg("a faked queue never reserves a job, so it cannot release one"))
177        })
178    }
179
180    fn fail<'a>(&'a self, _job: &'a ReservedJob, _error: &'a str) -> BoxFuture<'a, Result<()>> {
181        Box::pin(async {
182            Err(Error::msg("a faked queue never reserves a job, so it cannot fail one"))
183        })
184    }
185
186    fn failed_jobs(&self) -> BoxFuture<'_, Result<Vec<FailedJob>>> {
187        Box::pin(async { Ok(Vec::new()) })
188    }
189
190    fn clear<'a>(&'a self, queue: &'a str) -> BoxFuture<'a, Result<u64>> {
191        Box::pin(async move {
192            let mut pushed = self.pushed.lock().expect("the fake queue was poisoned");
193            let before = pushed.len();
194            pushed.retain(|job| job.queue != queue);
195            Ok((before - pushed.len()) as u64)
196        })
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::queue::QueueExt;
204    use crate::tests_support::{CountingJob, counter};
205    use rustlavel_core::Json;
206
207    #[tokio::test]
208    async fn a_faked_queue_records_a_dispatch_without_running_it() {
209        let queue = fake();
210        let tally = counter();
211
212        queue.dispatch(&CountingJob::new(tally, 5)).await.unwrap();
213
214        queue.assert_pushed("counting");
215        assert_eq!(tally.runs(), 0, "a fake must never run the job");
216        assert_eq!(queue.count(), 1);
217    }
218
219    #[tokio::test]
220    async fn a_faked_queue_hands_nothing_to_a_worker() {
221        let queue = fake();
222        queue.push(QueuedJob::new("anything", Json::Null)).await.unwrap();
223
224        assert!(queue.pop("default").await.unwrap().is_none());
225    }
226
227    #[tokio::test]
228    async fn counting_assertions_distinguish_between_job_names() {
229        let queue = fake();
230        for _ in 0..3 {
231            queue.push(QueuedJob::new("a", Json::Null)).await.unwrap();
232        }
233        queue.push(QueuedJob::new("b", Json::Null)).await.unwrap();
234
235        queue.assert_pushed_times("a", 3).assert_pushed_times("b", 1).assert_not_pushed("c");
236    }
237
238    #[tokio::test]
239    async fn nothing_pushed_holds_on_a_fresh_fake_and_after_a_reset() {
240        let queue = fake();
241        queue.assert_nothing_pushed();
242
243        queue.push(QueuedJob::new("a", Json::Null)).await.unwrap();
244        queue.reset();
245        queue.assert_nothing_pushed();
246    }
247
248    #[tokio::test]
249    async fn the_queue_and_delay_a_job_was_given_are_both_recorded() {
250        let queue = fake();
251        queue.push_on("emails", QueuedJob::new("welcome", Json::Null)).await.unwrap();
252        queue
253            .later(Duration::from_secs(600), QueuedJob::new("digest", Json::Null))
254            .await
255            .unwrap();
256
257        queue.assert_pushed_on("emails", "welcome");
258        queue.assert_pushed_later("digest", Duration::from_secs(300));
259        assert_eq!(queue.size("emails").await.unwrap(), 1);
260    }
261
262    #[tokio::test]
263    async fn the_recorded_payload_is_available_for_inspection() {
264        let queue = fake();
265        queue
266            .push(QueuedJob::new("invoice", Json::object([("order", Json::from(42))])))
267            .await
268            .unwrap();
269
270        let pushed = queue.pushed("invoice");
271        assert_eq!(pushed.len(), 1);
272        assert_eq!(pushed[0].payload.get("order").and_then(Json::as_i64), Some(42));
273    }
274
275    #[tokio::test]
276    async fn a_failed_assertion_says_what_was_pushed_instead() {
277        let queue = fake();
278        queue.push(QueuedJob::new("actual", Json::Null)).await.unwrap();
279
280        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
281            queue.assert_pushed("expected");
282        }))
283        .unwrap_err();
284
285        let message = rustlavel_http::panic::message_of(panic.as_ref());
286        assert!(message.contains("expected `expected` to have been pushed"), "{message}");
287        assert!(message.contains("actual on `default`"), "{message}");
288    }
289
290    #[tokio::test]
291    async fn a_fake_is_usable_wherever_a_real_queue_is() {
292        let queue: std::sync::Arc<dyn Queue> = std::sync::Arc::new(fake());
293
294        queue.dispatch(&CountingJob::new(counter(), 1)).await.unwrap();
295
296        assert_eq!(queue.driver(), "fake");
297        assert_eq!(queue.size("default").await.unwrap(), 1);
298        assert!(queue.failed_jobs().await.unwrap().is_empty());
299    }
300}