aviso 2.0.0-rc.2

Core client library for aviso-server, ECMWF's notification service.
Documentation
// (C) Copyright 2024- ECMWF and individual contributors.
//
// This software is licensed under the terms of the Apache Licence Version 2.0
// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
// In applying this licence, ECMWF does not waive the privileges and immunities
// granted to it by virtue of its status as an intergovernmental organisation nor
// does it submit to any jurisdiction.

//! Unit tests for the trigger module's public surface: `Trigger`
//! builder defaults and setters, `Debug`/`Clone` shape,
//! `TriggerKindLabel` `Display`, `TriggerError` variant carriers
//! (including the load-bearing redaction discipline on
//! `TriggerError::Template`).

#![allow(
    clippy::unwrap_used,
    clippy::panic,
    reason = "test code: unwrap on constructor success and panic on unexpected variant are the standard test diagnostics"
)]

use std::path::PathBuf;

use super::{Trigger, TriggerError, TriggerKindLabel};
use crate::watch::trigger::kind::TriggerKind;

#[test]
fn echo_constructor_uses_default_retries_zero_and_required_true() {
    let trigger = Trigger::echo();
    assert!(matches!(trigger.kind, TriggerKind::Echo { .. }));
    assert_eq!(trigger.retries, 0);
    assert!(trigger.required);
}

#[test]
fn log_constructor_uses_default_retries_zero_and_required_true() {
    let trigger = Trigger::log("/tmp/some.log");
    let TriggerKind::Log { path } = &trigger.kind else {
        panic!("expected Log variant");
    };
    assert_eq!(path, &PathBuf::from("/tmp/some.log"));
    assert_eq!(trigger.retries, 0);
    assert!(trigger.required);
}

#[test]
fn retries_setter_overrides_default() {
    let trigger = Trigger::echo().retries(7);
    assert_eq!(trigger.retries, 7);
    assert!(matches!(trigger.kind, TriggerKind::Echo { .. }));
    assert!(trigger.required);
}

#[test]
fn required_setter_overrides_default() {
    let trigger = Trigger::echo().required(false);
    assert!(!trigger.required);
    assert_eq!(trigger.retries, 0);
}

#[test]
fn trigger_clone_preserves_all_fields() {
    let original = Trigger::log("/tmp/clone.log")
        .retries(3)
        .required(false)
        .timeout(std::time::Duration::from_secs(15))
        .fail_fast(false);
    let cloned = original.clone();
    let (TriggerKind::Log { path: a }, TriggerKind::Log { path: b }) =
        (&original.kind, &cloned.kind)
    else {
        panic!("clone did not preserve Log variant");
    };
    assert_eq!(a, b);
    assert_eq!(cloned.retries, original.retries);
    assert_eq!(cloned.required, original.required);
    assert_eq!(cloned.timeout, original.timeout);
    assert_eq!(cloned.fail_fast, original.fail_fast);
}

#[test]
fn trigger_debug_includes_all_fields() {
    let trigger = Trigger::log("/tmp/dbg.log")
        .retries(2)
        .required(true)
        .timeout(std::time::Duration::from_secs(7))
        .fail_fast(false);
    let dbg = format!("{trigger:?}");
    assert!(dbg.contains("Trigger"), "got: {dbg}");
    assert!(dbg.contains("kind"), "got: {dbg}");
    assert!(dbg.contains("retries"), "got: {dbg}");
    assert!(dbg.contains("required"), "got: {dbg}");
    assert!(dbg.contains("timeout"), "got: {dbg}");
    assert!(dbg.contains("fail_fast"), "got: {dbg}");
    assert!(dbg.contains("/tmp/dbg.log"), "got: {dbg}");
}

#[test]
fn trigger_kind_label_display_for_echo() {
    let label = TriggerKindLabel::Echo;
    assert_eq!(label.to_string(), "echo");
}

#[test]
fn trigger_kind_label_display_for_log_includes_path() {
    let label = TriggerKindLabel::Log {
        path: PathBuf::from("/var/log/aviso.log"),
    };
    assert_eq!(label.to_string(), "log(/var/log/aviso.log)");
}

#[cfg(unix)]
#[test]
fn trigger_kind_label_display_for_command_is_bare() {
    let label = TriggerKindLabel::Command;
    assert_eq!(label.to_string(), "command");
}

#[test]
fn trigger_error_io_variant_carries_io_kind() {
    let err: TriggerError = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe").into();
    match err {
        TriggerError::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::BrokenPipe),
        other => panic!("expected Io, got {other:?}"),
    }
}

#[test]
fn trigger_error_encode_variant_carries_serde_error() {
    let parse_err = serde_json::from_str::<i32>("not a number").unwrap_err();
    let err: TriggerError = parse_err.into();
    assert!(matches!(err, TriggerError::Encode(_)));
}

#[test]
fn trigger_error_template_display_uses_safe_context_not_raw_template() {
    use crate::watch::TemplateErrorKind;
    let err = TriggerError::Template {
        context: "command".to_string(),
        field: "notification.payload.target".to_string(),
        kind: TemplateErrorKind::Missing,
    };
    let rendered = err.to_string();
    assert!(rendered.contains("command"), "got: {rendered}");
    assert!(
        rendered.contains("notification.payload.target"),
        "got: {rendered}"
    );
    assert!(rendered.contains("Missing"), "got: {rendered}");
}

#[test]
fn trigger_kind_label_display_for_webhook_is_bare() {
    let label = TriggerKindLabel::Webhook;
    assert_eq!(label.to_string(), "webhook");
}

#[test]
fn webhook_constructor_uses_default_timeout_and_fail_fast_true() {
    let trigger = Trigger::webhook("https://example.com/hook");
    assert!(matches!(trigger.kind, TriggerKind::Webhook(_)));
    assert_eq!(trigger.retries, 0);
    assert!(trigger.required);
    assert_eq!(trigger.timeout, Some(super::DEFAULT_WEBHOOK_TIMEOUT));
    assert!(trigger.fail_fast);
}

#[test]
fn webhook_timeout_setter_overrides_default() {
    let trigger =
        Trigger::webhook("https://example.com/hook").timeout(std::time::Duration::from_secs(5));
    assert_eq!(trigger.timeout, Some(std::time::Duration::from_secs(5)));
}

#[test]
fn method_setter_silently_ignored_on_echo() {
    use super::HttpMethod;
    let trigger = Trigger::echo().method(HttpMethod::Get);
    assert!(matches!(trigger.kind, TriggerKind::Echo { .. }));
}

#[test]
fn header_setter_silently_ignored_on_log() {
    let trigger = Trigger::log("/tmp/ignored.log").header("X-Foo", "bar");
    let TriggerKind::Log { .. } = &trigger.kind else {
        panic!("expected Log variant after setter no-op");
    };
}

#[cfg(unix)]
#[test]
fn body_template_setter_silently_ignored_on_command() {
    let trigger = Trigger::command("echo hi").body_template(r#"{"k": "v"}"#);
    assert!(matches!(trigger.kind, TriggerKind::Command(_)));
}

#[test]
fn trigger_error_webhook_variant_carries_status_and_body_tail() {
    let err = TriggerError::Webhook {
        status: Some(reqwest::StatusCode::BAD_GATEWAY),
        body_tail: "upstream is down".to_string(),
    };
    let rendered = err.to_string();
    assert!(rendered.contains("502"), "got: {rendered}");
    assert!(rendered.contains("upstream is down"), "got: {rendered}");
}