backstage-client 0.1.4

A Rust client library for interacting with the Backstage Catalog API. Provides type-safe access to Backstage entities with async support, filtering, and comprehensive error handling.
Documentation
use backstage_client::{entities::Entity, BackstageClient, ClientError};
use std::collections::HashMap;

#[tokio::test]
async fn test_client_creation_with_valid_url() {
    let client = BackstageClient::new("https://demo.backstage.io", "test-token");
    assert!(client.is_ok());
}

#[tokio::test]
async fn test_client_creation_with_invalid_url() {
    let client = BackstageClient::new("not-a-valid-url", "test-token");
    assert!(client.is_err());
    if let Err(ClientError::InvalidUrl(_)) = client {
        // Test passed
    } else {
        panic!("Expected InvalidUrl error");
    }
}

#[tokio::test]
async fn test_client_creation_with_empty_token() {
    let client = BackstageClient::new("https://demo.backstage.io", "");
    assert!(client.is_err());
    if let Err(ClientError::Authentication(_)) = client {
        // Test passed
    } else {
        panic!("Expected Authentication error");
    }
}

#[tokio::test]
async fn test_client_creation_with_whitespace_token() {
    let client = BackstageClient::new("https://demo.backstage.io", "   ");
    assert!(client.is_err());
    if let Err(ClientError::Authentication(_)) = client {
        // Test passed
    } else {
        panic!("Expected Authentication error");
    }
}

#[tokio::test]
async fn test_filters_validation() {
    let client = BackstageClient::new("https://demo.backstage.io", "test-token").unwrap();

    // Test with invalid filters (empty key)
    let mut invalid_filters = HashMap::new();
    invalid_filters.insert("".to_string(), "Component".to_string());

    let result = client.fetch_entities::<Entity>(Some(invalid_filters)).await;
    assert!(result.is_err());
    if let Err(ClientError::InvalidFilter(_)) = result {
        // Test passed
    } else {
        panic!("Expected InvalidFilter error");
    }
}

#[tokio::test]
async fn test_filters_validation_empty_value() {
    let client = BackstageClient::new("https://demo.backstage.io", "test-token").unwrap();

    // Test with invalid filters (empty value)
    let mut invalid_filters = HashMap::new();
    invalid_filters.insert("kind".to_string(), "".to_string());

    let result = client.fetch_entities::<Entity>(Some(invalid_filters)).await;
    assert!(result.is_err());
    if let Err(ClientError::InvalidFilter(_)) = result {
        // Test passed
    } else {
        panic!("Expected InvalidFilter error");
    }
}

// Mock server tests would go here if we had a running Backstage instance
// For now, we'll focus on unit tests and validation tests

#[test]
fn test_entity_methods() {
    // This would require creating mock entity data
    // For now, we'll test that the methods exist and can be called
    // In a real scenario, you'd create sample Entity instances and test them
}

#[test]
fn test_url_normalization() {
    // Test that URLs are properly normalized (trailing slashes removed, etc.)
    let _client1 = BackstageClient::new("https://demo.backstage.io/", "test-token").unwrap();
    let _client2 = BackstageClient::new("https://demo.backstage.io", "test-token").unwrap();

    // Both should work and be equivalent
    // This is more of a regression test to ensure URL handling is consistent
}

#[cfg(feature = "integration-tests")]
mod mock_tests {
    use super::*;
    use mockito::Matcher;

    #[tokio::test]
    async fn test_fetch_entities_success() {
        let mut server = mockito::Server::new_async().await;
        let mock_response = r#"[
            {
                "apiVersion": "backstage.io/v1alpha1",
                "kind": "Component",
                "metadata": {
                    "name": "test-component",
                    "namespace": "default"
                },
                "spec": {
                    "type": "service",
                    "owner": "team-a",
                    "lifecycle": "production"
                }
            }
        ]"#;

        let _m = server
            .mock("GET", "/api/catalog/entities")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(mock_response)
            .create_async()
            .await;

        let client = BackstageClient::new(&server.url(), "test-token").unwrap();
        let result = client.fetch_entities::<Entity>(None).await;

        assert!(result.is_ok());
        let entities = result.unwrap();
        assert_eq!(entities.len(), 1);
    }

    #[tokio::test]
    async fn test_fetch_entities_unauthorized() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", "/api/catalog/entities")
            .with_status(401)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error": "Unauthorized"}"#)
            .create_async()
            .await;

        let client = BackstageClient::new(&server.url(), "invalid-token").unwrap();
        let result = client.fetch_entities::<Entity>(None).await;

        assert!(result.is_err());
        if let Err(ClientError::Authentication(_)) = result {
            // Test passed
        } else {
            panic!("Expected Authentication error");
        }
    }

    #[tokio::test]
    async fn test_fetch_entities_with_filters() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock(
                "GET",
                Matcher::Regex(r"^/api/catalog/entities\?filter=.*".to_string()),
            )
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body("[]")
            .create_async()
            .await;

        let client = BackstageClient::new(&server.url(), "test-token").unwrap();

        let mut filters = HashMap::new();
        filters.insert("kind".to_string(), "Component".to_string());

        let result = client.fetch_entities::<Entity>(Some(filters)).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_get_entity_success() {
        let mut server = mockito::Server::new_async().await;
        let mock_response = r#"{
            "apiVersion": "backstage.io/v1alpha1",
            "kind": "Component",
            "metadata": {
                "name": "test-component",
                "namespace": "default"
            },
            "spec": {
                "type": "service",
                "owner": "team-a",
                "lifecycle": "production"
            }
        }"#;

        let _m = server
            .mock(
                "GET",
                "/api/catalog/entities/by-name/component%3Adefault%2Ftest-component",
            )
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(mock_response)
            .create_async()
            .await;

        let client = BackstageClient::new(&server.url(), "test-token").unwrap();
        let result = client
            .get_entity::<Entity>("Component", None, "test-component")
            .await;

        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn test_get_entity_not_found() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock(
                "GET",
                "/api/catalog/entities/by-name/component%3Adefault%2Fnonexistent",
            )
            .with_status(404)
            .with_header("content-type", "application/json")
            .with_body(r#"{"error": "Entity not found"}"#)
            .create_async()
            .await;

        let client = BackstageClient::new(&server.url(), "test-token").unwrap();
        let result = client
            .get_entity::<Entity>("Component", None, "nonexistent")
            .await;

        assert!(result.is_err());
        if let Err(ClientError::ApiError { status: 404, .. }) = result {
            // Test passed
        } else {
            panic!("Expected 404 API error");
        }
    }
}