httpgenerator 1.1.0

Generate .http files from OpenAPI specifications
Documentation
use std::{
    fs,
    path::PathBuf,
    time::{SystemTime, UNIX_EPOCH},
};

use crate::{
    AzureAuthStatus, CliError, ExecutionObserver, ExecutionSummary,
    args::{CliArgs, OutputTypeArg},
    observer::NoopExecutionObserver,
};

use super::orchestrator::execute_with;
use super::{execute, should_attempt_azure_auth};

fn temp_output_dir(name: &str) -> PathBuf {
    std::env::temp_dir().join(format!(
        "httpgenerator-rust-cli-tests-{name}-{}",
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ))
}

fn petstore_args(output_folder: PathBuf) -> CliArgs {
    CliArgs {
        open_api_path: Some(test_fixture_path("v3.0", "petstore.json")),
        output_folder: output_folder.to_string_lossy().into_owned(),
        ..CliArgs::default()
    }
}

fn webhook31_args(output_folder: PathBuf) -> CliArgs {
    CliArgs {
        open_api_path: Some(test_fixture_path("v3.1", "webhook-example.json")),
        output_folder: output_folder.to_string_lossy().into_owned(),
        ..CliArgs::default()
    }
}

fn non_oauth31_args(output_folder: PathBuf) -> CliArgs {
    CliArgs {
        open_api_path: Some(test_fixture_path("v3.1", "non-oauth-scopes.json")),
        output_folder: output_folder.to_string_lossy().into_owned(),
        ..CliArgs::default()
    }
}

fn test_fixture_path(version: &str, file_name: &str) -> String {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
        .join("..")
        .join("test")
        .join("OpenAPI")
        .join(version)
        .join(file_name)
        .to_string_lossy()
        .into_owned()
}

fn cleanup(summary: &ExecutionSummary) {
    let _ = fs::remove_dir_all(&summary.output_folder);
}

#[test]
fn execute_writes_petstore_files() {
    let output_folder = temp_output_dir("petstore");
    let summary = execute(petstore_args(output_folder)).unwrap();

    assert_eq!(summary.files.len(), 19);
    assert!(
        summary
            .validation
            .as_ref()
            .is_some_and(|inspection| inspection.stats.path_item_count > 0)
    );
    assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
    assert!(
        summary
            .files
            .iter()
            .any(|path| path.ends_with("PutUpdatePet.http"))
    );
    assert!(
        summary
            .files
            .iter()
            .any(|path| path.ends_with("GetLoginUser.http"))
    );

    cleanup(&summary);
}

#[derive(Default)]
struct RecordingObserver {
    events: Vec<String>,
}

impl ExecutionObserver for RecordingObserver {
    fn validation_started(&mut self) {
        self.events.push("validation_started".to_string());
    }

    fn validation_succeeded(
        &mut self,
        inspection: &httpgenerator_core::openapi::OpenApiInspection,
    ) {
        self.events.push(format!(
            "validation_succeeded:{}",
            inspection.specification_version
        ));
    }

    fn file_writing_started(&mut self, file_count: usize) {
        self.events
            .push(format!("file_writing_started:{file_count}"));
    }

    fn files_written(&mut self, paths: &[PathBuf]) {
        self.events.push(format!("files_written:{}", paths.len()));
    }
}

#[test]
fn execute_notifies_observer_in_cli_lifecycle_order() {
    let output_folder = temp_output_dir("observer-order");
    let mut observer = RecordingObserver::default();
    let summary = execute_with(
        petstore_args(output_folder),
        &mut observer,
        |_tenant_id, _scope| Ok(None),
    )
    .unwrap();

    assert_eq!(
        observer.events,
        vec![
            "validation_started".to_string(),
            "validation_succeeded:OpenAPI 3.0.x".to_string(),
            "file_writing_started:19".to_string(),
            "files_written:19".to_string(),
        ]
    );

    cleanup(&summary);
}

#[test]
fn execute_respects_one_file_mode_and_custom_headers() {
    let output_folder = temp_output_dir("onefile");
    let summary = execute(CliArgs {
        output_type: OutputTypeArg::OneFile,
        generate_intellij_tests: true,
        custom_headers: vec!["X-API-Key: test123".to_string()],
        ..petstore_args(output_folder)
    })
    .unwrap();

    assert_eq!(summary.files.len(), 1);
    assert!(summary.validation.is_some());
    assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
    let content = fs::read_to_string(&summary.files[0]).unwrap();
    assert!(content.contains("X-API-Key: test123"));
    assert!(content.contains("> {%"));
    assert!(content.contains("### Request: PUT /pet"));

    cleanup(&summary);
}

#[test]
fn execute_rejects_openapi31_without_skip_validation() {
    let output_folder = temp_output_dir("openapi31-validation");
    let error = execute(webhook31_args(output_folder)).unwrap_err();

    assert_eq!(
        error,
        CliError::UnsupportedValidationVersion {
            version: httpgenerator_core::openapi::OpenApiSpecificationVersion::OpenApi31,
        }
    );
}

#[test]
fn execute_allows_openapi31_with_skip_validation() {
    let output_folder = temp_output_dir("openapi31-skip");
    let summary = execute(CliArgs {
        skip_validation: true,
        ..webhook31_args(output_folder)
    })
    .unwrap();

    assert!(summary.validation.is_none());
    assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
    assert!(summary.files.is_empty());

    cleanup(&summary);
}

#[test]
fn execute_allows_invalid_openapi31_with_skip_validation() {
    let output_folder = temp_output_dir("openapi31-invalid-skip");
    let summary = execute(CliArgs {
        skip_validation: true,
        ..non_oauth31_args(output_folder)
    })
    .unwrap();

    assert!(summary.validation.is_none());
    assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
    assert_eq!(summary.files.len(), 1);
    let content = fs::read_to_string(&summary.files[0]).unwrap();
    assert!(content.contains("### Request: GET /users"));

    cleanup(&summary);
}

#[test]
fn execute_uses_acquired_azure_token_as_authorization_header() {
    let output_folder = temp_output_dir("azure-auth");
    let mut observer = NoopExecutionObserver;
    let summary = execute_with(
        CliArgs {
            azure_scope: Some("api://example/.default".to_string()),
            azure_tenant_id: Some("tenant-id".to_string()),
            ..petstore_args(output_folder)
        },
        &mut observer,
        |tenant_id, scope| {
            assert_eq!(tenant_id, Some("tenant-id"));
            assert_eq!(scope, "api://example/.default");
            Ok(Some("test-token".to_string()))
        },
    )
    .unwrap();

    assert_eq!(summary.azure_auth, AzureAuthStatus::Acquired);
    let content = fs::read_to_string(&summary.files[0]).unwrap();
    assert!(content.contains("@authorization = Bearer test-token"));
    assert!(content.contains("Authorization: {{authorization}}"));

    cleanup(&summary);
}

#[test]
fn execute_continues_when_azure_token_lookup_fails() {
    let output_folder = temp_output_dir("azure-auth-failure");
    let mut observer = NoopExecutionObserver;
    let summary = execute_with(
        CliArgs {
            azure_scope: Some("api://example/.default".to_string()),
            azure_tenant_id: Some("tenant-id".to_string()),
            ..petstore_args(output_folder)
        },
        &mut observer,
        |tenant_id, scope| {
            assert_eq!(tenant_id, Some("tenant-id"));
            assert_eq!(scope, "api://example/.default");
            Err("Azure CLI credential failed: not logged in".to_string())
        },
    )
    .unwrap();

    assert_eq!(
        summary.azure_auth,
        AzureAuthStatus::Failed {
            reason: "Azure CLI credential failed: not logged in".to_string(),
        }
    );
    let content = fs::read_to_string(&summary.files[0]).unwrap();
    assert!(!content.contains("@authorization ="));
    assert!(!content.contains("Authorization: {{authorization}}"));

    cleanup(&summary);
}

#[test]
fn execute_continues_when_azure_scope_is_missing() {
    let output_folder = temp_output_dir("azure-auth-missing-scope");
    let mut observer = NoopExecutionObserver;
    let summary = execute_with(
        CliArgs {
            azure_tenant_id: Some("tenant-id".to_string()),
            ..petstore_args(output_folder)
        },
        &mut observer,
        |_tenant_id, _scope| panic!("token provider should not run without a scope"),
    )
    .unwrap();

    assert_eq!(
        summary.azure_auth,
        AzureAuthStatus::Failed {
            reason: "Azure Entra ID scope is required to acquire an authorization header."
                .to_string(),
        }
    );

    cleanup(&summary);
}

#[test]
fn should_attempt_azure_auth_only_when_scope_or_tenant_is_present_without_header() {
    let mut args = CliArgs {
        open_api_path: None,
        output_folder: "./".to_string(),
        no_logging: false,
        skip_validation: false,
        authorization_header: None,
        authorization_header_from_environment_variable: false,
        authorization_header_variable_name: "authorization".to_string(),
        content_type: "application/json".to_string(),
        base_url: None,
        output_type: OutputTypeArg::OneRequestPerFile,
        azure_scope: None,
        azure_tenant_id: None,
        timeout: 120,
        generate_intellij_tests: false,
        custom_headers: Vec::new(),
        skip_headers: false,
    };

    assert!(!should_attempt_azure_auth(&args));

    args.azure_scope = Some("api://example/.default".to_string());
    assert!(should_attempt_azure_auth(&args));

    args.authorization_header = Some("Bearer token".to_string());
    assert!(!should_attempt_azure_auth(&args));
}