Skip to main content

awa_testing/
lib.rs

1//! Test utilities for Awa job queue.
2//!
3//! Provides `TestClient` for integration testing of job handlers.
4
5pub mod setup;
6
7use awa_model::{AwaError, JobArgs, JobRow};
8use awa_worker::context::ProgressState;
9use awa_worker::{JobContext, JobError, JobResult, Worker};
10use sqlx::PgPool;
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::atomic::AtomicBool;
14use std::sync::Arc;
15
16/// Test client for working with jobs in tests.
17///
18/// Provides helper methods for inserting jobs and executing them synchronously.
19pub struct TestClient {
20    pool: PgPool,
21}
22
23impl TestClient {
24    /// Create a test client from an existing pool.
25    pub async fn from_pool(pool: PgPool) -> Self {
26        Self { pool }
27    }
28
29    /// Get the underlying pool.
30    pub fn pool(&self) -> &PgPool {
31        &self.pool
32    }
33
34    /// Run migrations (call this in test setup).
35    pub async fn migrate(&self) -> Result<(), AwaError> {
36        awa_model::migrations::run(&self.pool).await?;
37        crate::setup::reset_runtime_backend(&self.pool).await;
38        Ok(())
39    }
40
41    /// Clean the awa schema (for test isolation).
42    pub async fn clean(&self) -> Result<(), AwaError> {
43        sqlx::query("DELETE FROM awa.jobs")
44            .execute(&self.pool)
45            .await?;
46        sqlx::query("DELETE FROM awa.queue_meta")
47            .execute(&self.pool)
48            .await?;
49        sqlx::query("DELETE FROM awa.runtime_storage_backends WHERE backend = 'queue_storage'")
50            .execute(&self.pool)
51            .await?;
52        Ok(())
53    }
54
55    /// Insert a job.
56    pub async fn insert(&self, args: &impl JobArgs) -> Result<JobRow, AwaError> {
57        awa_model::insert(&self.pool, args).await
58    }
59
60    /// Claim and execute a single job of type T using the given worker.
61    ///
62    /// This overload does NOT filter by queue, so it may pick up jobs from any
63    /// queue. Prefer `work_one_in_queue` for test isolation.
64    pub async fn work_one<W: Worker>(&self, worker: &W) -> Result<WorkResult, AwaError> {
65        self.work_one_in_queue(worker, None).await
66    }
67
68    /// Claim and execute a single job, optionally filtered by queue.
69    pub async fn work_one_in_queue<W: Worker>(
70        &self,
71        worker: &W,
72        queue: Option<&str>,
73    ) -> Result<WorkResult, AwaError> {
74        // Claim one job
75        let jobs: Vec<JobRow> = sqlx::query_as::<_, JobRow>(
76            r#"
77            WITH claimed AS (
78                SELECT id FROM awa.jobs
79                WHERE state = 'available' AND kind = $1
80                  AND ($2::text IS NULL OR queue = $2)
81                ORDER BY run_at ASC, id ASC
82                LIMIT 1
83                FOR UPDATE SKIP LOCKED
84            )
85            UPDATE awa.jobs
86            SET state = 'running',
87                attempt = attempt + 1,
88                run_lease = run_lease + 1,
89                attempted_at = now(),
90                heartbeat_at = now(),
91                deadline_at = now() + interval '5 minutes'
92            FROM claimed
93            WHERE awa.jobs.id = claimed.id
94            RETURNING awa.jobs.*
95            "#,
96        )
97        .bind(worker.kind())
98        .bind(queue)
99        .fetch_all(&self.pool)
100        .await?;
101
102        let job = match jobs.into_iter().next() {
103            Some(job) => job,
104            None => return Ok(WorkResult::NoJob),
105        };
106
107        let cancel = Arc::new(AtomicBool::new(false));
108        let state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>> =
109            Arc::new(HashMap::new());
110        let progress = Arc::new(std::sync::Mutex::new(ProgressState::new(
111            job.progress.clone(),
112        )));
113        let ctx = JobContext::new_for_testing(
114            job.clone(),
115            cancel,
116            state,
117            self.pool.clone(),
118            progress.clone(),
119        );
120
121        let result = worker.perform(&ctx).await;
122
123        // Snapshot progress from the buffer after handler execution
124        let progress_snapshot: Option<serde_json::Value> = {
125            let guard = progress.lock().expect("progress lock poisoned");
126            guard.clone_latest()
127        };
128
129        // Update job state based on result
130        match &result {
131            Ok(JobResult::Completed) => {
132                sqlx::query(
133                    "UPDATE awa.jobs SET state = 'completed', finalized_at = now(), progress = NULL WHERE id = $1",
134                )
135                .bind(job.id)
136                .execute(&self.pool)
137                .await?;
138                Ok(WorkResult::Completed(job))
139            }
140            Ok(JobResult::Cancel(reason)) => {
141                sqlx::query(
142                    "UPDATE awa.jobs SET state = 'cancelled', finalized_at = now(), progress = $2 WHERE id = $1",
143                )
144                .bind(job.id)
145                .bind(&progress_snapshot)
146                .execute(&self.pool)
147                .await?;
148                Ok(WorkResult::Cancelled(job, reason.clone()))
149            }
150            Ok(JobResult::RetryAfter(_)) | Err(JobError::Retryable(_)) => {
151                sqlx::query(
152                    "UPDATE awa.jobs SET state = 'retryable', finalized_at = now(), progress = $2 WHERE id = $1",
153                )
154                .bind(job.id)
155                .bind(&progress_snapshot)
156                .execute(&self.pool)
157                .await?;
158                Ok(WorkResult::Retryable(job))
159            }
160            Ok(JobResult::Snooze(_)) => {
161                sqlx::query(
162                    "UPDATE awa.jobs SET state = 'available', attempt = attempt - 1, progress = $2 WHERE id = $1",
163                )
164                .bind(job.id)
165                .bind(&progress_snapshot)
166                .execute(&self.pool)
167                .await?;
168                Ok(WorkResult::Snoozed(job))
169            }
170            Ok(JobResult::WaitForCallback(_)) => {
171                // Check if callback_id was registered
172                let has_callback: Option<(Option<uuid::Uuid>,)> =
173                    sqlx::query_as("SELECT callback_id FROM awa.jobs WHERE id = $1")
174                        .bind(job.id)
175                        .fetch_optional(&self.pool)
176                        .await?;
177                match has_callback {
178                    Some((Some(_),)) => {
179                        sqlx::query(
180                            "UPDATE awa.jobs SET state = 'waiting_external', heartbeat_at = NULL, deadline_at = NULL, progress = $2 WHERE id = $1",
181                        )
182                        .bind(job.id)
183                        .bind(&progress_snapshot)
184                        .execute(&self.pool)
185                        .await?;
186                        let updated = self.get_job(job.id).await?;
187                        Ok(WorkResult::WaitingExternal(updated))
188                    }
189                    _ => {
190                        sqlx::query(
191                            "UPDATE awa.jobs SET state = 'failed', finalized_at = now() WHERE id = $1",
192                        )
193                        .bind(job.id)
194                        .execute(&self.pool)
195                        .await?;
196                        Ok(WorkResult::Failed(
197                            job,
198                            "WaitForCallback returned without calling register_callback"
199                                .to_string(),
200                        ))
201                    }
202                }
203            }
204            Err(JobError::Terminal(msg)) => {
205                sqlx::query(
206                    "UPDATE awa.jobs SET state = 'failed', finalized_at = now(), progress = $2 WHERE id = $1",
207                )
208                .bind(job.id)
209                .bind(&progress_snapshot)
210                .execute(&self.pool)
211                .await?;
212                Ok(WorkResult::Failed(job, msg.clone()))
213            }
214        }
215    }
216
217    /// Get a job by ID.
218    pub async fn get_job(&self, job_id: i64) -> Result<JobRow, AwaError> {
219        awa_model::admin::get_job(&self.pool, job_id).await
220    }
221}
222
223/// Result of `work_one`.
224#[derive(Debug)]
225pub enum WorkResult {
226    /// No job was available.
227    NoJob,
228    /// Job completed successfully.
229    Completed(JobRow),
230    /// Job was retried.
231    Retryable(JobRow),
232    /// Job was snoozed.
233    Snoozed(JobRow),
234    /// Job was cancelled.
235    Cancelled(JobRow, String),
236    /// Job failed terminally.
237    Failed(JobRow, String),
238    /// Job is waiting for an external callback.
239    WaitingExternal(JobRow),
240}
241
242impl WorkResult {
243    pub fn is_completed(&self) -> bool {
244        matches!(self, WorkResult::Completed(_))
245    }
246
247    pub fn is_failed(&self) -> bool {
248        matches!(self, WorkResult::Failed(_, _))
249    }
250
251    pub fn is_no_job(&self) -> bool {
252        matches!(self, WorkResult::NoJob)
253    }
254
255    pub fn is_waiting_external(&self) -> bool {
256        matches!(self, WorkResult::WaitingExternal(_))
257    }
258}