Skip to main content

wabot_testing/
async_jobs.rs

1//! Run command and cron handlers in a test. Port of
2//! `wabot-ts/src/testing/asyncHarness.ts`.
3//!
4//! ## Why not just call the handler
5//!
6//! TS's harness calls `handler.handle(command)` directly. That is the
7//! fast thing, and it skips everything the job runner does *around* a
8//! handler: the started → succeeded/failed transitions, the retry
9//! decision, and — since Phase 6b — restoring the audit actor and
10//! correlation id of whoever enqueued the work.
11//!
12//! Those are exactly the parts a test wants to pin, so this harness
13//! runs the **real `JobRunner`** against an in-memory repository.
14//! There are no polling workers and no database, so it stays a unit
15//! test in cost; what it gives up is only the scheduler's timing.
16
17use std::sync::Arc;
18
19use parking_lot::Mutex;
20use wabot_addon_async_in_memory::InMemoryJobRepository;
21use wabot_core::injection::Container;
22use wabot_feature_async::{
23    job, register_async_runtime, register_job_repository, AsyncError, CommandData,
24    CommandHandlerEntry, CommandRegistry, CronHandlerEntry, Job, JobRepository, JobRunner,
25};
26
27/// Executes handlers the way production does, minus the polling.
28///
29/// ```ignore
30/// let harness = AsyncHarness::builder()
31///     .command(SendEmailHandler::__handler_entry(&container))
32///     .container(container)
33///     .build();
34///
35/// harness.execute(&SendEmail { to: "ada@example.com".into() })
36///     .await
37///     .assert_succeeded();
38/// ```
39pub struct AsyncHarness {
40    container: Container,
41    repository: Arc<InMemoryJobRepository>,
42    registry: Arc<CommandRegistry>,
43    runner: Arc<JobRunner>,
44    /// Ids in the order they ran — the repository offers lookup by id
45    /// but no listing, and reaching past its trait for one would be a
46    /// harness depending on storage internals.
47    ran: Mutex<Vec<String>>,
48}
49
50impl AsyncHarness {
51    pub fn builder() -> AsyncHarnessBuilder {
52        AsyncHarnessBuilder {
53            container: None,
54            commands: Vec::new(),
55            crons: Vec::new(),
56        }
57    }
58
59    /// The container handlers resolve from — register their
60    /// dependencies here before building.
61    pub fn container(&self) -> &Container {
62        &self.container
63    }
64
65    /// Enqueue and run one command through the real runner, returning
66    /// the finished job.
67    ///
68    /// # Panics
69    ///
70    /// If no handler is registered for the command. A test that runs a
71    /// command nothing handles is testing nothing, and in production
72    /// that is a boot-time misconfiguration rather than a runtime
73    /// branch.
74    pub async fn execute<C: CommandData + serde::Serialize>(&self, command: &C) -> FinishedJob {
75        let payload = serde_json::to_value(command).expect("a serializable command");
76        self.execute_named(C::COMMAND_NAME, payload).await
77    }
78
79    /// [`AsyncHarness::execute`] with the command named directly — for
80    /// a cron's command, or a payload built as raw JSON.
81    pub async fn execute_named(
82        &self,
83        command_name: &str,
84        payload: serde_json::Value,
85    ) -> FinishedJob {
86        assert!(
87            self.registry.get(command_name).is_some(),
88            "AsyncHarness: no handler registered for command '{command_name}'. \
89             Registered: {:?}",
90            self.registry.command_names()
91        );
92
93        let job = job::new_job(job::JobData {
94            base: Default::default(),
95            command_name: command_name.to_string(),
96            command_data: payload,
97            scheduled_at: Some(chrono::Utc::now().timestamp_millis()),
98            started_at: None,
99            success_at: None,
100            failed_at: None,
101            retry_delays_seconds: self
102                .registry
103                .options_for(command_name)
104                .and_then(|o| o.retry_delays_seconds),
105            intent_number: None,
106            error: None,
107            acceptable_running_time_seconds: None,
108            stuck_retry_attempts: None,
109            dedup_key: None,
110            // Whatever attributed the *test's* scope, exactly as an
111            // enqueue from a request would capture it.
112            actor: wabot_core::audit::audit_actor(),
113            request_id: wabot_core::log_context::request_id(),
114        });
115
116        self.repository.create(&job).await.expect("stored");
117        self.ran.lock().push(job.id().to_string());
118        let result = self.runner.run(self.container.clone(), job.clone()).await;
119        let stored = self
120            .repository
121            .find(job.id())
122            .await
123            .expect("a readable repository")
124            .expect("the job it just ran");
125
126        FinishedJob {
127            job: stored,
128            run_error: result.err(),
129        }
130    }
131
132    /// Run one cron handler's body immediately — the tick without the
133    /// clock.
134    pub async fn run_cron(&self, command_name: &str) -> FinishedJob {
135        self.execute_named(command_name, serde_json::Value::Null)
136            .await
137    }
138
139    /// Every job the harness has run, oldest first.
140    pub async fn jobs(&self) -> Vec<Job> {
141        let ids = self.ran.lock().clone();
142        let mut jobs = Vec::with_capacity(ids.len());
143        for id in ids {
144            if let Ok(Some(job)) = self.repository.find(&id).await {
145                jobs.push(job);
146            }
147        }
148        jobs
149    }
150}
151
152/// A job after the runner finished with it.
153pub struct FinishedJob {
154    pub job: Job,
155    /// Set when the *runner* failed (an unregistered command, a
156    /// repository error) — distinct from the handler failing, which is
157    /// recorded on the job.
158    pub run_error: Option<AsyncError>,
159}
160
161impl FinishedJob {
162    pub fn succeeded(&self) -> bool {
163        job::was_success(&self.job)
164    }
165
166    /// The handler's error message, when it failed.
167    pub fn error(&self) -> Option<String> {
168        self.job.data().error.as_ref().map(|e| e.message.clone())
169    }
170
171    /// Attempts so far. Retries increment it, so this is how a test
172    /// checks a retry was scheduled rather than a final failure.
173    pub fn attempts(&self) -> u32 {
174        self.job.data().intent_number.unwrap_or(0)
175    }
176
177    /// When the job is queued to run again — `Some` after a failure
178    /// with retries configured.
179    pub fn retry_at_ms(&self) -> Option<i64> {
180        self.job
181            .data()
182            .failed_at
183            .is_none()
184            .then(|| self.job.data().scheduled_at)
185            .flatten()
186    }
187
188    /// Assert the handler succeeded, showing its error if not.
189    pub fn assert_succeeded(&self) -> &Self {
190        assert!(
191            self.succeeded(),
192            "expected the job to succeed, but it {}",
193            match self.error() {
194                Some(message) => format!("failed: {message}"),
195                None => "did not finish".to_string(),
196            }
197        );
198        self
199    }
200}
201
202pub struct AsyncHarnessBuilder {
203    container: Option<Container>,
204    commands: Vec<CommandHandlerEntry>,
205    crons: Vec<CronHandlerEntry>,
206}
207
208impl AsyncHarnessBuilder {
209    /// The container handlers resolve from. Register their
210    /// dependencies (a fake mailer, say) before building.
211    pub fn container(mut self, container: Container) -> Self {
212        self.container = Some(container);
213        self
214    }
215
216    /// A `#[command_handler]` entry, exactly as
217    /// `run_async_workers` would receive it.
218    pub fn command(mut self, entry: CommandHandlerEntry) -> Self {
219        self.commands.push(entry);
220        self
221    }
222
223    /// A `#[cron_handler]` entry.
224    pub fn cron(mut self, entry: CronHandlerEntry) -> Self {
225        self.crons.push(entry);
226        self
227    }
228
229    pub fn build(self) -> AsyncHarness {
230        let container = self.container.unwrap_or_default();
231        let repository = Arc::new(InMemoryJobRepository::new());
232        register_job_repository(&container, repository.clone());
233        register_async_runtime(&container);
234
235        let registry: Arc<CommandRegistry> = container.resolve();
236        for entry in self.commands {
237            registry.register(entry);
238        }
239        for entry in self.crons {
240            // The same bridge production installs: a cron tick runs as
241            // an ordinary job under the cron's command name.
242            registry.register(wabot_feature_async::cron_command_entry(&entry));
243        }
244
245        let runner = Arc::new(JobRunner::new(repository.clone(), registry.clone()));
246
247        AsyncHarness {
248            container,
249            repository,
250            registry,
251            runner,
252            ran: Mutex::new(Vec::new()),
253        }
254    }
255}