Clean up
Some checks failed
Build and Publish Docker Image / test (push) Failing after 7s
Build and Publish Docker Image / build-and-push (push) Has been skipped
Build and Publish Docker Image / security-scan (push) Has been skipped

This commit is contained in:
Andras Schmelczer 2026-06-06 19:41:55 +01:00
parent 3f60b72c3b
commit b414c05400
18 changed files with 905 additions and 985 deletions

View file

@ -3,11 +3,11 @@ use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use docker_compose_updater::registry::{Client, ImageRef};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_end_to_end_compose_file_parsing_and_updating() {
async fn test_compose_file_parsing() {
let compose_content = r#"
version: '3.8'
services:
@ -15,12 +15,12 @@ services:
image: nginx:1.21.0 # Web server
ports:
- "80:80"
db:
image: postgres:13.7
environment:
POSTGRES_DB: myapp
redis:
image: redis:6.2.1-alpine
ports:
@ -30,9 +30,7 @@ services:
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let updater = ComposeUpdater::new(create_test_config());
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
@ -54,7 +52,7 @@ services:
}
#[tokio::test]
async fn test_comment_and_formatting_preservation_during_updates() {
async fn test_comment_and_formatting_preservation() {
let compose_content = r#"
version: '3.8'
services:
@ -62,7 +60,7 @@ services:
image: nginx:1.21.0 # This is a comment
ports:
- "80:80"
# This is another comment
db:
image: "postgres:13.7" # Database comment with quotes
@ -73,9 +71,7 @@ services:
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let updater = ComposeUpdater::new(create_test_config());
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
@ -87,34 +83,34 @@ services:
.original_line
.contains("# Database comment"));
// Replace unquoted image -> stays unquoted, preserves comment
let new_content = updater
.replace_image_in_content(&result.content, &result.services[0], "nginx:1.20.0")
.unwrap();
assert!(new_content.contains("# This is a comment"));
assert!(new_content.contains("nginx:1.20.0"));
assert!(new_content.contains("nginx:1.20.0 # This is a comment"));
assert!(!new_content.contains("nginx:1.21.0"));
// Replace quoted image -> stays quoted, preserves comment
let new_content = updater
.replace_image_in_content(&new_content, &result.services[1], "postgres:14.0")
.unwrap();
assert!(new_content.contains("# Database comment"));
assert!(new_content.contains("\"postgres:14.0\""));
assert!(new_content.contains("\"postgres:14.0\" # Database comment"));
assert!(!new_content.contains("postgres:13.7"));
}
#[tokio::test]
async fn test_empty_compose_paths_handling() {
async fn test_empty_compose_paths() {
let config = Config {
compose_paths: vec![],
..create_test_config()
};
let updater = ComposeUpdater::new(config);
let result = updater.update_all_compose_files().await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
let result = updater.update_all_compose_files().await.unwrap();
assert_eq!(result.files_found, 0);
assert!(result.updated_files.is_empty());
}
#[test]
@ -140,6 +136,77 @@ fn test_config_defaults_and_image_filtering() {
assert!(!config_with_ignores.is_image_ignored("nginx:1.21.0"));
}
#[test]
fn test_env_var_substitution() {
std::env::set_var("TEST_TOKEN", "test_value_123");
let test_yaml = r#"
registries:
"test.registry":
url: "https://test.registry"
auth_token: "${TEST_TOKEN}"
"#;
let expanded = Config::expand_env_vars(test_yaml);
assert!(expanded.contains("test_value_123"));
assert!(!expanded.contains("${TEST_TOKEN}"));
std::env::remove_var("TEST_TOKEN");
}
#[test]
fn test_dollar_var_and_unset_var_expansion() {
std::env::set_var("TEST_DOLLAR_TOKEN", "dollar_value_456");
let expanded = Config::expand_env_vars("token: $TEST_DOLLAR_TOKEN");
assert!(expanded.contains("dollar_value_456"));
std::env::remove_var("TEST_DOLLAR_TOKEN");
let expanded = Config::expand_env_vars("token: ${DEFINITELY_UNSET_VAR}");
assert!(!expanded.contains("DEFINITELY_UNSET_VAR"));
assert!(expanded.contains("token: "));
}
#[tokio::test]
#[ignore = "Only run in CI with GITHUB_TOKEN set"]
async fn test_ghcr_authentication_e2e() {
let github_token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN must be set");
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: Some(github_token),
},
);
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
};
let client = Client::new(config);
let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap();
let versions = client.get_available_versions(&image_ref).await.unwrap();
assert!(!versions.is_empty());
assert!(
versions.iter().any(|v| v.version.to_string() == "0.5.1"),
"Should find version 0.5.1"
);
}
fn create_test_config() -> Config {
let mut registries = HashMap::new();
registries.insert(
@ -159,101 +226,3 @@ fn create_test_config() -> Config {
dry_run: true,
}
}
#[test]
fn test_env_var_substitution() {
// Test environment variable substitution in config
std::env::set_var("TEST_TOKEN", "test_value_123");
let test_yaml = r#"
registries:
"test.registry":
url: "https://test.registry"
auth_token: "${TEST_TOKEN}"
"#;
let expanded = Config::expand_env_vars(test_yaml);
assert!(expanded.contains("test_value_123"));
assert!(!expanded.contains("${TEST_TOKEN}"));
// Clean up
std::env::remove_var("TEST_TOKEN");
}
#[test]
fn test_config_loading_with_env_vars() {
// Set up test environment variable
std::env::set_var("GITHUB_TOKEN", "ghp_test_token_for_testing");
// Test that config loading works with our test config
if std::path::Path::new("config.test.yaml").exists() {
let config = Config::load(Path::new("config.test.yaml").to_path_buf()).unwrap();
if let Some(ghcr_config) = config.registries.get("ghcr.io") {
// Should have resolved the environment variable
assert_eq!(
ghcr_config.auth_token,
Some("ghp_test_token_for_testing".to_string())
);
}
}
// Clean up
std::env::remove_var("GITHUB_TOKEN");
}
#[tokio::test]
#[ignore = "Only run in CI with GITHUB_TOKEN set"]
async fn test_ghcr_authentication_e2e() {
let github_token = std::env::var("GITHUB_TOKEN").unwrap();
// This test validates that Docker Registry v2 authentication works with GHCR
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(),
// Use the token we validated above
auth_token: Some(github_token),
},
);
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
};
let client = Client::new(config);
// Test with the actual image that was failing
let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap();
let result = client.get_available_versions(&image_ref).await;
match result {
Ok(versions) => {
assert!(!versions.is_empty(), "Should return at least one version");
// Verify that we can find the specific version 0.5.1
let has_target_version = versions.iter().any(|v| v.version.to_string() == "0.5.1");
assert!(
has_target_version,
"Should find target version 0.5.1 in available versions"
);
}
Err(e) => {
panic!("GHCR authentication test failed: {e}");
}
}
}

View file

@ -8,7 +8,6 @@ use tempfile::NamedTempFile;
fn test_config_default_values() {
let config = Config::default();
// Verify all default values are reasonable
assert_eq!(config.schedule, "0 0 2 * * *");
assert_eq!(
config.update_strategy,
@ -16,10 +15,8 @@ fn test_config_default_values() {
);
assert!(!config.dry_run);
assert!(config.ignore_images.is_empty());
// compose_paths should be a valid Vec (length check is always true for usize)
let _paths_count = config.compose_paths.len();
assert!(!config.compose_paths.is_empty());
// Should have default registries configured
assert!(config.registries.contains_key("docker.io"));
assert!(config.registries.contains_key("ghcr.io"));
@ -29,12 +26,10 @@ fn test_config_default_values() {
let ghcr_registry = &config.registries["ghcr.io"];
assert_eq!(ghcr_registry.url, "https://ghcr.io");
assert!(ghcr_registry.auth_token.is_none());
}
#[test]
fn test_config_from_different_file_formats() {
// Test loading from a properly formatted YAML file
fn test_config_from_yaml_file() {
let valid_yaml = r#"
compose_paths:
- "./docker-compose.yml"
@ -57,10 +52,7 @@ registries:
temp_file.write_all(valid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_ok(), "Should load valid YAML config");
let config = result.unwrap();
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert_eq!(config.compose_paths.len(), 2);
assert_eq!(config.schedule, "0 0 3 * * *");
assert_eq!(config.update_strategy, UpdateStrategy::Latest);
@ -74,45 +66,18 @@ registries:
}
#[test]
fn test_config_with_invalid_yaml_syntax() {
let invalid_yamls = [
// Invalid YAML syntax
r#"
compose_paths:
- "./docker-compose.yml"
invalid_indent:
schedule: "0 0 2 * * *"
"#,
// Invalid field types
r#"
compose_paths: "not an array"
schedule: 123
dry_run: "not a boolean"
"#,
// Completely invalid YAML
r#"
{ invalid yaml syntax [
"#,
];
fn test_config_rejects_invalid_yaml() {
let invalid_yaml = r#"{ invalid yaml syntax ["#;
for (i, invalid_yaml) in invalid_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(invalid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap();
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(invalid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
// Should either fail gracefully or fall back to defaults
if result.is_ok() {
let config = result.unwrap();
// If it succeeds, should have reasonable defaults
assert!(
!config.schedule.is_empty(),
"Schedule should not be empty for test case {i}"
);
}
// If it fails, that's also acceptable behavior for invalid YAML
}
let result = Config::load(temp_file.path().to_path_buf());
assert!(
result.is_err(),
"Completely invalid YAML should fail to load"
);
}
#[test]
@ -123,10 +88,6 @@ fn test_config_update_strategy_parsing() {
"LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor,
),
(
"LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor,
),
];
for (strategy_str, expected_strategy) in strategies {
@ -142,19 +103,13 @@ update_strategy: "{strategy_str}"
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(
result.is_ok(),
"Should parse valid strategy: {strategy_str}"
);
let config = result.unwrap();
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert_eq!(config.update_strategy, expected_strategy);
}
}
#[test]
fn test_config_with_invalid_update_strategy() {
fn test_config_rejects_invalid_update_strategy() {
let yaml_config = r#"
compose_paths: []
schedule: "0 0 2 * * *"
@ -166,15 +121,12 @@ update_strategy: "InvalidStrategy"
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_err(), "Should not parse invalid update strategy");
assert!(result.is_err());
}
#[test]
fn test_image_ignore_patterns_functionality() {
// Test various ignore patterns
fn test_image_ignore_patterns() {
let test_cases = vec![
// (ignore_pattern, image_to_test, should_be_ignored)
("localhost", "localhost:5000/app:latest", true),
("localhost", "nginx:latest", false),
("127.0.0.1", "127.0.0.1:5000/app:latest", true),
@ -182,6 +134,7 @@ fn test_image_ignore_patterns_functionality() {
("ghcr.io", "ghcr.io/user/app:latest", true),
("ghcr.io", "docker.io/nginx:latest", false),
("test-", "test-app:latest", true),
// "app-test:latest" does NOT contain "test-" (it has "test:" after the dash)
("test-", "app-test:latest", false),
];
@ -191,10 +144,13 @@ fn test_image_ignore_patterns_functionality() {
..Config::default()
};
let result = config.is_image_ignored(image_to_test);
assert_eq!(
result, should_be_ignored,
"Pattern '{ignore_pattern}' with image '{image_to_test}' should be ignored: {should_be_ignored}"
config.is_image_ignored(image_to_test),
should_be_ignored,
"Pattern '{}' with image '{}': expected ignored={}",
ignore_pattern,
image_to_test,
should_be_ignored
);
}
}
@ -211,31 +167,17 @@ fn test_multiple_ignore_patterns() {
..Config::default()
};
let test_cases = vec![
("localhost:5000/app:latest", true),
("127.0.0.1:5000/app:latest", true),
("test-app:latest", true),
("ghcr.io/user/app:latest", true),
("docker.io/nginx:latest", false),
("registry.example.com/app:latest", false),
("app-test:latest", false),
];
for (image, should_be_ignored) in test_cases {
let result = config.is_image_ignored(image);
assert_eq!(
result, should_be_ignored,
"Image '{image}' should be ignored: {should_be_ignored}"
);
}
assert!(config.is_image_ignored("localhost:5000/app:latest"));
assert!(config.is_image_ignored("127.0.0.1:5000/app:latest"));
assert!(config.is_image_ignored("test-app:latest"));
assert!(config.is_image_ignored("ghcr.io/user/app:latest"));
assert!(!config.is_image_ignored("docker.io/nginx:latest"));
assert!(!config.is_image_ignored("registry.example.com/app:latest"));
}
#[test]
fn test_registry_configuration_validation() {
// Test registry configs with various configurations
fn test_registry_configuration() {
let mut registries = HashMap::new();
// Registry with just URL
registries.insert(
"simple.registry.com".to_string(),
RegistryConfig {
@ -243,8 +185,6 @@ fn test_registry_configuration_validation() {
auth_token: None,
},
);
// Registry with auth token
registries.insert(
"auth.registry.com".to_string(),
RegistryConfig {
@ -258,17 +198,13 @@ fn test_registry_configuration_validation() {
..Config::default()
};
// Verify registries are properly configured
assert!(config.registries.contains_key("simple.registry.com"));
assert!(config.registries.contains_key("auth.registry.com"));
let simple_registry = &config.registries["simple.registry.com"];
assert_eq!(simple_registry.url, "https://simple.registry.com");
assert!(simple_registry.auth_token.is_none());
let auth_registry = &config.registries["auth.registry.com"];
assert_eq!(auth_registry.url, "https://auth.registry.com");
assert_eq!(auth_registry.auth_token, Some("secret-token".to_string()));
assert!(config.registries["simple.registry.com"]
.auth_token
.is_none());
assert_eq!(
config.registries["auth.registry.com"].auth_token,
Some("secret-token".to_string())
);
}
#[test]
@ -284,36 +220,19 @@ registries: {}
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
if result.is_ok() {
let config = result.unwrap();
assert!(config.compose_paths.is_empty());
assert!(config.ignore_images.is_empty());
// Empty schedule should either be rejected or have a default
assert!(!config.schedule.is_empty() || config.schedule.is_empty()); // Either is acceptable
}
// If loading fails for empty schedule, that's also acceptable
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(config.compose_paths.is_empty());
assert!(config.ignore_images.is_empty());
assert!(config.schedule.is_empty());
assert!(config.registries.is_empty());
}
#[test]
fn test_config_compose_paths_handling() {
// Test various compose path configurations
let test_cases = vec![
// Single file path
vec!["./docker-compose.yml"],
// Multiple file paths
vec!["./docker-compose.yml", "./docker-compose.override.yml"],
// Directory paths
vec!["./services/", "./environments/"],
// Mixed paths
vec!["./docker-compose.yml", "./services/", "./override.yml"],
// Relative and absolute-looking paths
vec![
"docker-compose.yml",
"/app/config/compose.yml",
"../compose.yml",
],
];
for paths in test_cases {
@ -334,13 +253,33 @@ schedule: "0 0 2 * * *"
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_ok(), "Should handle paths: {paths:?}");
let config = result.unwrap();
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert_eq!(config.compose_paths.len(), paths.len());
for (i, expected_path) in paths.iter().enumerate() {
assert_eq!(config.compose_paths[i], PathBuf::from(expected_path));
}
}
}
#[test]
fn test_load_normalizes_empty_auth_token_to_none() {
// An auth_token referencing an unset variable expands to "" and should be
// treated as "no token" rather than an empty credential.
let yaml_config = r#"
compose_paths: []
schedule: "0 0 2 * * *"
registries:
private.registry.com:
url: "https://private.registry.com"
auth_token: "${DCU_DEFINITELY_UNSET_TOKEN}"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(config.registries["private.registry.com"]
.auth_token
.is_none());
}

View file

@ -9,153 +9,125 @@ use tempfile::{NamedTempFile, TempDir};
#[test]
fn test_scheduler_invalid_cron_expressions() {
// Test that scheduler now properly rejects invalid cron expressions instead of falling back
let invalid_cron_configs = [
let invalid_crons = [
"invalid cron",
"0 0 25 * * *", // Invalid hour
"60 0 2 * * *", // Invalid minute
"", // Empty string
"0 0 2 * *", // Missing field
"0 0 25 * * *",
"60 0 2 * * *",
"",
"0 0 2 * *",
];
for invalid_cron in invalid_cron_configs {
for cron in invalid_crons {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: invalid_cron.to_string(),
schedule: cron.to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let result = Scheduler::new(config, None);
assert!(
result.is_err(),
"Expected error for invalid cron expression: '{invalid_cron}'"
Scheduler::new(config, None).is_err(),
"Expected error for invalid cron expression: '{cron}'"
);
}
}
#[test]
fn test_scheduler_valid_cron_expressions() {
// Test that scheduler accepts valid cron expressions
let valid_cron_configs = [
"0 0 2 * * *", // Default - 2 AM daily
"0 30 1 * * *", // 1:30 AM daily
"0 0 */6 * * *", // Every 6 hours
"0 0 2 * * MON", // Mondays at 2 AM
"0 15 14 1 * *", // 1st of month at 2:15 PM
let valid_crons = [
"0 0 2 * * *",
"0 30 1 * * *",
"0 0 */6 * * *",
"0 0 2 * * MON",
"0 15 14 1 * *",
];
for valid_cron in valid_cron_configs {
for cron in valid_crons {
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: valid_cron.to_string(),
schedule: cron.to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
let result = Scheduler::new(config, None);
assert!(
result.is_ok(),
"Expected success for valid cron expression: '{valid_cron}'"
Scheduler::new(config, None).is_ok(),
"Expected success for valid cron expression: '{cron}'"
);
}
}
#[test]
fn test_compose_invalid_file_paths() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let updater = ComposeUpdater::new(create_test_config());
// Test with completely invalid path
let result = updater.parse_compose_file("/nonexistent/path/file.yml");
assert!(result.is_err(), "Should fail for nonexistent file");
assert!(updater
.parse_compose_file("/nonexistent/path/file.yml")
.is_err());
// Test with directory instead of file
let temp_dir = TempDir::new().unwrap();
let result = updater.parse_compose_file(temp_dir.path().to_str().unwrap());
assert!(result.is_err(), "Should fail when path is a directory");
assert!(updater
.parse_compose_file(temp_dir.path().to_str().unwrap())
.is_err());
}
#[test]
fn test_compose_malformed_yaml() {
let config = create_test_config();
let updater = ComposeUpdater::new(config);
let updater = ComposeUpdater::new(create_test_config());
let malformed_yamls = [
// Invalid YAML syntax
r#"
version: '3.8'
services:
web:
image: nginx:1.21.0
invalid_indent
ports:
- "80:80"
"#,
// Missing required fields
r#"
version: '3.8'
# Missing services section
"#,
// Completely empty file
"",
// Invalid version format
r#"
version: invalid
services:
web:
image: nginx:1.21.0
"#,
];
// Missing services section -> no services found
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"version: '3.8'\n").unwrap();
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert!(result.services.is_empty());
for (i, malformed_yaml) in malformed_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(malformed_yaml.as_bytes()).unwrap();
// Empty file -> no services found
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"").unwrap();
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert!(result.services.is_empty());
let result = updater.parse_compose_file(temp_file.path().to_str().unwrap());
// We expect these to either fail or return empty services (graceful degradation)
if result.is_ok() {
let compose_file = result.unwrap();
// If it parses successfully, it should at least not crash
assert!(
compose_file.services.is_empty() || !compose_file.services.is_empty(),
"Test case {i} should either fail or succeed gracefully"
);
}
// If it fails, that's also acceptable behavior for malformed YAML
}
// Valid services section with invalid version field -> still finds services
let mut temp_file = NamedTempFile::new().unwrap();
temp_file
.write_all(b"version: invalid\nservices:\n web:\n image: nginx:1.21.0\n")
.unwrap();
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert_eq!(result.services.len(), 1);
}
#[test]
fn test_image_ref_parsing_edge_cases() {
// Test that image parsing doesn't crash on edge cases
let long_name = "a".repeat(300);
let edge_case_refs = [
"", // Empty string
":", // Just colon
"image:", // Missing tag
":tag", // Missing image
"registry.com/", // Missing image name
&long_name, // Extremely long name
];
// Empty string parses but produces empty name (no panic)
let result = ImageRef::parse("").unwrap();
assert_eq!(result.name, "");
assert_eq!(result.tag, "latest");
for edge_case_ref in edge_case_refs {
let result = ImageRef::parse(edge_case_ref);
// The main goal is that parsing doesn't crash and returns a result
// Some might succeed (with defaults) or fail - both are acceptable
if result.is_ok() {
let _image_ref = result.unwrap();
// If it succeeds, that's fine - the parser is robust
// We don't make strict assertions about the content since
// the parser may use defaults for edge cases
}
// If they fail, that's also perfectly acceptable behavior
}
// Missing tag defaults to "latest"
let result = ImageRef::parse("nginx").unwrap();
assert_eq!(result.tag, "latest");
assert_eq!(result.name, "nginx");
// Registry with namespace
let result = ImageRef::parse("ghcr.io/user/app:v1").unwrap();
assert_eq!(result.registry, "ghcr.io");
assert_eq!(result.namespace, Some("user".to_string()));
assert_eq!(result.name, "app");
assert_eq!(result.tag, "v1");
// Very long name shouldn't panic
let long_name = "a".repeat(300);
let _ = ImageRef::parse(&long_name);
}
#[test]
@ -163,7 +135,7 @@ fn test_registry_unknown_registry() {
let config = Config {
compose_paths: vec![],
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // Empty registries
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
@ -171,7 +143,6 @@ fn test_registry_unknown_registry() {
let registry_client = RegistryClient::new(config);
// Test with an unknown registry
let image_ref = ImageRef {
registry: "unknown.registry.com".to_string(),
namespace: Some("user".to_string()),
@ -179,57 +150,43 @@ fn test_registry_unknown_registry() {
tag: "1.0.0".to_string(),
};
// This should fail because the registry is not configured
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(registry_client.get_available_versions(&image_ref));
assert!(result.is_err(), "Should fail for unknown registry");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Unknown registry"),
"Error should mention unknown registry, got: {error_msg}"
"Expected 'Unknown registry' in error, got: {error_msg}"
);
}
#[tokio::test]
async fn test_compose_update_with_empty_paths() {
let config = Config {
compose_paths: vec![], // Empty paths
compose_paths: vec![],
..create_test_config()
};
let updater = ComposeUpdater::new(config);
// Should handle empty paths gracefully
let result = updater.update_all_compose_files().await;
assert!(result.is_ok(), "Should handle empty paths gracefully");
assert!(
result.unwrap().is_empty(),
"Should return empty list for empty paths"
);
let result = updater.update_all_compose_files().await.unwrap();
assert_eq!(result.files_found, 0);
assert!(result.updated_files.is_empty());
}
#[test]
fn test_config_edge_cases() {
// Test config with minimal valid data
let minimal_config = Config {
compose_paths: vec![], // Empty paths
compose_paths: vec![],
schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // No registries
registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest,
ignore_images: vec![],
dry_run: true,
};
// Should be able to create scheduler with minimal config
let scheduler_result = Scheduler::new(minimal_config.clone(), None);
assert!(
scheduler_result.is_ok(),
"Should accept minimal valid config"
);
// Should be able to create compose updater with minimal config
assert!(Scheduler::new(minimal_config.clone(), None).is_ok());
let _updater = ComposeUpdater::new(minimal_config.clone());
// This should not panic
assert!(!minimal_config.is_image_ignored("any-image:tag"));
}
@ -238,8 +195,7 @@ fn test_version_selection_with_mismatched_prefixes_suffixes() {
use docker_compose_updater::strategy::create_selector;
use docker_compose_updater::version::VersionInfo;
let config = create_test_config();
let selector = create_selector(&config.update_strategy);
let selector = create_selector(&create_test_config().update_strategy);
let current_prefix = Some("v".to_string());
let current_suffix = Some("-alpine".to_string());
@ -251,10 +207,9 @@ fn test_version_selection_with_mismatched_prefixes_suffixes() {
];
let target = selector.select_target_version(&available, current_prefix, current_suffix);
assert!(
target.is_none(),
"Should not find target when prefix/suffix don't match"
"No versions match both prefix 'v' and suffix '-alpine'"
);
}