queuey-macros 0.2.1

Derive macros (#[derive(Queues)], #[derive(Job)]) for queuey
Documentation
//! Behavioural tests for the code generated by `#[derive(Queues)]` and
//! `#[derive(Job)]`.
//!
//! These assert on struct fields only: `Backoff::delay_for` and
//! `RetryPolicy::decide` are deliberately never called, since the retry maths
//! lives in the core crate and is tested there.

use std::time::Duration;

use queuey_core::{Backoff, Job, QueueSet, RetryPolicy};
use queuey_macros::{Job, Queues};
use serde::{Deserialize, Serialize};

/// Stand-in for the `queuey` facade, exercising `crate = "..."`.
pub mod facade {
    pub use queuey_core::*;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
#[queues(prefix = "myapp")]
enum AppQueues {
    #[queue(prefetch = 10)]
    Emails,
    #[queue(
        name = "img",
        retry(max_attempts = 3, backoff = "exponential", base = "1s", max = "2m")
    )]
    Images,
    HTTPCalls,
    SendEmails,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
enum Plain {
    #[queue(prefetch = 1, durable = false, message_ttl = "30s")]
    Fast,
    #[queue(retry(max_attempts = 2, backoff = "fixed", delay = "500ms"))]
    Slow,
    #[queue(name = "custom.name", retry(backoff = "none"))]
    Custom,
    #[queue(message_ttl = "2 m")]
    WhitespaceTtl,
    #[queue(message_ttl = "45")]
    BareSeconds,
    #[queue(retry())]
    DefaultRetry,
    #[queue(retry(
        max_attempts = 9,
        backoff = "exponential",
        base = "250ms",
        factor = 1.5,
        max = "1h",
        jitter = false
    ))]
    FullyTuned,
}

/// `max_priority` in every shape: explicit levels, explicitly disabled, omitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
enum Priorities {
    #[queue(max_priority = 3)]
    Tiered,
    #[queue(max_priority = 0)]
    Unprioritised,
    #[queue(prefetch = 4)]
    Defaulted,
    #[queue(max_priority = 255)]
    EveryLevel,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
#[queues(crate = "crate::facade", prefix = "aliased")]
enum ViaFacade {
    Work,
}

/// A `factor` that happens to spell out a well known constant. The generated
/// `3.14159265358979f64` would otherwise trip `clippy::approx_constant` in the
/// *user's* crate under `-D warnings`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Queues)]
enum ApproxConstants {
    #[queue(retry(factor = 3.14159265358979))]
    Pi,
}

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Emails)]
struct SendEmail {
    to: String,
}

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Images, name = "images.resize")]
struct ResizeImage {
    path: String,
}

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Emails, retry(max_attempts = 5))]
struct RetryingJob;

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Emails, retry(max_attempts = 4, backoff = "fixed", delay = "2s"))]
struct FixedRetryJob;

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = AppQueues::Emails, retry(max_attempts = 1, backoff = "none"))]
struct NoBackoffJob;

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = self::ViaFacade::Work, crate = "crate::facade")]
enum FacadeJob {
    Only,
}

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = crate::Plain::Custom)]
struct FullyQualifiedQueue {
    id: u64,
}

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = ApproxConstants::Pi, retry(factor = 2.71828182845905, max = "10m"))]
struct ApproxConstantJob;

fn queue_of<J: Job>() -> J::Queue {
    J::QUEUE
}

#[test]
fn all_returns_variants_in_declaration_order() {
    assert_eq!(
        AppQueues::all(),
        &[
            AppQueues::Emails,
            AppQueues::Images,
            AppQueues::HTTPCalls,
            AppQueues::SendEmails
        ]
    );
    assert_eq!(Plain::all().len(), 7);
    assert_eq!(Plain::all()[0], Plain::Fast);
}

#[test]
fn names_apply_the_prefix_and_snake_case_default() {
    assert_eq!(AppQueues::Emails.name(), "myapp.emails");
    assert_eq!(AppQueues::Images.name(), "myapp.img");
    assert_eq!(AppQueues::HTTPCalls.name(), "myapp.http_calls");
    assert_eq!(AppQueues::SendEmails.name(), "myapp.send_emails");
}

#[test]
fn names_without_a_prefix_are_bare() {
    assert_eq!(Plain::Fast.name(), "fast");
    assert_eq!(Plain::Slow.name(), "slow");
    assert_eq!(Plain::Custom.name(), "custom.name");
    assert_eq!(Plain::WhitespaceTtl.name(), "whitespace_ttl");
    assert_eq!(ViaFacade::Work.name(), "aliased.work");
}

#[test]
fn names_are_static_strings() {
    fn keep(name: &'static str) -> &'static str {
        name
    }
    assert_eq!(keep(AppQueues::Emails.name()), "myapp.emails");
}

#[test]
fn from_name_round_trips() {
    for queue in AppQueues::all() {
        assert_eq!(AppQueues::from_name(queue.name()), Some(*queue));
    }
    assert_eq!(Plain::from_name("custom.name"), Some(Plain::Custom));
    assert_eq!(AppQueues::from_name("emails"), None);
    assert_eq!(AppQueues::from_name("nope"), None);
}

#[test]
fn config_defaults_match_queue_config_new() {
    let config = AppQueues::HTTPCalls.config();
    assert_eq!(config.name, "myapp.http_calls");
    assert_eq!(config.prefetch, 16);
    assert!(config.durable);
    assert_eq!(config.message_ttl, None);
    assert_eq!(config.retry, RetryPolicy::default());
}

#[test]
fn config_carries_prefetch_durable_and_ttl() {
    let config = Plain::Fast.config();
    assert_eq!(config.name, "fast");
    assert_eq!(config.prefetch, 1);
    assert!(!config.durable);
    assert_eq!(config.message_ttl, Some(Duration::from_secs(30)));

    assert_eq!(AppQueues::Emails.config().prefetch, 10);
    assert_eq!(
        Plain::WhitespaceTtl.config().message_ttl,
        Some(Duration::from_secs(120))
    );
    assert_eq!(
        Plain::BareSeconds.config().message_ttl,
        Some(Duration::from_secs(45))
    );
}

#[test]
fn config_carries_max_priority() {
    assert_eq!(Priorities::Tiered.config().max_priority, Some(3));
    // `0` is not "omitted": it explicitly turns priorities off.
    assert_eq!(Priorities::Unprioritised.config().max_priority, None);
    // Omitted falls through to the `QueueConfig` default.
    assert_eq!(Priorities::Defaulted.config().max_priority, Some(10));
    assert_eq!(Priorities::EveryLevel.config().max_priority, Some(255));
}

#[test]
fn config_retry_exponential_with_explicit_values() {
    assert_eq!(
        AppQueues::Images.config().retry,
        RetryPolicy {
            max_attempts: 3,
            backoff: Backoff::Exponential {
                base: Duration::from_secs(1),
                factor: 2.0,
                max: Duration::from_secs(120),
                jitter: true,
            },
        }
    );
}

#[test]
fn config_retry_defaults_mirror_backoff_exponential() {
    assert_eq!(
        Plain::DefaultRetry.config().retry,
        RetryPolicy {
            max_attempts: 3,
            backoff: Backoff::exponential(),
        }
    );
}

#[test]
fn config_retry_fully_tuned_exponential() {
    assert_eq!(
        Plain::FullyTuned.config().retry,
        RetryPolicy {
            max_attempts: 9,
            backoff: Backoff::Exponential {
                base: Duration::from_millis(250),
                factor: 1.5,
                max: Duration::from_secs(3600),
                jitter: false,
            },
        }
    );
}

#[test]
fn config_retry_fixed_and_none() {
    assert_eq!(
        Plain::Slow.config().retry,
        RetryPolicy {
            max_attempts: 2,
            backoff: Backoff::Fixed(Duration::from_millis(500)),
        }
    );
    assert_eq!(
        Plain::Custom.config().retry,
        RetryPolicy {
            max_attempts: 3,
            backoff: Backoff::None,
        }
    );
}

#[test]
fn job_name_defaults_to_the_type_path() {
    assert_eq!(
        SendEmail::NAME,
        concat!(module_path!(), "::", stringify!(SendEmail))
    );
    assert!(SendEmail::NAME.ends_with("::SendEmail"));
}

#[test]
fn job_name_can_be_overridden() {
    assert_eq!(ResizeImage::NAME, "images.resize");
}

#[test]
fn job_queue_associates_the_right_set_and_variant() {
    assert_eq!(SendEmail::QUEUE, AppQueues::Emails);
    assert_eq!(ResizeImage::QUEUE, AppQueues::Images);
    assert_eq!(FacadeJob::QUEUE, ViaFacade::Work);
    assert_eq!(FullyQualifiedQueue::QUEUE, Plain::Custom);

    assert_eq!(queue_of::<SendEmail>(), AppQueues::Emails);
    assert_eq!(queue_of::<FullyQualifiedQueue>(), Plain::Custom);

    let queue: <SendEmail as Job>::Queue = AppQueues::Images;
    assert_eq!(queue.name(), "myapp.img");
}

#[test]
fn retry_policy_is_none_without_the_attribute() {
    assert_eq!(SendEmail::retry_policy(), None);
    assert_eq!(ResizeImage::retry_policy(), None);
    assert_eq!(FacadeJob::retry_policy(), None);
}

#[test]
fn retry_policy_uses_exponential_defaults() {
    assert_eq!(
        RetryingJob::retry_policy(),
        Some(RetryPolicy {
            max_attempts: 5,
            backoff: Backoff::exponential(),
        })
    );
}

#[test]
fn retry_policy_supports_fixed_and_none_backoff() {
    assert_eq!(
        FixedRetryJob::retry_policy(),
        Some(RetryPolicy {
            max_attempts: 4,
            backoff: Backoff::Fixed(Duration::from_secs(2)),
        })
    );
    assert_eq!(
        NoBackoffJob::retry_policy(),
        Some(RetryPolicy {
            max_attempts: 1,
            backoff: Backoff::None,
        })
    );
}

#[test]
fn constant_like_factors_round_trip_without_clippy_complaining() {
    let Backoff::Exponential { factor, .. } = ApproxConstants::Pi.config().retry.backoff else {
        panic!("expected an exponential backoff");
    };
    assert!((factor - std::f64::consts::PI).abs() < 1e-12, "{factor}");

    let Some(RetryPolicy {
        backoff: Backoff::Exponential { factor, max, .. },
        ..
    }) = ApproxConstantJob::retry_policy()
    else {
        panic!("expected an exponential backoff");
    };
    assert!((factor - std::f64::consts::E).abs() < 1e-12, "{factor}");
    assert_eq!(max, Duration::from_secs(600));
}

#[test]
fn jobs_stay_serializable() {
    let job = SendEmail {
        to: "someone@example.com".to_owned(),
    };
    let encoded = serde_json::to_string(&job).unwrap();
    let decoded: SendEmail = serde_json::from_str(&encoded).unwrap();
    assert_eq!(decoded.to, job.to);
}