use super::*;
use crate::core::config::{TeamConfig, TeamMember};
use std::collections::HashMap;
fn make_team() -> TeamConfig {
let mut aliases = HashMap::new();
aliases.insert("bobby".into(), "Bob Smith".into());
TeamConfig {
members: vec![TeamMember {
name: "Bob Smith".into(),
email: "bob@example.com".into(),
aliases: vec!["bsmith@example.com".into()],
}],
aliases,
canonical_domain: None,
}
}
#[test]
fn exact_email_alias_match() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("Whoever", "bsmith@example.com");
assert_eq!(n, "Bob Smith");
assert_eq!(e, "bob@example.com");
}
#[test]
fn exact_name_alias_match() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("bobby", "x@y.com");
assert_eq!(n, "Bob Smith");
assert_eq!(e, "bob@example.com");
}
#[test]
fn fuzzy_match_canonical_name() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, _e) = r.resolve("Bob Smyth", "unknown@elsewhere.com");
assert_eq!(n, "Bob Smith");
}
#[test]
fn no_match_returns_input() {
let r = IdentityResolver::new(Some(&make_team()));
let (n, e) = r.resolve("Zelda Q", "zelda@nowhere.test");
assert_eq!(n, "Zelda Q");
assert_eq!(e, "zelda@nowhere.test");
}
#[test]
fn empty_team_passthrough() {
let r = IdentityResolver::new(None);
let (n, e) = r.resolve("Anyone", "anyone@x.com");
assert_eq!(n, "Anyone");
assert_eq!(e, "anyone@x.com");
}
#[test]
fn all_aliases_registered() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Alice Smith".to_string(),
vec![
"alice@company.com".into(),
"alice.smith@personal.com".into(),
"asmith".into(), ],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("whoever", "alice@company.com");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n, e) = r.resolve("whoever", "alice.smith@personal.com");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n, e) = r.resolve("asmith", "noise@nowhere.test");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
}
#[test]
fn email_local_part_fuzzy_match() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Bob Matsuoka".to_string(),
vec!["bob.matsuoka@researchco.example".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Bob M", "bob.matsuoka@otherdomain.com");
assert_eq!(n, "Bob Matsuoka");
assert_eq!(e, "bob.matsuoka@researchco.example");
}
#[test]
fn case_insensitive_email_lookup() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert("Alice Smith".to_string(), vec!["alice@company.com".into()]);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Whoever", "ALICE@COMPANY.COM");
assert_eq!(n, "Alice Smith");
assert_eq!(e, "alice@company.com");
let (n2, e2) = r.resolve("WhoEver", "Alice@Company.Com");
assert_eq!(n2, "Alice Smith");
assert_eq!(e2, "alice@company.com");
}
#[test]
fn short_name_fuzzy() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Bob Matsuoka".to_string(),
vec!["bob.matsuoka@co.com".into()],
);
let r = IdentityResolver::from_alias_map(&map);
let (n, _e) = r.resolve("Bob M", "bobm@unknown.test");
assert_eq!(n, "Bob Matsuoka");
}
#[test]
fn unknown_author_passthrough() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert("Alice Smith".to_string(), vec!["alice@company.com".into()]);
let r = IdentityResolver::from_alias_map(&map);
let (n, e) = r.resolve("Zelda Q", "zelda@nowhere.test");
assert_eq!(n, "Zelda Q");
assert_eq!(e, "zelda@nowhere.test");
}
#[test]
fn multiple_emails_same_person() {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Pedro Silva".to_string(),
vec![
"pedro.silva@researchco.example".into(),
"100000001+pedrosilvaextern@users.noreply.github.com".into(),
"pedro@personal.dev".into(),
],
);
let r = IdentityResolver::from_alias_map(&map);
let (n1, e1) = r.resolve("Pedro Silva", "pedro.silva@researchco.example");
let (n2, e2) = r.resolve(
"pedrosilvaextern",
"100000001+pedrosilvaextern@users.noreply.github.com",
);
let (n3, e3) = r.resolve("P. Silva", "pedro@personal.dev");
assert_eq!(n1, "Pedro Silva");
assert_eq!(n2, "Pedro Silva");
assert_eq!(n3, "Pedro Silva");
assert_eq!(e1, "pedro.silva@researchco.example");
assert_eq!(e2, "pedro.silva@researchco.example");
assert_eq!(e3, "pedro.silva@researchco.example");
}
#[test]
fn duetto_contractors_config_resolves() {
let tmpdir = tempfile::TempDir::new().expect("create tmp");
let tmp = tmpdir.path();
let aliases_yaml = r#"
developers:
- name: "Pedro Silva"
primary_email: "pedro.silva@researchco.example"
aliases:
- "100000001+pedrosilvaextern@users.noreply.github.com"
- name: "Rohan Mehta"
primary_email: "rohan.mehta@researchco.example"
aliases:
- "Rohan.Mehta-c@researchco.example"
- "rohan-extern"
- name: "Kiran Anand Kumar Nayak"
primary_email: "kiran.nayak@researchco.example"
aliases:
- "kirannayak-extern"
- "100000002+kirannayak-extern@users.noreply.github.com"
"#;
let aliases_path = tmp.join("aliases.yaml");
std::fs::write(&aliases_path, aliases_yaml).expect("write aliases");
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
aliases_path.to_string_lossy()
);
let config_path = tmp.join("duetto-contractors.yaml");
std::fs::write(&config_path, config_yaml).expect("write config");
let cfg =
crate::core::config::Config::load(&config_path).expect("load duetto-contractors yaml");
let r = IdentityResolver::from_config(&cfg);
let (n, _) = r.resolve("whoever", "pedro.silva@researchco.example");
assert_eq!(n, "Pedro Silva");
let (n, _) = r.resolve("whoever", "Rohan.Mehta-c@researchco.example");
assert_eq!(n, "Rohan Mehta");
let (n, _) = r.resolve("kirannayak-extern", "noise@nowhere.test");
assert_eq!(n, "Kiran Anand Kumar Nayak");
}
#[test]
fn normalize_for_fuzzy_basic() {
assert_eq!(normalize_for_fuzzy("Bob.Matsuoka"), "bob matsuoka");
assert_eq!(normalize_for_fuzzy("alice_smith-c"), "alice smith c");
assert_eq!(normalize_for_fuzzy(" Foo Bar "), "foo bar");
}
#[test]
fn email_local_part_basic() {
assert_eq!(email_local_part("Bob@Example.COM"), "bob");
assert_eq!(email_local_part("no-at-symbol"), "no-at-symbol");
}
#[test]
fn email_domain_matches_basic() {
assert!(email_domain_matches(
"a@RESEARCHCO.EXAMPLE",
"researchco.example"
));
assert!(email_domain_matches(
"a@researchco.example",
"@researchco.example"
));
assert!(!email_domain_matches("a@other.com", "researchco.example"));
assert!(!email_domain_matches("invalid-email", "researchco.example"));
assert!(!email_domain_matches("a@researchco.example", ""));
}
#[test]
fn canonical_domain_prefers_org_email_for_team_member() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Alice Org".into(),
email: "alice@researchco.example".into(),
aliases: vec!["alice@personal.com".into()],
}],
aliases: HashMap::new(),
canonical_domain: Some("researchco.example".into()),
};
let r = IdentityResolver::new(Some(&team));
let (_, e) = r.resolve("Alice Org", "alice@personal.com");
assert_eq!(e, "alice@researchco.example");
assert_eq!(r.canonical_domain(), Some("researchco.example"));
}
#[test]
fn a_merged_away_email_routes_to_its_canonical_row() {
use crate::core::db::Database;
use rusqlite::params;
let db = Database::open_in_memory().expect("open db");
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email, aliases) \
VALUES ('Alice', 'alice@corp.com', ?1)",
params![r#"["alice@personal.com"]"#],
)
.expect("seed destination");
let dst: i64 = db
.connection()
.query_row(
"SELECT id FROM authors WHERE canonical_email = 'alice@corp.com'",
[],
|r| r.get(0),
)
.expect("dst id");
let r = IdentityResolver::new(None);
let id = r
.upsert_author(&db, "Alice", "alice@personal.com")
.expect("upsert");
assert_eq!(id, dst, "the alias must route to its canonical identity");
let upper = r
.upsert_author(&db, "Alice", "Alice@Personal.com")
.expect("upsert");
assert_eq!(upper, dst);
let rows: i64 = db
.connection()
.query_row("SELECT COUNT(*) FROM authors", [], |r| r.get(0))
.expect("count");
assert_eq!(rows, 1, "no row may be re-created for a merged-away email");
let other = r
.upsert_author(&db, "Bob", "not-alice@personal.com.example")
.expect("upsert");
assert_ne!(other, dst, "a substring match must not claim the identity");
}
#[test]
fn configured_canonical_domain_normalises_the_value() {
use crate::collect::identity::resolver::configured_canonical_domain;
use crate::core::config::Config;
let mut config = Config::default();
assert_eq!(configured_canonical_domain(&config), None, "no team block");
config.team = Some(TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some(" @Corp.COM ".into()),
});
assert_eq!(
configured_canonical_domain(&config),
Some("corp.com".to_string())
);
config.team = Some(TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some(" ".into()),
});
assert_eq!(
configured_canonical_domain(&config),
None,
"empty is absent"
);
}
#[test]
fn canonical_domain_routes_new_personal_email_to_existing_org_row() {
use crate::core::db::Database;
use rusqlite::params;
let team = TeamConfig {
members: vec![],
aliases: HashMap::new(),
canonical_domain: Some("researchco.example".into()),
};
let r = IdentityResolver::new(Some(&team));
let db = Database::open_in_memory().expect("db");
let _ = r
.upsert_author(&db, "Bob Matsuoka", "bob@researchco.example")
.expect("seed");
let id = r
.upsert_author(&db, "Bob Matsuoka", "bob@personal.com")
.expect("upsert");
let stored_email: String = db
.connection()
.query_row(
"SELECT canonical_email FROM authors WHERE id = ?1",
params![id],
|row| row.get(0),
)
.expect("lookup");
assert_eq!(stored_email, "bob@researchco.example");
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_name = 'Bob Matsuoka'",
[],
|row| row.get(0),
)
.expect("count");
assert_eq!(count, 1);
}
#[test]
fn canonical_domain_absent_falls_back_to_first_seen_email() {
use crate::core::db::Database;
let r = IdentityResolver::new(None);
assert_eq!(r.canonical_domain(), None);
let db = Database::open_in_memory().expect("db");
let _ = r
.upsert_author(&db, "Carol", "carol@personal.com")
.expect("seed");
let _ = r
.upsert_author(&db, "Carol", "carol@work.com")
.expect("upsert");
let count: i64 = db
.connection()
.query_row(
"SELECT COUNT(*) FROM authors WHERE canonical_name = 'Carol'",
[],
|row| row.get(0),
)
.expect("count");
assert_eq!(count, 2);
}
#[test]
fn email_domain_basic() {
assert_eq!(email_domain("ops+snyk@Duetto.COM"), "duetto.com");
assert_eq!(email_domain("no-at-symbol"), "");
assert_eq!(email_domain("weird@name@example.org"), "example.org");
}
#[test]
fn tier3_does_not_merge_bots_sharing_domain_suffix() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Jenkins CI".into(),
email: "jenkins@duettoresearch.com".into(),
aliases: vec![],
}],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
assert!(
jaro_winkler("ops+snyk@duettoresearch.com", "jenkins@duettoresearch.com")
>= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity should exceed the threshold"
);
let (name, email) = r.resolve("Snyk Bot", "ops+snyk@duettoresearch.com");
assert_ne!(
name, "Jenkins CI",
"Snyk bot must not be misattributed to Jenkins CI (#2253)"
);
assert_eq!(name, "Snyk Bot");
assert_eq!(email, "ops+snyk@duettoresearch.com");
}
#[test]
fn tier3_still_matches_same_domain_near_identical_local_parts() {
let team = TeamConfig {
members: vec![TeamMember {
name: "Alice Cooper".into(),
email: "alice.cooper@acme.com".into(),
aliases: vec![],
}],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
let (name, email) = r.resolve("acoopr", "alice.coopr@acme.com");
assert_eq!(name, "Alice Cooper");
assert_eq!(email, "alice.cooper@acme.com");
}
const ISSUE_4251_ALIASES_YAML: &str = r#"
developers:
- name: "Crisandra Fonseca"
primary_email: "crisandra.fonseca@researchco.example"
aliases: []
- name: "Nico Mercer"
primary_email: "nico.mercer@researchco.example"
aliases: []
- name: "Marcel Sutton"
primary_email: "marcel.sutton@researchco.example"
aliases: []
- name: "Joshua Kepler"
primary_email: "joshua.kepler@researchco.example"
aliases: []
- name: "Joshua McFarland"
primary_email: "joshua.mcfarland@researchco.example"
aliases: []
- name: "Rohan Mehta"
primary_email: "rohan.mehta@researchco.example"
aliases:
- "Rohan.Mehta-c@researchco.example"
- "rohan-extern"
- name: "Pedro Silva"
primary_email: "pedro.silva@researchco.example"
aliases:
- "100000001+pedrosilvaextern@users.noreply.github.com"
"#;
const ISSUE_4251_MISATTRIBUTIONS: &[(&str, &str, &str)] = &[
(
"Crispian Alvarenga",
"crispian.alvarenga@researchco.example",
"Crisandra Fonseca",
),
(
"Nico Vanderbergheim",
"nico.vanderbergheim@researchco.example",
"Nico Mercer",
),
(
"Marci Dalton",
"marci.dalton@researchco.example",
"Marcel Sutton",
),
("Josh Dorsey", "josh@researchco.example", "Joshua Kepler"),
("Joseph Wu", "joseph.wu@researchco.example", "Joshua Kepler"),
];
fn resolver_from_aliases_file(extra_config_yaml: &str) -> (IdentityResolver, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let aliases_path = tmp.path().join("aliases.yaml");
std::fs::write(&aliases_path, ISSUE_4251_ALIASES_YAML).unwrap();
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n{extra_config_yaml}",
aliases_path.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
(IdentityResolver::from_config(&cfg), tmp)
}
fn assert_roster_loaded(r: &IdentityResolver) {
assert_eq!(
r.resolve("whoever", "crisandra.fonseca@researchco.example"),
(
"Crisandra Fonseca".to_string(),
"crisandra.fonseca@researchco.example".to_string()
),
"non-vacuity: the roster must actually be loaded, otherwise a \
pass-through result proves nothing"
);
assert_eq!(
r.resolve("rohan-extern", "noise@nowhere.test").0,
"Rohan Mehta",
"non-vacuity: declared login-handle aliases must be present"
);
}
#[test]
fn aliases_file_disables_tier34_name_fuzzy() {
let (r, _tmp) = resolver_from_aliases_file("");
assert!(
!r.fuzzy_fallback(),
"supplying an aliases_file must disable the fuzzy fallback"
);
assert_roster_loaded(&r);
let mut observed: Vec<String> = Vec::new();
let mut expected: Vec<String> = Vec::new();
for (name, email, wrong_target) in ISSUE_4251_MISATTRIBUTIONS {
let raw_name = jaro_winkler(&name.to_lowercase(), &wrong_target.to_lowercase());
let norm_local = jaro_winkler(
&normalize_for_fuzzy(&email_local_part(email)),
&normalize_for_fuzzy(wrong_target),
);
assert!(
raw_name >= DEFAULT_SIMILARITY_THRESHOLD
|| norm_local >= NORMALIZED_SIMILARITY_THRESHOLD,
"precondition: {name} vs {wrong_target} must be fuzzy-reachable \
(tier3 name={raw_name:.4}, tier4 normalized={norm_local:.4})"
);
let (resolved_name, resolved_email) = r.resolve(name, email);
observed.push(format!(
"{name} <{email}> => {resolved_name} <{resolved_email}>"
));
expected.push(format!("{name} <{email}> => {name} <{email}>"));
}
assert_eq!(
observed, expected,
"#4251: undeclared authors must pass through, not be guessed onto the \
nearest-spelled roster member"
);
}
#[test]
fn aliases_file_gate_preserves_tier12_declared_resolutions() {
let (r, _tmp) = resolver_from_aliases_file("");
let (n, e) = r.resolve("whoever", "Rohan.Mehta-c@researchco.example");
assert_eq!(n, "Rohan Mehta");
assert_eq!(e, "rohan.mehta@researchco.example");
let (n, e) = r.resolve("rohan-extern", "noise@nowhere.test");
assert_eq!(n, "Rohan Mehta");
assert_eq!(e, "rohan.mehta@researchco.example");
let (n, e) = r.resolve(
"pedrosilvaextern",
"100000001+pedrosilvaextern@users.noreply.github.com",
);
assert_eq!(n, "Pedro Silva");
assert_eq!(e, "pedro.silva@researchco.example");
let (n, e) = r.resolve("whoever", "CRISANDRA.FONSECA@RESEARCHCO.EXAMPLE");
assert_eq!(n, "Crisandra Fonseca");
assert_eq!(e, "crisandra.fonseca@researchco.example");
}
#[test]
fn explicit_opt_in_reenables_fuzzy_with_aliases_file() {
let (r, _tmp) = resolver_from_aliases_file("fuzzy_identity_fallback: true\n");
assert!(
r.fuzzy_fallback(),
"explicit opt-in must re-enable Tier 3/4"
);
assert_roster_loaded(&r);
let (n, _) = r.resolve("Marci Dalton", "marci.dalton@researchco.example");
assert_eq!(n, "Marcel Sutton");
}
#[test]
#[should_panic(expected = "non-vacuity")]
fn non_vacuity_control_rejects_zero_member_resolver() {
let empty: HashMap<String, Vec<String>> = HashMap::new();
let r = IdentityResolver::from_alias_map(&empty);
assert_eq!(
r.resolve(
"Crispian Alvarenga",
"crispian.alvarenga@researchco.example"
),
(
"Crispian Alvarenga".to_string(),
"crispian.alvarenga@researchco.example".to_string()
)
);
assert_roster_loaded(&r); }
#[test]
fn broken_aliases_file_keeps_fuzzy_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist.yaml");
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
missing.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
assert!(cfg.resolved_alias_map(cfg.config_dir()).is_err());
assert!(cfg.resolved_aliases().is_empty());
let r = IdentityResolver::from_config(&cfg);
assert!(
r.fuzzy_fallback(),
"a declared aliases_file that failed to load must not be treated as a \
comprehensive alias table"
);
}
#[test]
fn empty_aliases_file_keeps_fuzzy_enabled() {
let tmp = tempfile::TempDir::new().unwrap();
let aliases_path = tmp.path().join("aliases.yaml");
std::fs::write(&aliases_path, "developers: []\n").unwrap();
let config_yaml = format!(
"version: \"1.0\"\naliases_file: \"{}\"\n",
aliases_path.to_string_lossy()
);
let config_path = tmp.path().join("config.yaml");
std::fs::write(&config_path, config_yaml).unwrap();
let cfg = crate::core::config::Config::load(&config_path).unwrap();
assert!(cfg.resolved_alias_map(cfg.config_dir()).unwrap().is_empty());
let r = IdentityResolver::from_config(&cfg);
assert!(r.fuzzy_fallback());
}
#[test]
fn inline_developer_aliases_do_not_disable_fuzzy() {
let yaml = r#"
version: "1.0"
developer_aliases:
"Marcel Sutton":
- "marcel.sutton@researchco.example"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
assert!(
!cfg.resolved_aliases().is_empty(),
"inline map must be loaded"
);
let r = IdentityResolver::from_config(&cfg);
assert!(
r.fuzzy_fallback(),
"#4251 gates on aliases_file, not on any non-empty alias map"
);
let (n, _) = r.resolve("Marci Dalton", "marci.dalton@researchco.example");
assert_eq!(n, "Marcel Sutton");
}
#[test]
fn explicit_opt_out_disables_fuzzy_without_aliases_file() {
let yaml = r#"
version: "1.0"
fuzzy_identity_fallback: false
developer_aliases:
"Marcel Sutton":
- "marcel.sutton@researchco.example"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
let r = IdentityResolver::from_config(&cfg);
assert!(!r.fuzzy_fallback());
let (n, e) = r.resolve("Marci Dalton", "marci.dalton@researchco.example");
assert_eq!(n, "Marci Dalton");
assert_eq!(e, "marci.dalton@researchco.example");
}
#[test]
fn no_aliases_file_keeps_fuzzy_enabled_by_default() {
let yaml = r#"
version: "1.0"
developer_aliases:
"Bob Matsuoka":
- "bob.matsuoka@researchco.example"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).unwrap();
let r = IdentityResolver::from_config(&cfg);
assert!(r.fuzzy_fallback());
let (n, _) = r.resolve("Bob M", "bob.matsuoka@otherdomain.com");
assert_eq!(n, "Bob Matsuoka");
}
#[test]
fn with_fuzzy_fallback_false_suppresses_tier34() {
let r = IdentityResolver::new(Some(&make_team()));
assert_eq!(
r.resolve("Bob Smyth", "unknown@elsewhere.com").0,
"Bob Smith"
);
let r = IdentityResolver::new(Some(&make_team())).with_fuzzy_fallback(false);
let (n, e) = r.resolve("Bob Smyth", "unknown@elsewhere.com");
assert_eq!(n, "Bob Smyth");
assert_eq!(e, "unknown@elsewhere.com");
assert_eq!(r.resolve("bobby", "x@y.com").0, "Bob Smith");
}
#[test]
fn issue_2253_domain_gate_intact_when_fuzzy_enabled() {
let team = TeamConfig {
members: vec![
TeamMember {
name: "Jenkins CI".into(),
email: "jenkins@duettoresearch.com".into(),
aliases: vec![],
},
TeamMember {
name: "Alice Cooper".into(),
email: "alice.cooper@acme.com".into(),
aliases: vec![],
},
],
aliases: HashMap::new(),
canonical_domain: None,
};
let r = IdentityResolver::new(Some(&team));
assert!(r.fuzzy_fallback(), "no aliases_file → fuzzy stays on");
assert!(
jaro_winkler("ops+snyk@duettoresearch.com", "jenkins@duettoresearch.com")
>= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: full-string similarity clears the threshold"
);
let (n, e) = r.resolve("Snyk Bot", "ops+snyk@duettoresearch.com");
assert_eq!(n, "Snyk Bot");
assert_eq!(e, "ops+snyk@duettoresearch.com");
let (n, e) = r.resolve("acoopr", "alice.coopr@acme.com");
assert_eq!(n, "Alice Cooper");
assert_eq!(e, "alice.cooper@acme.com");
}
#[test]
fn canonical_domain_read_from_config() {
let yaml = r#"
team:
canonical_domain: "researchco.example"
members:
- name: "Alice"
email: "alice@researchco.example"
"#;
let cfg: crate::core::config::Config = serde_yaml::from_str(yaml).expect("parse");
let r = IdentityResolver::from_config(&cfg);
assert_eq!(r.canonical_domain(), Some("researchco.example"));
}
#[test]
fn member_order_is_deterministic_across_rebuilds() {
fn build() -> IdentityResolver {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for (name, email) in [
("Joshua Kepler", "joshua.kepler@acme.com"),
("Joshua Mcfarland", "joshua.mcfarland@acme.com"),
("Joshua Renner", "joshua.renner@acme.com"),
("Joshua Vance", "joshua.vance@acme.com"),
("Joshua Whitlock", "joshua.whitlock@acme.com"),
("Joshua Ackley", "joshua.ackley@acme.com"),
] {
map.insert(name.to_string(), vec![email.to_string()]);
}
IdentityResolver::from_alias_map(&map)
}
let expected_members: Vec<(String, String)> = [
("Joshua Ackley", "joshua.ackley@acme.com"),
("Joshua Kepler", "joshua.kepler@acme.com"),
("Joshua Mcfarland", "joshua.mcfarland@acme.com"),
("Joshua Renner", "joshua.renner@acme.com"),
("Joshua Vance", "joshua.vance@acme.com"),
("Joshua Whitlock", "joshua.whitlock@acme.com"),
]
.iter()
.map(|(n, e)| ((*n).to_string(), (*e).to_string()))
.collect();
let expected_resolution = build().resolve("josh", "josh@unaffiliated.test");
for i in 0..100 {
let r = build();
assert_eq!(
r.members, expected_members,
"members order differs on rebuild {i}"
);
assert_eq!(
r.resolve("josh", "josh@unaffiliated.test"),
expected_resolution,
"resolution differs on rebuild {i}"
);
}
}
#[test]
fn fuzzy_tie_breaks_on_stable_key() {
fn team(order: [(&str, &str); 2]) -> TeamConfig {
TeamConfig {
members: order
.iter()
.map(|(n, e)| TeamMember {
name: (*n).to_string(),
email: (*e).to_string(),
aliases: vec![],
})
.collect(),
aliases: HashMap::new(),
canonical_domain: None,
}
}
let a = ("Sam Taylor", "a.taylor@acme.com");
let b = ("Sam Taylor", "b.taylor@acme.com");
let inbound_name = "Sam Taylorr";
let inbound_email = "sam@unaffiliated.test";
let score_a = jaro_winkler(&inbound_name.to_lowercase(), &a.0.to_lowercase());
let score_b = jaro_winkler(&inbound_name.to_lowercase(), &b.0.to_lowercase());
assert_eq!(score_a, score_b, "precondition: scores must tie exactly");
assert!(
score_a >= DEFAULT_SIMILARITY_THRESHOLD,
"precondition: the tied score must clear the Tier-3 threshold"
);
for order in [[a, b], [b, a]] {
let r = IdentityResolver::new(Some(&team(order)));
let (n, e) = r.resolve(inbound_name, inbound_email);
assert_eq!(n, "Sam Taylor");
assert_eq!(
e, "a.taylor@acme.com",
"tie must go to the lowest (email, name) key; declared order was {order:?}"
);
}
}
#[test]
fn alias_collision_resolves_deterministically() {
fn build() -> IdentityResolver {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
map.insert(
"Jane Roe".to_string(),
vec!["jane.roe@acme.com".into(), "jr".into()],
);
map.insert(
"John Roe".to_string(),
vec!["john.roe@acme.com".into(), "jr".into()],
);
IdentityResolver::from_alias_map(&map)
}
for i in 0..200 {
let (n, e) = build().resolve("jr", "jr@unaffiliated.test");
assert_eq!(n, "Jane Roe", "alias collision flipped on rebuild {i}");
assert_eq!(
e, "jane.roe@acme.com",
"alias collision flipped on rebuild {i}"
);
}
}
fn colliding_team_resolver() -> IdentityResolver {
let mut aliases = HashMap::new();
aliases.insert("JR2".to_string(), "John Roe".to_string());
aliases.insert("jr2".to_string(), "Jane Roe".to_string());
let team = TeamConfig {
members: vec![
TeamMember {
name: "Jane Roe".into(),
email: "jane.roe@acme.com".into(),
aliases: vec!["jr".into()],
},
TeamMember {
name: "John Roe".into(),
email: "john.roe@acme.com".into(),
aliases: vec!["jr".into()],
},
],
aliases,
canonical_domain: None,
};
IdentityResolver::new(Some(&team))
}
#[test]
fn team_member_alias_collision_goes_to_the_first_claimant() {
for i in 0..200 {
let (n, e) = colliding_team_resolver().resolve("jr", "jr@unaffiliated.test");
assert_eq!(
n, "Jane Roe",
"member alias collision flipped on rebuild {i}"
);
assert_eq!(
e, "jane.roe@acme.com",
"member alias collision flipped on rebuild {i}"
);
}
}
#[test]
fn team_free_form_alias_case_collision_goes_to_the_first_claimant() {
for i in 0..200 {
let (n, e) = colliding_team_resolver().resolve("jr2", "jr2@unaffiliated.test");
assert_eq!(
n, "John Roe",
"team.aliases case collision flipped on rebuild {i}"
);
assert_eq!(
e, "john.roe@acme.com",
"team.aliases case collision flipped on rebuild {i}"
);
}
}