screenshotfreeapi 1.0.0

Official Rust client for ScreenshotFreeAPI — Screenshot-as-a-Service
Documentation
/// Unit tests for the ScreenshotFreeAPI Rust SDK.
///
/// These tests do **not** require a running server.  They exercise:
/// - Client construction and cloning
/// - `WaitOptions` defaults
/// - Error type creation and display
/// - Webhook signature verification (all code paths)
/// - Serde round-trips for key request/response types
use screenshotfreeapi::{
    verify_webhook_signature, CreateMonitorRequest, CreateWorkspaceRequest, Dimensions,
    EnqueueResponse, HealthResponse, HtmlScreenshotOptions, InviteRequest, JobResult,
    JobStatusResponse, MobileScreenshotOptions, RegisterRequest, ScreenshotFreeAPIClient, ScreenshotFreeAPIError,
    TokenResponse, UpdateRoleRequest, UpgradeRequest, WaitOptions, WebScreenshotOptions,
    ZapierSubscribeRequest,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn make_hmac(body: &[u8], secret: &str) -> String {
    type HmacSha256 = Hmac<Sha256>;
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
    mac.update(body);
    hex::encode(mac.finalize().into_bytes())
}

// ---------------------------------------------------------------------------
// 1. Client construction
// ---------------------------------------------------------------------------

#[test]
fn client_constructs_with_new() {
    let client = ScreenshotFreeAPIClient::new("sfa_test_key");
    // Clone must be cheap (Arc-backed).
    let _cloned = client.clone();
}

#[test]
fn client_constructs_with_base_url() {
    let client = ScreenshotFreeAPIClient::with_base_url("sfa_test_key", "http://localhost:3000");
    let _cloned = client.clone();
}

// ---------------------------------------------------------------------------
// 2. WaitOptions defaults
// ---------------------------------------------------------------------------

#[test]
fn wait_options_defaults() {
    let opts = WaitOptions::default();
    assert_eq!(opts.interval_ms, 2_000, "default poll interval should be 2 000 ms");
    assert_eq!(opts.timeout_ms, 120_000, "default timeout should be 120 000 ms");
    assert!(opts.on_progress.is_none(), "progress callback should be None by default");
}

#[test]
fn wait_options_custom() {
    let opts = WaitOptions {
        interval_ms: 500,
        timeout_ms: 30_000,
        on_progress: None,
    };
    assert_eq!(opts.interval_ms, 500);
    assert_eq!(opts.timeout_ms, 30_000);
}

// ---------------------------------------------------------------------------
// 3. Error variants — creation, display, and Debug
// ---------------------------------------------------------------------------

#[test]
fn error_authentication() {
    let e = ScreenshotFreeAPIError::Authentication { message: "bad key".into() };
    assert!(e.to_string().contains("bad key"));
}

#[test]
fn error_forbidden() {
    let e = ScreenshotFreeAPIError::Forbidden { message: "suspended".into() };
    assert!(e.to_string().contains("suspended"));
}

#[test]
fn error_not_found() {
    let e = ScreenshotFreeAPIError::NotFound { message: "job 123 not found".into() };
    assert!(e.to_string().contains("123"));
}

#[test]
fn error_validation() {
    let e = ScreenshotFreeAPIError::Validation { message: "url is required".into() };
    assert!(e.to_string().contains("url is required"));
}

#[test]
fn error_rate_limit() {
    let e = ScreenshotFreeAPIError::RateLimit { retry_after_seconds: 30 };
    let msg = e.to_string();
    assert!(msg.contains("30"), "should include retry-after seconds");
}

#[test]
fn error_quota_exceeded() {
    let e = ScreenshotFreeAPIError::QuotaExceeded;
    assert!(e.to_string().contains("Quota exceeded") || e.to_string().contains("quota"));
}

#[test]
fn error_payment_required() {
    let e = ScreenshotFreeAPIError::PaymentRequired { message: "subscription cancelled".into() };
    assert!(e.to_string().contains("subscription cancelled"));
}

#[test]
fn error_job_failed() {
    let e = ScreenshotFreeAPIError::JobFailed { job_id: "abc123".into(), reason: "navigation timeout".into() };
    let msg = e.to_string();
    assert!(msg.contains("navigation timeout"));
}

#[test]
fn error_job_timeout() {
    let e = ScreenshotFreeAPIError::JobTimeout { timeout_ms: 60_000 };
    let msg = e.to_string();
    assert!(msg.contains("60000"));
}

#[test]
fn error_invalid_signature() {
    let e = ScreenshotFreeAPIError::InvalidSignature;
    assert!(e.to_string().contains("Invalid webhook signature"));
}

#[test]
fn error_unexpected() {
    let e = ScreenshotFreeAPIError::Unexpected { status: 503, message: "service unavailable".into() };
    let msg = e.to_string();
    assert!(msg.contains("503"));
    assert!(msg.contains("service unavailable"));
}

// ---------------------------------------------------------------------------
// 4. Webhook signature — valid path
// ---------------------------------------------------------------------------

#[test]
fn webhook_valid_signature() {
    let body = b"{\"jobId\":\"clxyz\",\"status\":\"completed\"}";
    let secret = "whsec_my_very_secret_key";
    let sig = make_hmac(body, secret);
    assert!(verify_webhook_signature(body, &sig, secret).is_ok());
}

// ---------------------------------------------------------------------------
// 5. Webhook signature — invalid paths
// ---------------------------------------------------------------------------

#[test]
fn webhook_wrong_secret_rejected() {
    let body = b"{\"jobId\":\"abc\"}";
    let sig = make_hmac(body, "correct_secret");
    let result = verify_webhook_signature(body, &sig, "wrong_secret");
    assert!(result.is_err());
    assert!(matches!(result.unwrap_err(), ScreenshotFreeAPIError::InvalidSignature));
}

#[test]
fn webhook_tampered_body_rejected() {
    let original = b"{\"jobId\":\"abc\"}";
    let tampered = b"{\"jobId\":\"evil\"}";
    let sig = make_hmac(original, "secret");
    assert!(verify_webhook_signature(tampered, &sig, "secret").is_err());
}

#[test]
fn webhook_truncated_signature_rejected() {
    let body = b"hello world";
    let full_sig = make_hmac(body, "secret");
    // Truncate to half length — length mismatch → rejected
    let short_sig = &full_sig[..full_sig.len() / 2];
    assert!(verify_webhook_signature(body, short_sig, "secret").is_err());
}

#[test]
fn webhook_empty_body_valid() {
    let body = b"";
    let sig = make_hmac(body, "my_secret");
    assert!(verify_webhook_signature(body, &sig, "my_secret").is_ok());
}

#[test]
fn webhook_hex_case_sensitivity() {
    // The API returns lowercase hex; uppercase should be rejected since
    // constant_time_eq is byte-exact.
    let body = b"test";
    let sig_lower = make_hmac(body, "secret");
    let sig_upper = sig_lower.to_uppercase();
    // If they differ in case, the upper variant must fail.
    if sig_lower != sig_upper {
        assert!(verify_webhook_signature(body, &sig_upper, "secret").is_err());
    }
}

// ---------------------------------------------------------------------------
// 6. Serde round-trips for key types
// ---------------------------------------------------------------------------

#[test]
fn serialize_web_screenshot_options() {
    let opts = WebScreenshotOptions {
        url: "https://example.com".into(),
        description: Some("hero section".into()),
        full_page: Some(true),
        format: Some("png".into()),
        ..Default::default()
    };
    let json = serde_json::to_string(&opts).unwrap();
    // camelCase serialization
    assert!(json.contains("\"url\""));
    assert!(json.contains("\"description\""));
    assert!(json.contains("\"fullPage\""), "fullPage should be camelCase, got: {json}");
}

#[test]
fn skip_none_fields_in_web_options() {
    let opts = WebScreenshotOptions {
        url: "https://example.com".into(),
        ..Default::default()
    };
    let json = serde_json::to_string(&opts).unwrap();
    // None fields should be omitted
    assert!(!json.contains("description"), "None fields must be skipped");
    assert!(!json.contains("element"), "None fields must be skipped");
}

#[test]
fn deserialize_job_status_response() {
    let json = r#"{
        "jobId": "clxyz123",
        "status": "processing",
        "progress": 42,
        "error": null
    }"#;
    let resp: JobStatusResponse = serde_json::from_str(json).unwrap();
    assert_eq!(resp.job_id, "clxyz123");
    assert_eq!(resp.status, "processing");
    assert_eq!(resp.progress, Some(42));
    assert!(resp.error.is_none());
}

#[test]
fn deserialize_enqueue_response() {
    let json = r#"{
        "jobId": "abc",
        "status": "queued",
        "statusUrl": "/jobs/abc/status",
        "estimatedSeconds": 8
    }"#;
    let resp: EnqueueResponse = serde_json::from_str(json).unwrap();
    assert_eq!(resp.job_id, "abc");
    assert_eq!(resp.status, "queued");
    assert_eq!(resp.estimated_seconds, Some(8));
}

#[test]
fn deserialize_job_result() {
    let json = r#"{
        "jobId": "j1",
        "jobType": "web",
        "screenshots": [{
            "url": "https://s3.example.com/img.png",
            "format": "png",
            "width": 1280,
            "height": 720,
            "selector": null,
            "capturedAt": "2026-06-01T12:00:00Z"
        }],
        "metadata": {
            "pageTitle": "Home",
            "aiSelectorUsed": true,
            "rawAiSelector": ".hero",
            "aiConfidence": 0.92,
            "aiModel": "anthropic/claude-opus-4-5",
            "aiSelectorFailed": false,
            "aiFallbackUsed": false,
            "processingMs": 3800,
            "fromCache": false
        },
        "completedAt": "2026-06-01T12:00:05Z"
    }"#;
    let result: JobResult = serde_json::from_str(json).unwrap();
    assert_eq!(result.job_id, "j1");
    assert_eq!(result.screenshots.len(), 1);
    assert_eq!(result.screenshots[0].width, 1280);
    assert!(result.metadata.ai_selector_used);
    assert_eq!(result.metadata.ai_confidence, Some(0.92));
}

#[test]
fn deserialize_health_response() {
    let json = r#"{"status":"ok","version":"1.0.0","uptime":3600.5}"#;
    let h: HealthResponse = serde_json::from_str(json).unwrap();
    assert_eq!(h.status, "ok");
}

#[test]
fn register_request_serializes() {
    let req = RegisterRequest {
        email: "dev@example.com".into(),
        password: "secret123!".into(),
        name: "Dev User".into(),
    };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("dev@example.com"));
}

#[test]
fn upgrade_request_serializes() {
    let req = UpgradeRequest { plan_id: "growth".into() };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("planId"), "should be camelCase planId, got: {json}");
}

#[test]
fn create_workspace_request_serializes() {
    let req = CreateWorkspaceRequest { name: "Acme Corp".into() };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("Acme Corp"));
}

#[test]
fn invite_request_serializes() {
    let req = InviteRequest { email: "alice@example.com".into(), role: "member".into() };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("member"));
}

#[test]
fn mobile_options_defaults_empty() {
    let opts = MobileScreenshotOptions::default();
    let json = serde_json::to_string(&opts).unwrap();
    // All fields are None, should produce minimal JSON
    assert!(!json.contains("appName"), "None fields must be skipped: {json}");
}

#[test]
fn dimensions_serializes_correctly() {
    let d = Dimensions { width: 1440, height: 900 };
    let json = serde_json::to_string(&d).unwrap();
    assert!(json.contains("1440"));
    assert!(json.contains("900"));
}

#[test]
fn zapier_subscribe_request_serializes() {
    let req = ZapierSubscribeRequest {
        trigger_event: "job.completed".into(),
        target_url: "https://hooks.zapier.com/abc".into(),
    };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("targetUrl"), "should be camelCase: {json}");
    assert!(json.contains("triggerEvent"), "should be camelCase: {json}");
    assert!(json.contains("job.completed"));
}

#[test]
fn token_response_deserializes() {
    let json = r#"{
        "accessToken": "eyJhbGciOiJIUzI1NiJ9.test",
        "refreshToken": "ref_abc123",
        "expiresAt": "2026-06-21T00:00:00.000Z",
        "refreshExpiresAt": "2026-07-20T00:00:00.000Z"
    }"#;
    let resp: TokenResponse = serde_json::from_str(json).unwrap();
    assert_eq!(resp.expires_at, "2026-06-21T00:00:00.000Z");
    assert!(!resp.access_token.is_empty());
}

#[test]
fn create_monitor_request_serializes() {
    let req = CreateMonitorRequest {
        app_id: "com.instagram.android".into(),
        platform: "android".into(),
        schedule: "0 9 * * *".into(),
        webhook_url: Some("https://example.com/hook".into()),
        diff_threshold: None,
        label: Some("Instagram".into()),
    };
    let json = serde_json::to_string(&req).unwrap();
    assert!(json.contains("appId"), "should be camelCase: {json}");
    assert!(json.contains("webhookUrl"), "should be camelCase: {json}");
}