Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / ddcd / tests / api.rs

Back to remindwall — Current RemindWall

Download this file

//! Integration tests against a fake m1ddc so no hardware (or Homebrew) is
//! needed. The fake is a shell script the test writes per-case; its argv log
//! doubles as the assertion surface for what ddcd actually asked the bus.

use std::path::PathBuf;
use std::time::Duration;

use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;

use ddcd::{Config, GUARD_HEADER, app};

struct Fake {
    dir: tempfile::TempDir,
}

impl Fake {
    /// Writes an executable fake m1ddc with the given body. `$LOG` expands to
    /// a per-fake log file path the script can append argv to.
    fn new(script_body: &str) -> Self {
        let dir = tempfile::tempdir().expect("tempdir");
        let fake = Self { dir };
        fake.write_tool("m1ddc", script_body);
        fake
    }

    /// Adds another fake tool (pmset, caffeinate) sharing the same call log.
    fn write_tool(&self, name: &str, script_body: &str) {
        use std::os::unix::fs::PermissionsExt;
        let log = self.dir.path().join("calls.log");
        let path = self.dir.path().join(name);
        let script = format!(
            "#!/bin/bash\nLOG={}\nTOOL={}\n{}\n",
            log.display(),
            name,
            script_body
        );
        std::fs::write(&path, script).expect("write fake");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
    }

    fn path(&self) -> PathBuf {
        self.dir.path().join("m1ddc")
    }

    fn calls(&self) -> Vec<String> {
        std::fs::read_to_string(self.dir.path().join("calls.log"))
            .unwrap_or_default()
            .lines()
            .map(str::to_string)
            .collect()
    }

    /// Generous per-call timeout: tests run in parallel and bash spawn under
    /// contention can take hundreds of ms. The timeout PATH is exercised by
    /// wedged_m1ddc_times_out_instead_of_hanging with its own short config.
    /// No retries by default - retry behavior has dedicated tests.
    fn config(&self) -> Config {
        Config {
            m1ddc_path: self.path(),
            pmset_path: self.dir.path().join("pmset"),
            caffeinate_path: self.dir.path().join("caffeinate"),
            max_luminance: 100,
            timeout: Duration::from_secs(3),
            retry_delays: vec![],
        }
    }

    fn config_with_retries(&self) -> Config {
        Config {
            retry_delays: vec![Duration::from_millis(10), Duration::from_millis(10)],
            ..self.config()
        }
    }
}

/// A well-behaved monitor: luminance 43, max 100, logs every call.
fn healthy_fake() -> Fake {
    Fake::new(
        r#"echo "$@" >> "$LOG"
case "$1 $2" in
  "get luminance") echo 43 ;;
  "max luminance") echo 100 ;;
  "set luminance") ;;
  *) echo "unknown command" ; exit 1 ;;
esac"#,
    )
}

fn request(method: Method, uri: &str, body: Option<serde_json::Value>) -> Request<Body> {
    let mut builder = Request::builder()
        .method(method)
        .uri(uri)
        .header(GUARD_HEADER, "1");
    let body = match body {
        Some(json) => {
            builder = builder.header(header::CONTENT_TYPE, "application/json");
            Body::from(json.to_string())
        }
        None => Body::empty(),
    };
    builder.body(body).unwrap()
}

async fn json_body(response: axum::response::Response) -> serde_json::Value {
    let bytes = response.into_body().collect().await.unwrap().to_bytes();
    serde_json::from_slice(&bytes).unwrap()
}

#[tokio::test]
async fn requests_without_guard_header_are_refused() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(Request::get("/health").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn cors_preflight_is_refused() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(
            Request::builder()
                .method(Method::OPTIONS)
                .uri("/brightness")
                .header(header::ORIGIN, "https://evil.example")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
}

#[tokio::test]
async fn browser_originating_requests_are_refused_even_with_header() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(
            Request::get("/health")
                .header(GUARD_HEADER, "1")
                .header(header::ORIGIN, "http://localhost:8377")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn health_reports_binary_presence() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(request(Method::GET, "/health", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let body = json_body(response).await;
    assert_eq!(body["status"], "ok");
    assert_eq!(body["m1ddc_present"], true);
}

#[tokio::test]
async fn get_brightness_reads_and_scales() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let body = json_body(response).await;
    assert_eq!(body["raw"], 43);
    assert_eq!(body["max"], 100);
    assert!((body["brightness"].as_f64().unwrap() - 0.43).abs() < 1e-9);
}

#[tokio::test]
async fn put_brightness_scales_against_max() {
    let fake = healthy_fake();
    let response = app(fake.config())
        .oneshot(request(
            Method::PUT,
            "/brightness",
            Some(serde_json::json!({ "brightness": 0.5 })),
        ))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::NO_CONTENT);
    // Pure write: the panel is never asked for max.
    assert_eq!(fake.calls(), vec!["set luminance 50"]);
}

#[tokio::test]
async fn put_brightness_rejects_out_of_range() {
    let fake = healthy_fake();
    for bad in [-0.1, 1.1, f64::NAN] {
        let response = app(fake.config())
            .oneshot(request(
                Method::PUT,
                "/brightness",
                Some(serde_json::json!({ "brightness": bad })),
            ))
            .await
            .unwrap();
        // NaN fails serde deserialization (400); the rest hit our range check (422).
        assert!(
            response.status() == StatusCode::UNPROCESSABLE_ENTITY
                || response.status() == StatusCode::BAD_REQUEST,
            "brightness {bad} accepted"
        );
        assert!(fake.calls().is_empty(), "bus touched for invalid input {bad}");
    }
}


#[tokio::test]
async fn wedged_m1ddc_times_out_instead_of_hanging() {
    let fake = Fake::new(r#"sleep 30"#);
    let config = Config {
        timeout: Duration::from_millis(500),
        ..fake.config()
    };
    let started = std::time::Instant::now();
    let response = app(config)
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
    assert!(
        started.elapsed() < Duration::from_secs(5),
        "timeout did not bound the call"
    );
}

#[tokio::test]
async fn m1ddc_failure_surfaces_as_bad_gateway() {
    let fake = Fake::new(r#"echo "DDC communication failure"; exit 1"#);
    let response = app(fake.config())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
    let body = json_body(response).await;
    assert!(
        body["error"].as_str().unwrap().contains("DDC communication failure"),
        "m1ddc's stdout error text should surface: {body}"
    );
}

#[tokio::test]
async fn display_off_calls_pmset_displaysleepnow() {
    let fake = healthy_fake();
    fake.write_tool("pmset", r#"echo "$TOOL $@" >> "$LOG""#);
    let response = app(fake.config())
        .oneshot(request(
            Method::PUT,
            "/display",
            Some(serde_json::json!({ "on": false })),
        ))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::NO_CONTENT);
    assert_eq!(fake.calls(), vec!["pmset displaysleepnow"]);
}

#[tokio::test]
async fn display_on_wakes_via_caffeinate_user_activity() {
    let fake = healthy_fake();
    fake.write_tool("caffeinate", r#"echo "$TOOL $@" >> "$LOG""#);
    let response = app(fake.config())
        .oneshot(request(
            Method::PUT,
            "/display",
            Some(serde_json::json!({ "on": true })),
        ))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::NO_CONTENT);
    assert_eq!(fake.calls(), vec!["caffeinate -u -t 2"]);
}

#[tokio::test]
async fn display_power_failure_surfaces() {
    let fake = healthy_fake();
    fake.write_tool("pmset", r#"echo "not permitted"; exit 1"#);
    let response = app(fake.config())
        .oneshot(request(
            Method::PUT,
            "/display",
            Some(serde_json::json!({ "on": false })),
        ))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}

#[tokio::test]
async fn corrupted_negative_read_is_rejected_not_served() {
    // Field data from the kiosk's LG: "luminance: -51 / max 62". A negative
    // luminance is impossible - serving it would clamp to brightness 0 and
    // poison the app's restore level.
    let fake = Fake::new(
        r#"echo "$@" >> "$LOG"
case "$1 $2" in
  "max luminance") echo 100 ;;
  "get luminance") echo -51 ;;
esac"#,
    );
    let response = app(fake.config())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
    let body = json_body(response).await;
    assert!(body["error"].as_str().unwrap().contains("implausible"));
}

#[tokio::test]
async fn corrupted_read_recovers_via_retry() {
    let fake = Fake::new(
        r#"echo "$@" >> "$LOG"
case "$1 $2" in
  "max luminance") echo 100 ;;
  "get luminance")
    if [ ! -f "$LOG.once" ]; then touch "$LOG.once"; echo -51; else echo 43; fi ;;
esac"#,
    );
    let response = app(fake.config_with_retries())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let body = json_body(response).await;
    assert_eq!(body["raw"], 43);
}

#[tokio::test]
async fn reading_above_max_is_rejected_as_corruption() {
    let fake = Fake::new(
        r#"echo "$@" >> "$LOG"
case "$1 $2" in
  "max luminance") echo 100 ;;
  "get luminance") echo 3200 ;;
esac"#,
    );
    let response = app(fake.config())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}


#[tokio::test]
async fn transient_failure_is_retried_and_succeeds() {
    // Fails the first "get luminance" (the AV service just came back from
    // panel sleep), succeeds on retry.
    let fake = Fake::new(
        r#"echo "$@" >> "$LOG"
case "$1 $2" in
  "max luminance") echo 100 ;;
  "get luminance")
    if [ ! -f "$LOG.once" ]; then touch "$LOG.once"; echo "DDC no reply"; exit 1; fi
    echo 43 ;;
esac"#,
    );
    let response = app(fake.config_with_retries())
        .oneshot(request(Method::GET, "/brightness", None))
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let calls = fake.calls();
    assert_eq!(
        calls.iter().filter(|l| *l == "get luminance").count(),
        2,
        "expected one retry: {calls:?}"
    );
}


#[tokio::test]
async fn concurrent_requests_never_interleave_on_the_bus() {
    // DDC/I2C is single-master: the mutex must serialize transactions. The
    // fake records begin/end markers; interleaving shows as nested begins.
    let fake = Fake::new(
        r#"echo "begin" >> "$LOG"
sleep 0.1
echo "end" >> "$LOG"
case "$1 $2" in
  "get luminance") echo 43 ;;
  "max luminance") echo 100 ;;
esac"#,
    );
    let shared_app = app(fake.config());
    let (r1, r2) = tokio::join!(
        shared_app.clone().oneshot(request(Method::GET, "/brightness", None)),
        shared_app.clone().oneshot(request(Method::GET, "/brightness", None)),
    );
    assert_eq!(r1.unwrap().status(), StatusCode::OK);
    assert_eq!(r2.unwrap().status(), StatusCode::OK);

    let calls = fake.calls();
    // One bus transaction per GET now - max is configuration, never read.
    let begins = calls.iter().filter(|l| *l == "begin").count();
    assert_eq!(begins, 2, "{calls:?}");
    let mut depth = 0i32;
    for line in &calls {
        match line.as_str() {
            "begin" => {
                depth += 1;
                assert_eq!(depth, 1, "interleaved DDC transactions: {calls:?}");
            }
            "end" => depth -= 1,
            _ => {}
        }
    }
}