[+] End-to-end testing

This commit is contained in:
2026-05-08 15:32:33 +00:00
parent ac2f2559c1
commit 74e4a6eff4
15 changed files with 2405 additions and 62 deletions
File diff suppressed because it is too large Load Diff
+80
View File
@@ -19,6 +19,9 @@ fn parses_token_forms() {
[[mirrors]]
name = "personal"
sync_visibility = "public"
repo_whitelist = ["^important-", "-mirror$"]
repo_blacklist = ["-archive$"]
create_missing = true
visibility = "private"
allow_force = false
@@ -43,6 +46,15 @@ fn parses_token_forms() {
config.mirrors[0].conflict_resolution,
ConflictResolutionStrategy::AutoRebasePullRequest
);
assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public);
assert_eq!(
config.mirrors[0].repo_whitelist,
vec!["^important-".to_string(), "-mirror$".to_string()]
);
assert_eq!(
config.mirrors[0].repo_blacklist,
vec!["-archive$".to_string()]
);
let webhook = config.webhook.unwrap();
assert!(webhook.install);
assert_eq!(webhook.url, "https://mirror.example.test/webhook");
@@ -64,6 +76,9 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
kind: NamespaceKind::User,
namespace: "alice".to_string(),
}],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -90,6 +105,9 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -141,6 +159,68 @@ fn api_base_defaults_match_providers() {
);
}
#[test]
fn sync_visibility_matches_repo_privacy() {
assert!(SyncVisibility::All.matches_private(true));
assert!(SyncVisibility::All.matches_private(false));
assert!(SyncVisibility::Private.matches_private(true));
assert!(!SyncVisibility::Private.matches_private(false));
assert!(!SyncVisibility::Public.matches_private(true));
assert!(SyncVisibility::Public.matches_private(false));
}
#[test]
fn repo_name_filter_applies_whitelist_then_blacklist() {
let mut mirror = mirror_config();
mirror.repo_whitelist = vec!["^important-".to_string(), "-mirror$".to_string()];
mirror.repo_blacklist = vec!["-archive$".to_string()];
let filter = mirror.repo_filter().unwrap();
assert!(filter.matches("important-api"));
assert!(filter.matches("user-mirror"));
assert!(!filter.matches("important-archive"));
assert!(!filter.matches("random"));
}
#[test]
fn validation_rejects_invalid_repo_filter_regex() {
let mut config = Config {
sites: vec![site("github", ProviderKind::Github)],
mirrors: vec![mirror_config()],
webhook: None,
};
config.mirrors[0].repo_whitelist = vec!["(".to_string()];
let err = validate_config(&config).unwrap_err().to_string();
assert!(err.contains("invalid repo_whitelist regex"));
}
fn mirror_config() -> MirrorConfig {
MirrorConfig {
name: "personal".to_string(),
endpoints: vec![
EndpointConfig {
site: "github".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
EndpointConfig {
site: "github".to_string(),
kind: NamespaceKind::Org,
namespace: "example".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
conflict_resolution: ConflictResolutionStrategy::Fail,
}
}
fn site(name: &str, provider: ProviderKind) -> SiteConfig {
SiteConfig {
name: name.to_string(),
+35 -1
View File
@@ -12,6 +12,8 @@ fn wizard_builds_sync_group_from_profile_urls() {
"",
"n",
"",
"",
"",
"n",
"4",
]
@@ -42,6 +44,7 @@ fn wizard_builds_sync_group_from_profile_urls() {
assert_eq!(config.mirrors[0].endpoints[0].namespace, "hykilpikonna");
assert_eq!(config.mirrors[0].endpoints[1].site, "gitea-example-test");
assert_eq!(config.mirrors[0].endpoints[1].namespace, "azalea");
assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::All);
assert!(config.mirrors[0].create_missing);
assert_eq!(config.mirrors[0].visibility, Visibility::Private);
assert!(!config.mirrors[0].allow_force);
@@ -73,6 +76,8 @@ fn wizard_can_build_three_way_sync() {
"",
"n",
"",
"",
"",
"n",
"4",
]
@@ -99,6 +104,8 @@ fn wizard_can_enable_webhooks() {
"",
"n",
"",
"",
"",
"y",
"https://mirror.example.test/webhook",
"y",
@@ -150,6 +157,8 @@ fn wizard_reuses_existing_credentials_for_same_instance() {
"",
"n",
"",
"",
"",
"n",
"4",
]
@@ -200,6 +209,9 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -226,6 +238,9 @@ fn wizard_can_ask_to_run_full_sync_after_config() {
mirrors: vec![MirrorConfig {
name: "sync-1".to_string(),
endpoints: Vec::new(),
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -299,6 +314,9 @@ fn wizard_edits_existing_sync_group_from_menu() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::Private,
repo_whitelist: vec!["^important-".to_string()],
repo_blacklist: vec!["-archive$".to_string()],
create_missing: false,
visibility: Visibility::Public,
allow_force: true,
@@ -314,6 +332,10 @@ fn wizard_edits_existing_sync_group_from_menu() {
"https://gitlab.com/bob",
"",
"n",
"public",
"y",
"^public-",
"-skip$",
"",
"n",
"4",
@@ -334,6 +356,9 @@ fn wizard_edits_existing_sync_group_from_menu() {
assert_eq!(mirror.endpoints[1].site, "gitlab");
assert_eq!(mirror.endpoints[1].namespace, "bob");
assert!(!mirror.create_missing);
assert_eq!(mirror.sync_visibility, SyncVisibility::Public);
assert_eq!(mirror.repo_whitelist, vec!["^public-".to_string()]);
assert_eq!(mirror.repo_blacklist, vec!["-skip$".to_string()]);
assert_eq!(mirror.visibility, Visibility::Public);
assert!(mirror.allow_force);
@@ -378,6 +403,9 @@ fn wizard_prefills_existing_sync_group_when_editing() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -385,7 +413,7 @@ fn wizard_prefills_existing_sync_group_when_editing() {
}],
webhook: None,
};
let input = ["2", "1", "", "", "", "", "n", "", "n", "4"].join("\n") + "\n";
let input = ["2", "1", "", "", "", "", "n", "", "", "", "n", "4"].join("\n") + "\n";
let mut reader = Cursor::new(input.as_bytes());
let mut output = Vec::new();
@@ -439,6 +467,9 @@ fn wizard_deletes_existing_sync_group_from_menu() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -495,6 +526,9 @@ fn wizard_can_go_back_from_delete_menu() {
namespace: "alice".to_string(),
},
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
+121
View File
@@ -63,10 +63,15 @@ where
W: Write,
{
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?;
let sync_visibility = prompt_sync_visibility(reader, writer, None)?;
let repo_filters = prompt_repo_filters(reader, writer, None)?;
let conflict_resolution = prompt_conflict_resolution(reader, writer, None)?;
config.upsert_mirror(MirrorConfig {
name: next_mirror_name(config),
endpoints,
sync_visibility,
repo_whitelist: repo_filters.whitelist,
repo_blacklist: repo_filters.blacklist,
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -267,15 +272,27 @@ where
match value.parse::<usize>() {
Ok(index) if (1..=config.mirrors.len()).contains(&index) => {
let existing = config.mirrors[index - 1].endpoints.clone();
let existing_sync_visibility = config.mirrors[index - 1].sync_visibility.clone();
let existing_repo_filters = RepoFilterInput {
whitelist: config.mirrors[index - 1].repo_whitelist.clone(),
blacklist: config.mirrors[index - 1].repo_blacklist.clone(),
};
let existing_conflict_resolution =
config.mirrors[index - 1].conflict_resolution.clone();
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?;
let sync_visibility =
prompt_sync_visibility(reader, writer, Some(&existing_sync_visibility))?;
let repo_filters =
prompt_repo_filters(reader, writer, Some(&existing_repo_filters))?;
let conflict_resolution = prompt_conflict_resolution(
reader,
writer,
Some(&existing_conflict_resolution),
)?;
config.mirrors[index - 1].endpoints = endpoints;
config.mirrors[index - 1].sync_visibility = sync_visibility;
config.mirrors[index - 1].repo_whitelist = repo_filters.whitelist;
config.mirrors[index - 1].repo_blacklist = repo_filters.blacklist;
config.mirrors[index - 1].conflict_resolution = conflict_resolution;
prompt_webhook_setup(reader, writer, config)?;
writeln!(writer, "updated sync group {index}")?;
@@ -476,6 +493,100 @@ where
}
}
fn prompt_sync_visibility<R, W>(
reader: &mut R,
writer: &mut W,
existing: Option<&SyncVisibility>,
) -> Result<SyncVisibility>
where
R: BufRead,
W: Write,
{
let default = existing.map(sync_visibility_value).unwrap_or("all");
loop {
writeln!(writer, "Which repositories should this sync group include?")?;
writeln!(writer, " 1. all")?;
writeln!(writer, " 2. private only")?;
writeln!(writer, " 3. public only")?;
let value = prompt_with_default(reader, writer, "Sync visibility", default)?;
match value.trim().to_ascii_lowercase().as_str() {
"1" | "all" => return Ok(SyncVisibility::All),
"2" | "private" | "private only" | "private-only" => {
return Ok(SyncVisibility::Private);
}
"3" | "public" | "public only" | "public-only" => {
return Ok(SyncVisibility::Public);
}
_ => writeln!(writer, "Enter 1, 2, 3, all, private, or public.")?,
}
}
}
fn prompt_repo_filters<R, W>(
reader: &mut R,
writer: &mut W,
existing: Option<&RepoFilterInput>,
) -> Result<RepoFilterInput>
where
R: BufRead,
W: Write,
{
let existing = existing.cloned().unwrap_or_default();
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty();
if !prompt_bool(
reader,
writer,
"Configure repository name whitelist/blacklist?",
has_existing,
)? {
return Ok(RepoFilterInput::default());
}
Ok(RepoFilterInput {
whitelist: prompt_repo_pattern_list(
reader,
writer,
"Whitelist regexes (comma-separated, empty means all repo names)",
&existing.whitelist,
)?,
blacklist: prompt_repo_pattern_list(
reader,
writer,
"Blacklist regexes (comma-separated)",
&existing.blacklist,
)?,
})
}
fn prompt_repo_pattern_list<R, W>(
reader: &mut R,
writer: &mut W,
label: &str,
existing: &[String],
) -> Result<Vec<String>>
where
R: BufRead,
W: Write,
{
let value = if existing.is_empty() {
prompt_optional(reader, writer, label)?
} else {
prompt_with_default(reader, writer, label, &existing.join(", "))?
};
if let Err(error) = validate_repo_pattern_list(&value) {
bail!(error);
}
Ok(parse_repo_pattern_list(&value))
}
fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str {
match sync_visibility {
SyncVisibility::All => "all",
SyncVisibility::Private => "private",
SyncVisibility::Public => "public",
}
}
fn conflict_resolution_value(strategy: &ConflictResolutionStrategy) -> &'static str {
match strategy {
ConflictResolutionStrategy::Fail => "fail",
@@ -540,6 +651,16 @@ where
}
}
fn prompt_optional<R, W>(reader: &mut R, writer: &mut W, label: &str) -> Result<String>
where
R: BufRead,
W: Write,
{
write!(writer, "{label}: ")?;
writer.flush()?;
Ok(read_line(reader)?.trim().to_string())
}
fn prompt_with_default<R, W>(
reader: &mut R,
writer: &mut W,
+53
View File
@@ -308,6 +308,56 @@ fn repo_deletion_decision_ignores_repos_not_previously_synced_everywhere() {
assert_eq!(decision, RepoDeletionDecision::None);
}
#[test]
fn filtered_sync_visibility_does_not_treat_state_only_repos_as_deleted() {
let mut mirror = test_mirror();
mirror.sync_visibility = crate::config::SyncVisibility::Public;
let mut ref_state = RefState::default();
ref_state.set_repo(
&mirror.name,
"private-repo",
BTreeMap::from([("github_alice".to_string(), remote_ref_state("a", &[]))]),
);
let repo_filter = mirror.repo_filter().unwrap();
let names = sync_candidate_repo_names(&HashMap::new(), &ref_state, &mirror, &repo_filter);
assert!(names.is_empty());
}
#[test]
fn all_visibility_keeps_state_only_repos_for_deletion_detection() {
let mirror = test_mirror();
let mut ref_state = RefState::default();
ref_state.set_repo(
&mirror.name,
"deleted-repo",
BTreeMap::from([("github_alice".to_string(), remote_ref_state("a", &[]))]),
);
let repo_filter = mirror.repo_filter().unwrap();
let names = sync_candidate_repo_names(&HashMap::new(), &ref_state, &mirror, &repo_filter);
assert_eq!(names, BTreeSet::from(["deleted-repo".to_string()]));
}
#[test]
fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() {
let mut mirror = test_mirror();
mirror.repo_whitelist = vec!["^public-".to_string()];
let repo_filter = mirror.repo_filter().unwrap();
let mut ref_state = RefState::default();
ref_state.set_repo(
&mirror.name,
"private-repo",
BTreeMap::from([("github_alice".to_string(), remote_ref_state("a", &[]))]),
);
let names = sync_candidate_repo_names(&HashMap::new(), &ref_state, &mirror, &repo_filter);
assert!(names.is_empty());
}
#[test]
fn conflict_branch_prefixes_are_reversible_not_slug_collisions() {
let slash_branch = conflict_pr_branch_prefix("release/foo");
@@ -358,6 +408,9 @@ fn test_mirror() -> MirrorConfig {
MirrorConfig {
name: "sync-1".to_string(),
endpoints: vec![endpoint("github"), endpoint("gitea")],
sync_visibility: crate::config::SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: crate::config::Visibility::Private,
allow_force: false,
+47
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::config::SyncVisibility;
use crate::config::{
ConflictResolutionStrategy, EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig,
TokenConfig, Visibility,
@@ -109,6 +110,9 @@ fn matches_jobs_by_provider_and_namespace() {
endpoint("github", NamespaceKind::User, "alice"),
endpoint("gitea", NamespaceKind::User, "azalea"),
],
sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(),
repo_blacklist: Vec::new(),
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
@@ -129,6 +133,41 @@ fn matches_jobs_by_provider_and_namespace() {
assert_eq!(jobs[0].repo, "repo");
}
#[test]
fn matching_jobs_respects_repo_name_filters() {
let mut mirror = MirrorConfig {
name: "sync-1".to_string(),
endpoints: vec![endpoint("github", NamespaceKind::User, "alice")],
sync_visibility: SyncVisibility::All,
repo_whitelist: vec!["^important-".to_string()],
repo_blacklist: vec!["-archive$".to_string()],
create_missing: true,
visibility: Visibility::Private,
allow_force: false,
conflict_resolution: ConflictResolutionStrategy::Fail,
};
let config = Config {
sites: vec![site("github", ProviderKind::Github)],
mirrors: vec![mirror.clone()],
webhook: None,
};
assert_eq!(
matching_jobs(&config, &webhook_event("important-api")).len(),
1
);
assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty());
assert!(matching_jobs(&config, &webhook_event("random")).is_empty());
mirror.repo_whitelist.clear();
let config = Config {
sites: vec![site("github", ProviderKind::Github)],
mirrors: vec![mirror],
webhook: None,
};
assert_eq!(matching_jobs(&config, &webhook_event("random")).len(), 1);
}
#[test]
fn webhook_state_persists_installations() {
let temp = tempfile::TempDir::new().unwrap();
@@ -330,6 +369,14 @@ fn site(name: &str, provider: ProviderKind) -> SiteConfig {
}
}
fn webhook_event(repo: &str) -> WebhookEvent {
WebhookEvent {
provider: Some(ProviderKind::Github),
repo: repo.to_string(),
namespace: Some("alice".to_string()),
}
}
fn endpoint(site: &str, kind: NamespaceKind, namespace: &str) -> EndpointConfig {
EndpointConfig {
site: site.to_string(),