moniof 1.0.1

Actix middleware to monitor over-fetching and detect N+1 / overfetch database patterns (Mongo + SQL-agnostic)
Documentation
//! End-to-end tests for the `MoniOF` Actix middleware: it must scope the
//! task-local stats around the handler and surface them as response headers.

use actix_web::{test, web, App, HttpResponse};
use moniof::config::MoniOFConfig;
use moniof::core::stats::QueryKind;
use moniof::core::task_ctx::{mark, mark_latency};
use moniof::MoniOF;

/// Simulate `n` DB calls against `key`, each taking `ms`.
fn simulate_queries(key: &str, n: usize, ms: u128) {
    for _ in 0..n {
        mark(QueryKind::Sql, key);
        mark_latency(QueryKind::Sql, key, ms);
    }
}

fn header(res: &actix_web::dev::ServiceResponse, name: &str) -> Option<String> {
    res.headers().get(name).map(|v| v.to_str().unwrap().to_string())
}

#[actix_web::test]
async fn records_totals_and_latency_for_a_handler() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 3, 4);
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-total").as_deref(), Some("3"));
    assert_eq!(header(&res, "x-moniof-db-total-ms").as_deref(), Some("12"));
    assert!(header(&res, "x-moniof-elapsed-ms").is_some());
}

#[actix_web::test]
async fn reports_the_slowest_key_by_max_single_latency() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            // `orders` is called more often and has a higher *total*, but the
            // slowest single call belongs to `reports`.
            simulate_queries("orders", 4, 10);
            simulate_queries("reports", 1, 90);
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-slowest-key").as_deref(), Some("sql/reports"));
    assert_eq!(header(&res, "x-moniof-slowest-latency-ms").as_deref(), Some("90"));
}

#[actix_web::test]
async fn flags_an_n_plus_one_suspect() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 5, 2); // 5 >= min_count, 10ms >= min_total_ms
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-n-plus-one-key").as_deref(), Some("sql/users"));
    assert_eq!(header(&res, "x-moniof-n-plus-one-count").as_deref(), Some("5"));
    assert_eq!(header(&res, "x-moniof-n-plus-one-total-ms").as_deref(), Some("10"));
}

#[actix_web::test]
async fn does_not_flag_below_the_repeat_threshold() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 4, 50); // slow, but only 4 repeats
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-total").as_deref(), Some("4"));
    assert!(header(&res, "x-moniof-n-plus-one-key").is_none());
}

#[actix_web::test]
async fn of_mode_off_suppresses_n_plus_one_headers_but_keeps_counters() {
    let cfg = MoniOFConfig { of_mode: false, ..Default::default() };
    let app = test::init_service(App::new().wrap(MoniOF::with_config(cfg)).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 20, 5);
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-total").as_deref(), Some("20"));
    assert!(header(&res, "x-moniof-n-plus-one-key").is_none());
}

#[actix_web::test]
async fn add_response_headers_false_emits_nothing() {
    let cfg = MoniOFConfig { add_response_headers: false, ..Default::default() };
    let app = test::init_service(App::new().wrap(MoniOF::with_config(cfg)).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 9, 3);
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert!(header(&res, "x-moniof-total").is_none());
    assert!(header(&res, "x-moniof-db-total-ms").is_none());
    assert!(header(&res, "x-moniof-slowest-key").is_none());
}

#[actix_web::test]
async fn a_handler_with_no_queries_reports_zeroes() {
    let app = test::init_service(
        App::new()
            .wrap(MoniOF::new())
            .route("/", web::get().to(|| async { HttpResponse::Ok().finish() })),
    )
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;

    assert_eq!(header(&res, "x-moniof-total").as_deref(), Some("0"));
    assert_eq!(header(&res, "x-moniof-db-total-ms").as_deref(), Some("0"));
    assert!(header(&res, "x-moniof-slowest-key").is_none());
}

#[actix_web::test]
async fn stats_do_not_leak_between_requests() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            simulate_queries("users", 2, 1);
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    for _ in 0..3 {
        let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
        assert_eq!(
            header(&res, "x-moniof-total").as_deref(),
            Some("2"),
            "each request must start from a fresh QueryStats"
        );
    }
}

#[actix_web::test]
async fn stats_are_captured_across_await_points_in_the_handler() {
    let app = test::init_service(App::new().wrap(MoniOF::new()).route(
        "/",
        web::get().to(|| async {
            mark(QueryKind::Mongo, "users/find");
            actix_web::rt::task::yield_now().await;
            mark(QueryKind::Mongo, "users/find");
            HttpResponse::Ok().finish()
        }),
    ))
    .await;

    let res = test::call_service(&app, test::TestRequest::get().uri("/").to_request()).await;
    assert_eq!(header(&res, "x-moniof-total").as_deref(), Some("2"));
}