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