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;
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())
}
#[test]
fn client_constructs_with_new() {
let client = ScreenshotFreeAPIClient::new("sfa_test_key");
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();
}
#[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);
}
#[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"));
}
#[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());
}
#[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");
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() {
let body = b"test";
let sig_lower = make_hmac(body, "secret");
let sig_upper = sig_lower.to_uppercase();
if sig_lower != sig_upper {
assert!(verify_webhook_signature(body, &sig_upper, "secret").is_err());
}
}
#[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();
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();
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();
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}");
}