use docker_compose_updater::compose::updater::ComposeUpdater; use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy}; use docker_compose_updater::registry::ImageRef; use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; use tempfile::NamedTempFile; #[test] fn test_image_ref_comprehensive_parsing() { // Test comprehensive image reference parsing scenarios let test_cases = vec![ // (input, expected_registry, expected_namespace, expected_name, expected_tag) // Standard Docker Hub images ("nginx", "docker.io", None, "nginx", "latest"), ("nginx:1.21", "docker.io", None, "nginx", "1.21"), ("nginx:latest", "docker.io", None, "nginx", "latest"), // Docker Hub with namespace ( "library/nginx:1.21", "docker.io", Some("library"), "nginx", "1.21", ), ( "bitnami/nginx:1.21", "docker.io", Some("bitnami"), "nginx", "1.21", ), // Custom registries ("quay.io/nginx:1.21", "quay.io", None, "nginx", "1.21"), ( "gcr.io/project/nginx:1.21", "gcr.io", Some("project"), "nginx", "1.21", ), ( "registry.k8s.io/pause:3.9", "registry.k8s.io", None, "pause", "3.9", ), // GitHub Container Registry ( "ghcr.io/user/app:v1.0.0", "ghcr.io", Some("user"), "app", "v1.0.0", ), ( "ghcr.io/org/project:latest", "ghcr.io", Some("org"), "project", "latest", ), // Local registries with ports ( "localhost:5000/app:dev", "localhost:5000", None, "app", "dev", ), ( "127.0.0.1:8080/ns/app:test", "127.0.0.1:8080", Some("ns"), "app", "test", ), // Complex tags with versions and suffixes ( "nginx:1.21.6-alpine", "docker.io", None, "nginx", "1.21.6-alpine", ), ( "postgres:13.7-bullseye", "docker.io", None, "postgres", "13.7-bullseye", ), ( "redis:7.0.0-alpine3.16", "docker.io", None, "redis", "7.0.0-alpine3.16", ), // Enterprise/custom registries ( "my-registry.company.com/team/app:v2.1.3", "my-registry.company.com", Some("team"), "app", "v2.1.3", ), ( "harbor.example.org/project/nginx:stable", "harbor.example.org", Some("project"), "nginx", "stable", ), ]; for (input, expected_registry, expected_namespace, expected_name, expected_tag) in test_cases { let result = ImageRef::parse(input); assert!(result.is_ok(), "Failed to parse: {input}"); let image_ref = result.unwrap(); assert_eq!( image_ref.registry, expected_registry, "Registry mismatch for: {input}" ); assert_eq!( image_ref.namespace, expected_namespace.map(String::from), "Namespace mismatch for: {input}" ); assert_eq!(image_ref.name, expected_name, "Name mismatch for: {input}"); assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for: {input}"); // Test that the image ref can be converted back to string representation let display_string = image_ref.to_string(); assert!( !display_string.is_empty(), "Display string should not be empty for: {input}" ); // For Docker Hub images, the display should omit the registry if expected_registry == "docker.io" { assert!( !display_string.starts_with("docker.io"), "Docker Hub images should not show registry in display: {input}" ); } } } #[tokio::test] async fn test_compose_file_parsing_edge_cases() { let config = create_test_config(); let updater = ComposeUpdater::new(config); // Test various compose file formats and edge cases let test_cases = vec![ // Standard compose file ( "standard_compose", r#" version: '3.8' services: web: image: nginx:1.21.0 ports: - "80:80" db: image: postgres:13.7 "#, 2, // Expected number of services ), // Compose file with comments and whitespace ( "with_comments", r#" # This is a comment version: '3.8' services: # Web service comment web: image: nginx:1.21.0 # Inline comment ports: - "80:80" # Database service db: image: postgres:13.7 # More comments environment: POSTGRES_DB: test "#, 2, ), // Compose file with complex image references ( "complex_images", r#" version: '3.8' services: frontend: image: ghcr.io/company/frontend:v2.1.0 backend: image: my-registry.com:5000/team/backend:latest cache: image: redis:7.0.0-alpine3.16 database: image: postgres:14.5-bullseye "#, 4, ), // Minimal compose file ( "minimal", r#" version: '3' services: app: image: hello-world "#, 1, ), // Compose file with extends and other features ( "with_extends", r#" version: '3.8' services: base: image: ubuntu:20.04 environment: - ENV=production app: extends: service: base image: myapp:latest ports: - "3000:3000" "#, 2, ), ]; for (test_name, compose_content, expected_services) in test_cases { let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(compose_content.as_bytes()).unwrap(); let result = updater.parse_compose_file(temp_file.path().to_str().unwrap()); assert!(result.is_ok(), "Failed to parse compose file: {test_name}"); let compose_file = result.unwrap(); assert_eq!( compose_file.services.len(), expected_services, "Service count mismatch for: {test_name}" ); // Verify that all services have valid image references for service in &compose_file.services { assert!( !service.service_name.is_empty(), "Service name should not be empty in: {test_name}" ); assert!( !service.image_ref.name.is_empty(), "Image name should not be empty in: {test_name}" ); assert!( !service.image_ref.tag.is_empty(), "Image tag should not be empty in: {test_name}" ); assert!( !service.image_ref.registry.is_empty(), "Image registry should not be empty in: {test_name}" ); } } } #[tokio::test] async fn test_compose_content_replacement() { let config = create_test_config(); let updater = ComposeUpdater::new(config); let original_content = r#" version: '3.8' services: web: image: nginx:1.20.0 # Current version ports: - "80:80" db: image: postgres:13.6 "#; let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(original_content.as_bytes()).unwrap(); let compose_file = updater .parse_compose_file(temp_file.path().to_str().unwrap()) .unwrap(); // Test replacing nginx image let nginx_service = compose_file .services .iter() .find(|s| s.service_name == "web") .unwrap(); let result = updater.replace_image_in_content(&compose_file.content, nginx_service, "nginx:1.21.0"); assert!(result.is_ok(), "Should successfully replace image"); let new_content = result.unwrap(); assert!( new_content.contains("nginx:1.21.0"), "Should contain new image version" ); assert!( !new_content.contains("nginx:1.20.0"), "Should not contain old image version" ); assert!( new_content.contains("# Current version"), "Should preserve comments" ); assert!( new_content.contains("postgres:13.6"), "Should preserve other services unchanged" ); } #[test] fn test_compose_service_image_replacement_edge_cases() { let config = create_test_config(); let updater = ComposeUpdater::new(config); // Test cases with tricky image replacement scenarios let test_cases = vec![ // Multiple occurrences of similar image names ( r#" version: '3.8' services: nginx-proxy: image: nginx:1.20.0 nginx-cache: image: nginx:1.21.0 web: image: nginx:1.20.0 "#, "nginx-proxy", "nginx:1.20.0", "nginx:1.22.0", ), // Images with similar names but different registries ( r#" version: '3.8' services: public-nginx: image: nginx:1.20.0 private-nginx: image: my-registry.com/nginx:1.20.0 "#, "public-nginx", "nginx:1.20.0", "nginx:1.21.0", ), // Complex image with registry and namespace ( r#" version: '3.8' services: app: image: ghcr.io/company/app:v1.0.0 environment: - IMAGE_VERSION=v1.0.0 "#, "app", "ghcr.io/company/app:v1.0.0", "ghcr.io/company/app:v1.1.0", ), ]; for (compose_content, service_name, old_image, new_image) in test_cases { let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(compose_content.as_bytes()).unwrap(); let compose_file = updater .parse_compose_file(temp_file.path().to_str().unwrap()) .unwrap(); let target_service = compose_file .services .iter() .find(|s| s.service_name == service_name) .unwrap(); let result = updater.replace_image_in_content(&compose_file.content, target_service, new_image); assert!( result.is_ok(), "Should replace image for service: {service_name}" ); let new_content = result.unwrap(); assert!( new_content.contains(new_image), "Should contain new image: {new_image}" ); // Count occurrences to ensure only the right image was replaced let old_count = compose_content.matches(old_image).count(); let new_old_count = new_content.matches(old_image).count(); assert_eq!( new_old_count, old_count - 1, "Should replace exactly one occurrence for: {service_name}" ); } } #[test] fn test_image_ignore_patterns_comprehensive() { // Test comprehensive ignore pattern scenarios let test_cases = vec![ // Basic pattern matching (vec!["localhost"], "localhost:5000/app:latest", true), (vec!["localhost"], "nginx:latest", false), // Multiple patterns ( vec!["localhost", "127.0.0.1"], "127.0.0.1:5000/app:latest", true, ), ( vec!["localhost", "127.0.0.1"], "192.168.1.1:5000/app:latest", false, ), // Registry-based patterns (vec!["ghcr.io"], "ghcr.io/user/app:latest", true), (vec!["ghcr.io"], "docker.io/user/app:latest", false), // Prefix patterns (vec!["test-"], "test-app:latest", true), (vec!["test-"], "app-test:latest", false), (vec!["test-"], "testing:latest", false), // Complex registry patterns ( vec!["internal.company.com"], "internal.company.com/team/app:v1.0", true, ), ( vec!["internal.company.com"], "external.company.com/team/app:v1.0", false, ), // Partial matches (vec!["redis"], "redis:latest", true), (vec!["redis"], "valkey:latest", false), (vec!["redis"], "my-redis:latest", true), // This should match as substring ]; for (ignore_patterns, image, should_be_ignored) in test_cases { let config = Config { ignore_images: ignore_patterns.iter().map(|s| s.to_string()).collect(), ..create_test_config() }; let result = config.is_image_ignored(image); assert_eq!( result, should_be_ignored, "Pattern {ignore_patterns:?} with image '{image}' should be ignored: {should_be_ignored}" ); } } fn create_test_config() -> Config { let mut registries = HashMap::new(); registries.insert( "docker.io".to_string(), RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, }, ); registries.insert( "ghcr.io".to_string(), RegistryConfig { url: "https://ghcr.io".to_string(), auth_token: None, }, ); Config { compose_paths: vec![PathBuf::from("./test")], schedule: "0 0 2 * * *".to_string(), registries, update_strategy: UpdateStrategy::Latest, ignore_images: vec![], dry_run: true, } }