diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee65278b..22216181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@master with: - toolchain: 1.77.0 + toolchain: 1.78.0 components: clippy, rustfmt - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh diff --git a/.gitignore b/.gitignore index f2e4eb07..e9dcf6a1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ crates/wasm/overlay/data crates/wasm/overlay/dist crates/wasm/overlay/sources +lcov.info npm-debug.log* target target-wasm diff --git a/Cargo.lock b/Cargo.lock index a0746ccc..5efe0678 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,16 @@ dependencies = [ "nom", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-recursion" version = "1.0.5" @@ -837,6 +847,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1059,6 +1079,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1871,10 +1897,13 @@ dependencies = [ "itertools 0.12.1", "lazy_static", "markdown", + "mockito", + "pretty_assertions", "regex", "reqwest 0.12.4", "serde", "serde_yaml", + "tokio", "tracing", "url", ] @@ -2065,6 +2094,25 @@ dependencies = [ "syn 2.0.57", ] +[[package]] +name = "mockito" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6e023aa5bdf392aa06c78e4a4e6d498baab5138d0c993503350ebbc37bf1e" +dependencies = [ + "assert-json-diff", + "colored", + "futures-core", + "hyper 0.14.28", + "log", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -2446,6 +2494,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -3182,6 +3240,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" + [[package]] name = "simple_asn1" version = "0.6.2" @@ -3493,6 +3557,7 @@ dependencies = [ "libc", "mio", "num_cpus", + "parking_lot 0.12.1", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4276,6 +4341,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zerocopy" version = "0.7.32" diff --git a/Cargo.toml b/Cargo.toml index 620eb8e2..a5a30d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,11 @@ markdown = "1.0.0-alpha.17" md-5 = "0.10.6" mime_guess = "2.0.4" mockall = "0.12.1" +mockito = "1.4.0" num_cpus = "1.16.0" octorust = "0.7.0" parse_link_header = "0.3.3" +pretty_assertions = "1.4.0" qrcode = "0.14.0" regex = "1.10.4" reqwest = { version = "0.12.4", features = ["json", "native-tls-vendored"] } diff --git a/crates/cli/src/build/export.rs b/crates/cli/src/build/export.rs index c60bb91c..fd4be8d7 100644 --- a/crates/cli/src/build/export.rs +++ b/crates/cli/src/build/export.rs @@ -97,26 +97,26 @@ impl From<&data::Item> for Item { // Crunchbase values if let Some(organization) = &di.crunchbase_data { - item.crunchbase_city = organization.city.clone(); - item.crunchbase_country = organization.country.clone(); - item.crunchbase_description = organization.description.clone(); - item.crunchbase_homepage = organization.homepage_url.clone(); - item.crunchbase_kind = organization.kind.clone(); - item.crunchbase_linkedin = organization.linkedin_url.clone(); + item.crunchbase_city.clone_from(&organization.city); + item.crunchbase_country.clone_from(&organization.country); + item.crunchbase_description.clone_from(&organization.description); + item.crunchbase_homepage.clone_from(&organization.homepage_url); + item.crunchbase_kind.clone_from(&organization.kind); + item.crunchbase_linkedin.clone_from(&organization.linkedin_url); item.crunchbase_max_employees = organization.num_employees_max; item.crunchbase_min_employees = organization.num_employees_min; - item.crunchbase_region = organization.region.clone(); - item.crunchbase_ticker = organization.ticker.clone(); - item.crunchbase_twitter = organization.twitter_url.clone(); + item.crunchbase_region.clone_from(&organization.region); + item.crunchbase_ticker.clone_from(&organization.ticker); + item.crunchbase_twitter.clone_from(&organization.twitter_url); item.funding = organization.funding; - item.organization = organization.name.clone(); + item.organization.clone_from(&organization.name); } // Twitter if di.twitter_url.is_some() { - item.twitter = di.twitter_url.clone(); + item.twitter.clone_from(&di.twitter_url); } else if item.crunchbase_twitter.is_some() { - item.twitter = item.crunchbase_twitter.clone(); + item.twitter.clone_from(&item.crunchbase_twitter); } // Relation diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index fd58dbfe..5eea284e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -23,3 +23,8 @@ serde = { workspace = true } serde_yaml = { workspace = true } tracing = { workspace = true } url = { workspace = true } + +[dev-dependencies] +mockito = { workspace = true } +pretty_assertions = { workspace = true } +tokio = { workspace = true } diff --git a/crates/core/src/data.rs b/crates/core/src/data.rs index 406f1776..2f1e57cb 100644 --- a/crates/core/src/data.rs +++ b/crates/core/src/data.rs @@ -43,10 +43,10 @@ pub type GithubData = BTreeMap; pub type RepositoryUrl = String; /// Type alias to represent a subcategory name. -pub type SubCategoryName = String; +pub type SubcategoryName = String; /// Landscape data source. -#[derive(Args, Default)] +#[derive(Args, Default, Debug, Clone, PartialEq)] #[group(required = true, multiple = false)] pub struct DataSource { /// Landscape data file local path. @@ -207,7 +207,7 @@ impl LandscapeData { } /// Add items member subcategory. - #[cfg_attr(feature = "instrument", instrument(skip_all))] + #[instrument(skip_all)] pub fn add_member_subcategory(&mut self, members_category: &Option) { let Some(members_category) = members_category else { return; @@ -232,7 +232,7 @@ impl LandscapeData { } /// Add projects items TAG based on the TAGs settings. - #[cfg_attr(feature = "instrument", instrument(skip_all))] + #[instrument(skip_all)] pub fn add_tags(&mut self, settings: &LandscapeSettings) { let Some(tags) = &settings.tags else { return; @@ -299,7 +299,7 @@ impl From for LandscapeData { // Subcategories for legacy_subcategory in legacy_category.subcategories { - category.subcategories.push(SubCategory { + category.subcategories.push(Subcategory { name: legacy_subcategory.name.clone(), normalized_name: normalize_name(&legacy_subcategory.name), }); @@ -360,8 +360,8 @@ impl From for LandscapeData { repositories.push(Repository { url, branch: legacy_item.branch, + github_data: None, primary: Some(true), - ..Default::default() }); } if let Some(additional_repos) = legacy_item.additional_repos { @@ -369,8 +369,8 @@ impl From for LandscapeData { repositories.push(Repository { url: entry.repo_url, branch: entry.branch, + github_data: None, primary: Some(false), - ..Default::default() }); } } @@ -389,6 +389,7 @@ impl From for LandscapeData { item.clomonitor_name = extra.clomonitor_name; item.devstats_url = extra.dev_stats_url; item.discord_url = extra.discord_url; + item.docker_url = extra.docker_url; item.github_discussions_url = extra.github_discussions_url; item.gitter_url = extra.gitter_url; item.graduated_at = extra.graduated; @@ -413,9 +414,10 @@ impl From for LandscapeData { integration: extra.summary_integration, integrations: extra.summary_integrations, intro_url: extra.summary_intro_url, + personas: None, release_rate: extra.summary_release_rate, + tags: None, use_case: extra.summary_use_case, - ..Default::default() }; if let Some(personas) = extra.summary_personas { let v: Vec = personas.split(',').map(|p| p.trim().to_string()).collect(); @@ -450,7 +452,7 @@ impl From for LandscapeData { pub struct Category { pub name: CategoryName, pub normalized_name: CategoryName, - pub subcategories: Vec, + pub subcategories: Vec, } impl From<&settings::Category> for Category { @@ -461,7 +463,7 @@ impl From<&settings::Category> for Category { subcategories: settings_category .subcategories .iter() - .map(|sc| SubCategory { + .map(|sc| Subcategory { name: sc.clone(), normalized_name: normalize_name(sc), }) @@ -472,9 +474,9 @@ impl From<&settings::Category> for Category { /// Landscape subcategory. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct SubCategory { - pub name: SubCategoryName, - pub normalized_name: SubCategoryName, +pub struct Subcategory { + pub name: SubcategoryName, + pub normalized_name: SubcategoryName, } /// Landscape item (project, product, member, etc). @@ -678,7 +680,7 @@ pub struct Acquisition { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct AdditionalCategory { pub category: CategoryName, - pub subcategory: SubCategoryName, + pub subcategory: SubcategoryName, } /// Commit information. @@ -863,3 +865,627 @@ pub struct RepositoryGithubData { #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{FeaturedItemRule, FeaturedItemRuleOption, TagRule}; + + #[test] + fn datasource_new_from_url() { + let url = "https://example.url/data.yml"; + let src = DataSource::new_from_url(url.to_string()); + assert_eq!( + src, + DataSource { + data_file: None, + data_url: Some(url.to_string()), + } + ); + } + + #[tokio::test] + async fn landscape_data_new_using_file() { + let src = DataSource { + data_file: Some(PathBuf::from("src/testdata/data.yml")), + data_url: None, + }; + let _ = LandscapeData::new(&src).await.unwrap(); + } + + #[tokio::test] + async fn landscape_data_new_using_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/data.yml") + .with_status(200) + .with_body_from_file("src/testdata/data.yml") + .create_async() + .await; + + let src = DataSource::new_from_url(format!("{}/data.yml", server.url())); + let _ = LandscapeData::new(&src).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "data file or url not provided")] + async fn landscape_data_new_no_file_or_url_provided() { + let src = DataSource::default(); + let _ = LandscapeData::new(&src).await.unwrap(); + } + + #[test] + fn landscape_data_new_from_file() { + let file = Path::new("src/testdata/data.yml"); + let _ = LandscapeData::new_from_file(file).unwrap(); + } + + #[tokio::test] + async fn landscape_data_new_from_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/data.yml") + .with_status(200) + .with_body_from_file("src/testdata/data.yml") + .create_async() + .await; + + let url = format!("{}/data.yml", server.url()); + let _ = LandscapeData::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "unexpected status code getting landscape data file: 404")] + async fn landscape_data_new_from_url_not_found() { + let mut server = mockito::Server::new_async().await; + let mock = server.mock("GET", "/data.yml").with_status(404).create_async().await; + + let url = format!("{}/data.yml", server.url()); + let _ = LandscapeData::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[test] + fn landscape_data_new_from_raw_data() { + let raw_data = fs::read_to_string("src/testdata/data.yml").unwrap(); + let _ = LandscapeData::new_from_raw_data(&raw_data).unwrap(); + } + + #[test] + fn landscape_data_add_crunchbase_data() { + let mut landscape_data = LandscapeData::default(); + let crunchbase_url = "https://crunchbase.url/test".to_string(); + landscape_data.items.push(Item { + crunchbase_url: Some(crunchbase_url.clone()), + ..Default::default() + }); + + let mut crunchbase_data = CrunchbaseData::default(); + let org = Organization { + name: Some("test".to_string()), + ..Default::default() + }; + crunchbase_data.insert(crunchbase_url, org.clone()); + + landscape_data.add_crunchbase_data(&crunchbase_data); + assert_eq!(landscape_data.items[0].crunchbase_data, Some(org)); + } + + #[test] + fn landscape_data_add_featured_items_data_maturity() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + maturity: Some("graduated".to_string()), + ..Default::default() + }); + + let settings = LandscapeSettings { + featured_items: Some(vec![FeaturedItemRule { + field: "maturity".to_string(), + options: vec![FeaturedItemRuleOption { + value: "graduated".to_string(), + label: Some("Graduated".to_string()), + order: Some(1), + }], + }]), + ..Default::default() + }; + + landscape_data.add_featured_items_data(&settings); + assert_eq!( + landscape_data.items[0].featured, + Some(ItemFeatured { + label: Some("Graduated".to_string()), + order: Some(1) + }) + ); + } + + #[test] + fn landscape_data_add_featured_items_data_subcategory() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + subcategory: "Subcategory".to_string(), + ..Default::default() + }); + + let settings = LandscapeSettings { + featured_items: Some(vec![FeaturedItemRule { + field: "subcategory".to_string(), + options: vec![FeaturedItemRuleOption { + value: "Subcategory".to_string(), + label: Some("VIP category".to_string()), + order: Some(1), + }], + }]), + ..Default::default() + }; + + landscape_data.add_featured_items_data(&settings); + assert_eq!( + landscape_data.items[0].featured, + Some(ItemFeatured { + label: Some("VIP category".to_string()), + order: Some(1) + }) + ); + } + + #[test] + fn landscape_data_add_github_data() { + let mut landscape_data = LandscapeData::default(); + let repository_url = "https://repo.url/test".to_string(); + let repository = Repository { + url: repository_url.clone(), + primary: Some(true), + ..Default::default() + }; + landscape_data.items.push(Item { + repositories: Some(vec![repository.clone()]), + ..Default::default() + }); + + let mut github_data = GithubData::default(); + let repository_github_data = RepositoryGithubData { + description: "test".to_string(), + license: Some("Apache-2.0".to_string()), + ..Default::default() + }; + github_data.insert(repository_url.clone(), repository_github_data.clone()); + + landscape_data.add_github_data(&github_data); + assert_eq!( + landscape_data.items[0].repositories, + Some(vec![Repository { + github_data: Some(repository_github_data), + ..repository + }]) + ); + assert_eq!(landscape_data.items[0].oss, Some(true)); + } + + #[test] + fn landscape_data_add_member_subcategory() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + category: "Members".to_string(), + subcategory: "Member subcategory".to_string(), + crunchbase_url: Some("https://crunchbase.url/test".to_string()), + ..Default::default() + }); + landscape_data.items.push(Item { + category: "Other category".to_string(), + crunchbase_url: Some("https://crunchbase.url/test".to_string()), + ..Default::default() + }); + + landscape_data.add_member_subcategory(&Some("Members".to_string())); + assert_eq!( + landscape_data.items[1].member_subcategory, + Some("Member subcategory".to_string()) + ); + } + + #[test] + fn landscape_data_add_tags_category_match() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + category: "Category".to_string(), + maturity: Some("graduated".to_string()), + ..Default::default() + }); + + let mut tags = BTreeMap::new(); + tags.insert( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + subcategories: Some(vec![]), + }], + ); + let settings = LandscapeSettings { + tags: Some(tags), + ..Default::default() + }; + + landscape_data.add_tags(&settings); + assert_eq!(landscape_data.items[0].tag, Some("tag1".to_string())); + } + + #[test] + fn landscape_data_add_tags_subcategory_match() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + category: "Category".to_string(), + subcategory: "Subcategory".to_string(), + maturity: Some("graduated".to_string()), + ..Default::default() + }); + + let mut tags = BTreeMap::new(); + tags.insert( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + subcategories: Some(vec!["Subcategory".to_string()]), + }], + ); + let settings = LandscapeSettings { + tags: Some(tags), + ..Default::default() + }; + + landscape_data.add_tags(&settings); + assert_eq!(landscape_data.items[0].tag, Some("tag1".to_string())); + } + + #[test] + fn landscape_data_add_tags_no_project() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + category: "Category".to_string(), + ..Default::default() + }); + + let mut tags = BTreeMap::new(); + tags.insert( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + ..Default::default() + }], + ); + let settings = LandscapeSettings { + tags: Some(tags), + ..Default::default() + }; + + landscape_data.add_tags(&settings); + assert_eq!(landscape_data.items[0].tag, None); + } + + #[test] + fn landscape_data_add_tags_item_tag_already_set() { + let mut landscape_data = LandscapeData::default(); + landscape_data.items.push(Item { + category: "Category".to_string(), + maturity: Some("graduated".to_string()), + tag: Some("tag2".to_string()), + ..Default::default() + }); + + let mut tags = BTreeMap::new(); + tags.insert( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + ..Default::default() + }], + ); + let settings = LandscapeSettings { + tags: Some(tags), + ..Default::default() + }; + + landscape_data.add_tags(&settings); + assert_eq!(landscape_data.items[0].tag, Some("tag2".to_string())); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn landscape_data_from_legacy_data() { + let date = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(); + let legacy_data = legacy::LandscapeData { + landscape: vec![legacy::Category { + name: "Category".to_string(), + subcategories: vec![legacy::SubCategory { + name: "Subcategory".to_string(), + items: vec![legacy::Item { + name: "Item".to_string(), + homepage_url: "homepage_url".to_string(), + logo: "logo".to_string(), + additional_repos: Some(vec![legacy::Repository { + repo_url: "additional_repo_url".to_string(), + branch: Some("branch".to_string()), + }]), + branch: Some("branch".to_string()), + crunchbase: Some("crunchbase_url".to_string()), + description: Some("description".to_string()), + enduser: Some(false), + extra: Some(legacy::ItemExtra { + accepted: Some(date), + archived: Some(date), + audits: Some(vec![ItemAudit { + date, + kind: "kind".to_string(), + url: "url".to_string(), + vendor: "vendor".to_string(), + }]), + annual_review_date: Some(date), + annual_review_url: Some("annual_review_url".to_string()), + artwork_url: Some("artwork_url".to_string()), + blog_url: Some("blog_url".to_string()), + chat_channel: Some("chat_channel".to_string()), + clomonitor_name: Some("clomonitor_name".to_string()), + dev_stats_url: Some("dev_stats_url".to_string()), + discord_url: Some("discord_url".to_string()), + docker_url: Some("docker_url".to_string()), + github_discussions_url: Some("github_discussions_url".to_string()), + gitter_url: Some("gitter_url".to_string()), + graduated: Some(date), + incubating: Some(date), + linkedin_url: Some("linkedin_url".to_string()), + mailing_list_url: Some("mailing_list_url".to_string()), + package_manager_url: Some("package_manager_url".to_string()), + parent_project: Some("parent_project".to_string()), + slack_url: Some("slack_url".to_string()), + specification: Some(false), + stack_overflow_url: Some("stack_overflow_url".to_string()), + summary_business_use_case: Some("summary_business_use_case".to_string()), + summary_integration: Some("summary_integration".to_string()), + summary_integrations: Some("summary_integrations".to_string()), + summary_intro_url: Some("summary_intro_url".to_string()), + summary_use_case: Some("summary_use_case".to_string()), + summary_personas: Some("summary_personas".to_string()), + summary_release_rate: Some("summary_release_rate".to_string()), + summary_tags: Some("tag1,tag2".to_string()), + tag: Some("tag".to_string()), + training_certifications: Some("training_certifications".to_string()), + training_type: Some("training_type".to_string()), + youtube_url: Some("youtube_url".to_string()), + }), + joined: Some(date), + project: Some("graduated".to_string()), + repo_url: Some("repo_url".to_string()), + second_path: Some(vec!["category2/subcategory2.1".to_string()]), + twitter: Some("twitter_url".to_string()), + url_for_bestpractices: Some("url_for_bestpractices".to_string()), + unnamed_organization: Some(false), + }], + }], + }], + }; + + let expected_landscape_data = LandscapeData { + categories: vec![Category { + name: "Category".to_string(), + normalized_name: "category".to_string(), + subcategories: vec![Subcategory { + name: "Subcategory".to_string(), + normalized_name: "subcategory".to_string(), + }], + }], + items: vec![Item { + category: "Category".to_string(), + homepage_url: "homepage_url".to_string(), + id: "category--subcategory--item".to_string(), + logo: "logo".to_string(), + name: "Item".to_string(), + subcategory: "Subcategory".to_string(), + accepted_at: Some(date), + additional_categories: Some(vec![AdditionalCategory { + category: "category2".to_string(), + subcategory: "subcategory2.1".to_string(), + }]), + archived_at: Some(date), + artwork_url: Some("artwork_url".to_string()), + audits: Some(vec![ItemAudit { + date, + kind: "kind".to_string(), + url: "url".to_string(), + vendor: "vendor".to_string(), + }]), + blog_url: Some("blog_url".to_string()), + chat_channel: Some("chat_channel".to_string()), + clomonitor_name: Some("clomonitor_name".to_string()), + clomonitor_report_summary: None, + crunchbase_data: None, + crunchbase_url: Some("crunchbase_url".to_string()), + description: Some("description".to_string()), + devstats_url: Some("dev_stats_url".to_string()), + discord_url: Some("discord_url".to_string()), + docker_url: Some("docker_url".to_string()), + enduser: Some(false), + featured: None, + github_discussions_url: Some("github_discussions_url".to_string()), + gitter_url: Some("gitter_url".to_string()), + graduated_at: Some(date), + incubating_at: Some(date), + joined_at: Some(date), + linkedin_url: Some("linkedin_url".to_string()), + mailing_list_url: Some("mailing_list_url".to_string()), + maturity: Some("graduated".to_string()), + member_subcategory: None, + latest_annual_review_at: Some(date), + latest_annual_review_url: Some("annual_review_url".to_string()), + openssf_best_practices_url: Some("url_for_bestpractices".to_string()), + oss: None, + package_manager_url: Some("package_manager_url".to_string()), + parent_project: Some("parent_project".to_string()), + repositories: Some(vec![ + Repository { + url: "repo_url".to_string(), + branch: Some("branch".to_string()), + github_data: None, + primary: Some(true), + }, + Repository { + url: "additional_repo_url".to_string(), + branch: Some("branch".to_string()), + github_data: None, + primary: Some(false), + }, + ]), + slack_url: Some("slack_url".to_string()), + specification: Some(false), + stack_overflow_url: Some("stack_overflow_url".to_string()), + summary: Some(ItemSummary { + business_use_case: Some("summary_business_use_case".to_string()), + integration: Some("summary_integration".to_string()), + integrations: Some("summary_integrations".to_string()), + intro_url: Some("summary_intro_url".to_string()), + personas: Some("summary_personas".split(',').map(|p| p.trim().to_string()).collect()), + release_rate: Some("summary_release_rate".to_string()), + tags: Some(vec!["tag1".to_string(), "tag2".to_string()]), + use_case: Some("summary_use_case".to_string()), + }), + tag: Some("tag".to_string()), + training_certifications: Some("training_certifications".to_string()), + training_type: Some("training_type".to_string()), + twitter_url: Some("twitter_url".to_string()), + unnamed_organization: Some(false), + youtube_url: Some("youtube_url".to_string()), + }], + }; + + let landscape_data = LandscapeData::from(legacy_data); + pretty_assertions::assert_eq!(landscape_data, expected_landscape_data); + } + + #[test] + fn category_from_settings_category() { + let settings_category = settings::Category { + name: "Category".to_string(), + subcategories: vec!["Subcategory".to_string()], + }; + + let category = Category::from(&settings_category); + assert_eq!( + category, + Category { + name: "Category".to_string(), + normalized_name: "category".to_string(), + subcategories: vec![Subcategory { + name: "Subcategory".to_string(), + normalized_name: "subcategory".to_string(), + }], + } + ); + } + + #[test] + fn item_description() { + let item = Item { + description: Some("item description".to_string()), + repositories: Some(vec![Repository { + github_data: Some(RepositoryGithubData { + description: "repository description".to_string(), + ..Default::default() + }), + primary: Some(true), + ..Default::default() + }]), + crunchbase_data: Some(Organization { + description: Some("crunchbase description".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(item.description(), Some(&"item description".to_string())); + } + + #[test] + fn item_description_from_repository() { + let item = Item { + repositories: Some(vec![Repository { + github_data: Some(RepositoryGithubData { + description: "repository description".to_string(), + ..Default::default() + }), + primary: Some(true), + ..Default::default() + }]), + crunchbase_data: Some(Organization { + description: Some("crunchbase description".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(item.description(), Some(&"repository description".to_string())); + } + + #[test] + fn item_description_from_crunchbase() { + let item = Item { + crunchbase_data: Some(Organization { + description: Some("crunchbase description".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!(item.description(), Some(&"crunchbase description".to_string())); + } + + #[test] + fn item_primary_repository_found() { + let item = Item { + repositories: Some(vec![ + Repository { + url: "repo1".to_string(), + primary: Some(false), + ..Default::default() + }, + Repository { + url: "repo2".to_string(), + primary: Some(true), + ..Default::default() + }, + ]), + ..Default::default() + }; + + assert_eq!(item.primary_repository().unwrap().url, "repo2".to_string()); + } + + #[test] + fn item_primary_repository_not_found() { + let item = Item { + repositories: Some(vec![Repository { + url: "repo1".to_string(), + primary: Some(false), + ..Default::default() + }]), + ..Default::default() + }; + + assert!(item.primary_repository().is_none()); + } + + #[test] + fn item_set_id() { + let mut item = Item { + category: "Category".to_string(), + subcategory: "Subcategory".to_string(), + name: "Item".to_string(), + ..Default::default() + }; + + item.set_id(); + assert_eq!(item.id, "category--subcategory--item".to_string()); + } +} diff --git a/crates/core/src/data/legacy.rs b/crates/core/src/data/legacy.rs index fe0a7b21..290fe8bc 100644 --- a/crates/core/src/data/legacy.rs +++ b/crates/core/src/data/legacy.rs @@ -55,7 +55,7 @@ impl LandscapeData { // Check name if item.name.is_empty() { - return Err(format_err!("name is required")).context(ctx); + return Err(format_err!("item name is required")).context(ctx); } if items_seen.contains(&item.name) { return Err(format_err!("duplicate item name")).context(ctx); @@ -64,7 +64,7 @@ impl LandscapeData { // Check homepage if item.homepage_url.is_empty() { - return Err(format_err!("hompage_url is required")).context(ctx); + return Err(format_err!("homepage url is required")).context(ctx); } // Check logo @@ -231,3 +231,186 @@ fn validate_urls(item: &Item) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn landscape_data_validate_succeeds() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: "Item".to_string(), + homepage_url: "https://example.com".to_string(), + logo: "logo".to_string(), + additional_repos: Some(vec![Repository { + repo_url: "https://repo.url".to_string(), + ..Default::default() + }]), + extra: Some(ItemExtra { + audits: Some(vec![ItemAudit { + url: "https://audit.url".to_string(), + ..Default::default() + }]), + blog_url: Some("https://blog.url".to_string()), + ..Default::default() + }), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "category [0] name is required")] + fn landscape_data_validate_empty_category_name() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: String::new(), + subcategories: vec![], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "subcategory [0] name is required")] + fn landscape_data_validate_empty_subcategory_name() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: String::new(), + items: vec![], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "item name is required")] + fn landscape_data_validate_empty_item_name() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: String::new(), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "duplicate item name")] + fn landscape_data_validate_duplicate_item_name() { + let mut landscape = LandscapeData::default(); + let item = Item { + name: "Item".to_string(), + homepage_url: "https://example.com".to_string(), + logo: "logo".to_string(), + ..Default::default() + }; + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![item.clone(), item], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "homepage url is required")] + fn landscape_data_validate_empty_homepage_url() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: "Item".to_string(), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "logo is required")] + fn landscape_data_validate_empty_logo() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: "Item".to_string(), + homepage_url: "https://example.com".to_string(), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid tag")] + fn landscape_data_validate_invalid_tag() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: "Item".to_string(), + homepage_url: "https://example.com".to_string(), + logo: "logo".to_string(), + extra: Some(ItemExtra { + tag: Some("Invalid Tag".to_string()), + ..Default::default() + }), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid homepage url")] + fn landscape_data_validate_invalid_url() { + let mut landscape = LandscapeData::default(); + landscape.landscape.push(Category { + name: "Category".to_string(), + subcategories: vec![SubCategory { + name: "Subcategory".to_string(), + items: vec![Item { + name: "Item".to_string(), + homepage_url: "homepage_url".to_string(), + logo: "logo".to_string(), + ..Default::default() + }], + }], + }); + + landscape.validate().unwrap(); + } +} diff --git a/crates/core/src/datasets.rs b/crates/core/src/datasets.rs index 20131ba7..b30816cc 100644 --- a/crates/core/src/datasets.rs +++ b/crates/core/src/datasets.rs @@ -14,8 +14,19 @@ use crate::{ stats::Stats, }; -/// Datasets collection. +/// Input used to create a new Datasets instance. #[derive(Debug, Clone)] +pub struct NewDatasetsInput<'a> { + pub crunchbase_data: &'a CrunchbaseData, + pub github_data: &'a GithubData, + pub guide: &'a Option, + pub landscape_data: &'a LandscapeData, + pub qr_code: &'a String, + pub settings: &'a LandscapeSettings, +} + +/// Datasets collection. +#[derive(Debug, Clone, Default, PartialEq)] pub struct Datasets { /// #[base] pub base: Base, @@ -43,17 +54,6 @@ impl Datasets { } } -/// Input used to create a new Datasets instance. -#[derive(Debug, Clone)] -pub struct NewDatasetsInput<'a> { - pub crunchbase_data: &'a CrunchbaseData, - pub github_data: &'a GithubData, - pub guide: &'a Option, - pub landscape_data: &'a LandscapeData, - pub qr_code: &'a String, - pub settings: &'a LandscapeSettings, -} - /// Base dataset. /// /// This dataset contains the minimal data the web application needs to render @@ -359,3 +359,285 @@ pub mod full { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + data::{self, *}, + datasets::base, + guide::{self, LandscapeGuide}, + settings::{self, *}, + }; + use chrono::{NaiveDate, Utc}; + use tests::embed::EmbedView; + + #[test] + fn datasets_new() { + let input = NewDatasetsInput { + crunchbase_data: &CrunchbaseData::default(), + github_data: &GithubData::default(), + guide: &None, + landscape_data: &LandscapeData::default(), + qr_code: &String::default(), + settings: &LandscapeSettings::default(), + }; + + let datasets = Datasets::new(&input); + assert_eq!(datasets, Datasets::default()); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn base_new() { + // Landscape data + let item = data::Item { + category: "Category 1".to_string(), + crunchbase_data: Some(Organization { + generated_at: Utc::now(), + acquisitions: Some(vec![]), + funding_rounds: Some(vec![FundingRound { + amount: Some(1000), + ..Default::default() + }]), + ..Default::default() + }), + homepage_url: "https://homepage.url".to_string(), + id: "id".to_string(), + logo: "logo.svg".to_string(), + name: "Item".to_string(), + subcategory: "Subcategory 1".to_string(), + ..Default::default() + }; + let landscape_data = LandscapeData { + categories: vec![data::Category { + name: "Category 1".to_string(), + normalized_name: "category-1".to_string(), + subcategories: vec![ + Subcategory { + name: "Subcategory 1".to_string(), + normalized_name: "subcategory-1".to_string(), + }, + Subcategory { + name: "Subcategory 2".to_string(), + normalized_name: "subcategory-2".to_string(), + }, + ], + }], + items: vec![item.clone()], + }; + + // Settings + let colors = Some(Colors { + color1: "rgb(100, 100, 100)".to_string(), + ..Default::default() + }); + let footer = Some(Footer { + text: Some("Footer text".to_string()), + ..Default::default() + }); + let groups = vec![Group { + name: "Group 1".to_string(), + normalized_name: Some("group-1".to_string()), + categories: vec!["Category 1".to_string()], + }]; + let header = Some(Header { + logo: Some("https://logo.url".to_string()), + ..Default::default() + }); + let images = Some(Images { + favicon: Some("https://favicon.url".to_string()), + ..Default::default() + }); + let upcoming_event = UpcomingEvent { + name: "Event".to_string(), + start: NaiveDate::from_ymd_opt(2024, 5, 2).unwrap(), + end: NaiveDate::from_ymd_opt(2024, 5, 3).unwrap(), + banner_url: "https://banner.url".to_string(), + details_url: "https://details.url".to_string(), + }; + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + base_path: Some("/base/path".to_string()), + categories: Some(vec![settings::Category { + name: "Category 1".to_string(), + subcategories: vec!["Subcategory 1".to_string()], + }]), + colors: colors.clone(), + footer: footer.clone(), + grid_items_size: Some(GridItemsSize::Small), + groups: Some(groups.clone()), + header: header.clone(), + images: images.clone(), + members_category: Some("Members".to_string()), + upcoming_event: Some(upcoming_event.clone()), + view_mode: Some(ViewMode::Grid), + ..Default::default() + }; + + // Guide + let guide = LandscapeGuide { + categories: Some(vec![guide::Category { + category: "Category 1".to_string(), + subcategories: Some(vec![guide::Subcategory { + subcategory: "Subcategory 1".to_string(), + ..Default::default() + }]), + ..Default::default() + }]), + }; + + // QR code + let qr_code = "QR_CODE".to_string(); + + let base = Base::new(&landscape_data, &settings, &Some(guide), &qr_code); + let expected_base = Base { + finances_available: true, + foundation: "Foundation".to_string(), + qr_code: "QR_CODE".to_string(), + base_path: Some("/base/path".to_string()), + categories: vec![data::Category { + name: "Category 1".to_string(), + normalized_name: "category-1".to_string(), + subcategories: vec![Subcategory { + name: "Subcategory 1".to_string(), + normalized_name: "subcategory-1".to_string(), + }], + }], + categories_overridden: vec!["Category 1".to_string()], + colors, + footer, + grid_items_size: Some(GridItemsSize::Small), + groups, + guide_summary: vec![("Category 1".to_string(), vec!["Subcategory 1".to_string()])] + .into_iter() + .collect(), + header, + images, + items: vec![(&item).into()], + members_category: Some("Members".to_string()), + upcoming_event: Some(upcoming_event), + view_mode: Some(ViewMode::Grid), + }; + pretty_assertions::assert_eq!(base, expected_base); + } + + #[test] + fn base_item_from_data_item() { + let data_item = data::Item { + additional_categories: Some(vec![AdditionalCategory { + category: "Category 2".to_string(), + subcategory: "Subcategory 3".to_string(), + }]), + category: "Category 1".to_string(), + featured: Some(ItemFeatured { + label: Some("label".to_string()), + order: Some(1), + }), + id: "id".to_string(), + logo: "logo.svg".to_string(), + maturity: Some("graduated".to_string()), + name: "Item".to_string(), + oss: Some(true), + subcategory: "Subcategory 1".to_string(), + tag: Some("tag1".to_string()), + ..Default::default() + }; + + let item = base::Item::from(&data_item); + let expected_item = base::Item { + additional_categories: Some(vec![AdditionalCategory { + category: "Category 2".to_string(), + subcategory: "Subcategory 3".to_string(), + }]), + category: "Category 1".to_string(), + featured: Some(ItemFeatured { + label: Some("label".to_string()), + order: Some(1), + }), + id: "id".to_string(), + logo: "logo.svg".to_string(), + maturity: Some("graduated".to_string()), + name: "Item".to_string(), + oss: Some(true), + subcategory: "Subcategory 1".to_string(), + tag: Some("tag1".to_string()), + }; + pretty_assertions::assert_eq!(item, expected_item); + } + + #[test] + fn embed_new() { + let item = data::Item { + category: "Category 1".to_string(), + homepage_url: "https://homepage.url".to_string(), + id: "id".to_string(), + logo: "logo.svg".to_string(), + name: "Item".to_string(), + subcategory: "Subcategory 1".to_string(), + ..Default::default() + }; + let landscape_data = LandscapeData { + categories: vec![data::Category { + name: "Category 1".to_string(), + normalized_name: "category-1".to_string(), + subcategories: vec![Subcategory { + name: "Subcategory 1".to_string(), + normalized_name: "subcategory-1".to_string(), + }], + }], + items: vec![item.clone()], + }; + + let embed = Embed::new(&landscape_data); + let expected_embed_view = EmbedView { + category: data::Category { + name: "Category 1".to_string(), + normalized_name: "category-1".to_string(), + subcategories: vec![Subcategory { + name: "Subcategory 1".to_string(), + normalized_name: "subcategory-1".to_string(), + }], + }, + items: vec![(&item).into()], + }; + let expected_embed = embed::Embed { + views: vec![ + ("category-1".to_string(), expected_embed_view.clone()), + ("category-1--subcategory-1".to_string(), expected_embed_view), + ] + .into_iter() + .collect(), + }; + pretty_assertions::assert_eq!(embed, expected_embed); + } + + #[test] + fn full_new() { + let item = data::Item { + category: "Category 1".to_string(), + homepage_url: "https://homepage.url".to_string(), + id: "id".to_string(), + logo: "logo.svg".to_string(), + name: "Item".to_string(), + subcategory: "Subcategory 1".to_string(), + ..Default::default() + }; + let landscape_data = LandscapeData { + categories: vec![], + items: vec![item.clone()], + }; + let mut crunchbase_data = CrunchbaseData::default(); + crunchbase_data.insert("https:://crunchbase.url".to_string(), Organization::default()); + let mut github_data = GithubData::default(); + github_data.insert("https:://github.url".to_string(), RepositoryGithubData::default()); + + let full = Full::new(&landscape_data, &crunchbase_data, &github_data); + let expected_full = Full { + crunchbase_data, + github_data, + items: vec![item], + }; + pretty_assertions::assert_eq!(full, expected_full); + } +} diff --git a/crates/core/src/guide.rs b/crates/core/src/guide.rs index dacdb13a..1ff7e5a6 100644 --- a/crates/core/src/guide.rs +++ b/crates/core/src/guide.rs @@ -12,7 +12,7 @@ use std::{ use tracing::{debug, instrument}; /// Landscape guide source. -#[derive(Args, Default)] +#[derive(Args, Default, Debug, Clone, PartialEq)] #[group(required = false, multiple = false)] pub struct GuideSource { /// Landscape guide file local path. @@ -208,3 +208,201 @@ pub struct Subcategory { #[serde(skip_serializing_if = "Option::is_none")] pub keywords: Option>, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guidesource_new_from_url() { + let url = "https://example.url/guide.yml"; + let src = GuideSource::new_from_url(url.to_string()); + assert_eq!( + src, + GuideSource { + guide_file: None, + guide_url: Some(url.to_string()), + } + ); + } + + #[tokio::test] + async fn guide_new_using_file() { + let src = GuideSource { + guide_file: Some(PathBuf::from("src/testdata/guide.yml")), + guide_url: None, + }; + let _ = LandscapeGuide::new(&src).await.unwrap(); + } + + #[tokio::test] + async fn guide_new_using_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/guide.yml") + .with_status(200) + .with_body_from_file("src/testdata/guide.yml") + .create_async() + .await; + + let src = GuideSource::new_from_url(format!("{}/guide.yml", server.url())); + let _ = LandscapeGuide::new(&src).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + async fn guide_new_no_file_or_url_provided() { + let src = GuideSource::default(); + assert!(LandscapeGuide::new(&src).await.unwrap().is_none()); + } + + #[test] + fn guide_new_from_file() { + let file = Path::new("src/testdata/guide.yml"); + let _ = LandscapeGuide::new_from_file(file).unwrap(); + } + + #[tokio::test] + async fn guide_new_from_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/guide.yml") + .with_status(200) + .with_body_from_file("src/testdata/guide.yml") + .create_async() + .await; + + let url = format!("{}/guide.yml", server.url()); + let _ = LandscapeGuide::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "unexpected status code getting landscape guide file: 404")] + async fn guide_new_from_url_not_found() { + let mut server = mockito::Server::new_async().await; + let mock = server.mock("GET", "/guide.yml").with_status(404).create_async().await; + + let url = format!("{}/guide.yml", server.url()); + let _ = LandscapeGuide::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[test] + fn guide_new_from_yaml() { + let raw_data = fs::read_to_string("src/testdata/guide.yml").unwrap(); + let _ = LandscapeGuide::new_from_yaml(&raw_data).unwrap(); + } + + #[test] + fn guide_validate_success() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + content: Some("content".to_string()), + keywords: Some(vec!["keyword".to_string()]), + subcategories: Some(vec![Subcategory { + subcategory: "subcategory".to_string(), + content: "content".to_string(), + keywords: Some(vec!["keyword".to_string()]), + }]), + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "category cannot be empty")] + fn guide_validate_empty_category() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: String::new(), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "content cannot be empty")] + fn guide_validate_empty_category_content() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + content: Some(String::new()), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "keywords cannot be empty")] + fn guide_validate_empty_category_keywords() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + keywords: Some(vec![String::new()]), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "subcategory cannot be empty")] + fn guide_validate_empty_subcategory() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + subcategories: Some(vec![Subcategory { + subcategory: String::new(), + ..Default::default() + }]), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "content cannot be empty")] + fn guide_validate_empty_subcategory_content() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + subcategories: Some(vec![Subcategory { + subcategory: "subcategory".to_string(), + content: String::new(), + ..Default::default() + }]), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "keywords cannot be empty")] + fn guide_validate_empty_subcategory_keywords() { + let guide = LandscapeGuide { + categories: Some(vec![Category { + category: "category".to_string(), + subcategories: Some(vec![Subcategory { + subcategory: "subcategory".to_string(), + content: "content".to_string(), + keywords: Some(vec![String::new()]), + }]), + ..Default::default() + }]), + }; + + guide.validate().unwrap(); + } +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 9f2d1d4d..9b82cecd 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -7,7 +7,7 @@ //! NOTE: the landscape settings file uses a new format that is not backwards //! compatible with the legacy settings file used by existing landscapes. -use super::data::{CategoryName, SubCategoryName}; +use super::data::{CategoryName, SubcategoryName}; use crate::util::{normalize_name, validate_url}; use anyhow::{bail, format_err, Context, Result}; use chrono::NaiveDate; @@ -24,7 +24,7 @@ use std::{ use tracing::{debug, instrument}; /// Landscape settings location. -#[derive(Args, Default)] +#[derive(Args, Default, Debug, Clone, PartialEq)] #[group(required = true, multiple = false)] pub struct SettingsSource { /// Landscape settings file local path. @@ -493,7 +493,7 @@ pub struct Analytics { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Category { pub name: CategoryName, - pub subcategories: Vec, + pub subcategories: Vec, } lazy_static! { @@ -669,7 +669,7 @@ pub struct TagRule { pub category: CategoryName, #[serde(skip_serializing_if = "Option::is_none")] - pub subcategories: Option>, + pub subcategories: Option>, } /// Upcoming event details. @@ -689,3 +689,736 @@ pub enum ViewMode { Grid, Card, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::SettingsSource; + + #[test] + fn settings_source_new_from_url() { + let url = "https://example.url/settings.yml"; + let src = SettingsSource::new_from_url(url.to_string()); + assert_eq!( + src, + SettingsSource { + settings_file: None, + settings_url: Some(url.to_string()), + } + ); + } + + #[tokio::test] + async fn settings_new_using_file() { + let src = SettingsSource { + settings_file: Some(PathBuf::from("src/testdata/settings.yml")), + settings_url: None, + }; + let _ = LandscapeSettings::new(&src).await.unwrap(); + } + + #[tokio::test] + async fn settings_new_using_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/settings.yml") + .with_status(200) + .with_body_from_file("src/testdata/settings.yml") + .create_async() + .await; + + let src = SettingsSource::new_from_url(format!("{}/settings.yml", server.url())); + let _ = LandscapeSettings::new(&src).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "settings file or url not provided")] + async fn settings_new_no_file_or_url_provided() { + let src = SettingsSource::default(); + let _ = LandscapeSettings::new(&src).await.unwrap(); + } + + #[test] + fn settings_new_from_file() { + let file = Path::new("src/testdata/settings.yml"); + let _ = LandscapeSettings::new_from_file(file).unwrap(); + } + + #[tokio::test] + async fn settings_new_from_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/settings.yml") + .with_status(200) + .with_body_from_file("src/testdata/settings.yml") + .create_async() + .await; + + let url = format!("{}/settings.yml", server.url()); + let _ = LandscapeSettings::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "unexpected status code getting landscape settings file: 404")] + async fn landscape_data_new_from_url_not_found() { + let mut server = mockito::Server::new_async().await; + let mock = server.mock("GET", "/settings.yml").with_status(404).create_async().await; + + let url = format!("{}/settings.yml", server.url()); + let _ = LandscapeSettings::new_from_url(&url).await.unwrap(); + mock.assert_async().await; + } + + #[test] + fn settings_new_from_raw_data() { + let raw_data = fs::read_to_string("src/testdata/settings.yml").unwrap(); + let _ = LandscapeSettings::new_from_raw_data(&raw_data).unwrap(); + } + + #[test] + fn settings_footer_text_to_html_works() { + let mut settings = LandscapeSettings { + footer: Some(Footer { + text: Some("# Footer text".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + settings.footer_text_to_html().unwrap(); + assert_eq!(settings.footer.unwrap().text.unwrap(), "

Footer text

"); + } + + #[test] + fn settings_remove_base_path_trailing_slash_works() { + let mut settings = LandscapeSettings { + base_path: Some("/base_path/".to_string()), + ..Default::default() + }; + + settings.remove_base_path_trailing_slash(); + assert_eq!(settings.base_path.unwrap(), "/base_path"); + } + + #[test] + fn settings_set_groups_normalized_name_works() { + let mut settings = LandscapeSettings { + groups: Some(vec![Group { + name: "Group 1".to_string(), + ..Default::default() + }]), + ..Default::default() + }; + + settings.set_groups_normalized_name(); + assert_eq!( + settings.groups.unwrap().first().unwrap().normalized_name.as_ref().unwrap(), + "group-1" + ); + } + + #[test] + fn settings_validate_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "foundation cannot be empty")] + fn settings_validate_empty_foundation() { + let settings = LandscapeSettings { + foundation: String::new(), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid landscape url")] + fn settings_validate_invalid_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "invalid-url".to_string(), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_base_path_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + base_path: Some("/base_path".to_string()), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "base_path cannot be empty")] + fn settings_validate_base_path_empty() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + base_path: Some(String::new()), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "base_path must start with a slash")] + fn settings_validate_base_path_no_slash() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + base_path: Some("base_path".to_string()), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_categories_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + categories: Some(vec![Category { + name: "Category".to_string(), + subcategories: vec!["Subcategory".to_string()], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "category [0] name cannot be empty")] + fn settings_validate_categories_empty_name() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + categories: Some(vec![Category { + name: String::new(), + subcategories: vec![], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "category [Category]: subcategory [0] cannot be empty")] + fn settings_validate_categories_empty_subcategory() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + categories: Some(vec![Category { + name: "Category".to_string(), + subcategories: vec![String::new()], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_colors_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + colors: Some(Colors { + color1: "rgba(0, 107, 204, 1)".to_string(), + color2: "rgba(0, 107, 204, 1)".to_string(), + color3: "rgba(0, 107, 204, 1)".to_string(), + color4: "rgba(0, 107, 204, 1)".to_string(), + color5: "rgba(0, 107, 204, 1)".to_string(), + color6: "rgba(0, 107, 204, 1)".to_string(), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "color1 is not valid (expected format: \"rgba(0, 107, 204, 1)\")")] + fn settings_validate_colors_invalid_format() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + colors: Some(Colors { + color1: "invalid-color".to_string(), + color2: "rgba(0, 107, 204, 1)".to_string(), + color3: "rgba(0, 107, 204, 1)".to_string(), + color4: "rgba(0, 107, 204, 1)".to_string(), + color5: "rgba(0, 107, 204, 1)".to_string(), + color6: "rgba(0, 107, 204, 1)".to_string(), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_featured_items_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + featured_items: Some(vec![FeaturedItemRule { + field: "Field".to_string(), + options: vec![FeaturedItemRuleOption { + value: "Value".to_string(), + label: Some("Label".to_string()), + order: Some(1), + }], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "field cannot be empty")] + fn settings_validate_featured_items_empty_field() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + featured_items: Some(vec![FeaturedItemRule { + field: String::new(), + options: vec![], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "options cannot be empty")] + fn settings_validate_featured_items_empty_options() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + featured_items: Some(vec![FeaturedItemRule { + field: "Field".to_string(), + options: vec![], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "option value cannot be empty")] + fn settings_validate_featured_items_empty_option_value() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + featured_items: Some(vec![FeaturedItemRule { + field: "Field".to_string(), + options: vec![FeaturedItemRuleOption { + value: String::new(), + ..Default::default() + }], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "option label cannot be empty")] + fn settings_validate_featured_items_empty_option_label() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + featured_items: Some(vec![FeaturedItemRule { + field: "Field".to_string(), + options: vec![FeaturedItemRuleOption { + value: "Value".to_string(), + label: Some(String::new()), + ..Default::default() + }], + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_footer_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + footer: Some(Footer { + links: Some(FooterLinks { + github: Some("https://github.com".to_string()), + ..Default::default() + }), + logo: Some("https://logo.url".to_string()), + text: Some("Footer text".to_string()), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid github url")] + fn settings_validate_footer_invalid_github_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + footer: Some(Footer { + links: Some(FooterLinks { + github: Some("invalid-url".to_string()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid footer logo url")] + fn settings_validate_footer_invalid_logo_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + footer: Some(Footer { + logo: Some("invalid-url".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "footer text cannot be empty")] + fn settings_validate_footer_empty_text() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + footer: Some(Footer { + text: Some(String::new()), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_groups_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + groups: Some(vec![Group { + name: "Group 1".to_string(), + categories: vec!["Category".to_string()], + ..Default::default() + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "group [0] name cannot be empty")] + fn settings_validate_groups_empty_name() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + groups: Some(vec![Group { + name: String::new(), + categories: vec![], + ..Default::default() + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "group [Group 1]: category [0] cannot be empty")] + fn settings_validate_groups_empty_category() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + groups: Some(vec![Group { + name: "Group 1".to_string(), + categories: vec![String::new()], + ..Default::default() + }]), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_header_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + header: Some(Header { + links: Some(HeaderLinks { + github: Some("https://github.com".to_string()), + }), + logo: Some("https://logo.url".to_string()), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid github url")] + fn settings_validate_header_invalid_github_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + header: Some(Header { + links: Some(HeaderLinks { + github: Some("invalid-url".to_string()), + }), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid header logo url")] + fn settings_validate_header_invalid_logo_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + header: Some(Header { + logo: Some("invalid-url".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_images_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + images: Some(Images { + favicon: Some("https://favicon.url".to_string()), + open_graph: Some("https://open-graph.url".to_string()), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "invalid favicon url")] + fn settings_validate_images_invalid_favicon_url() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + images: Some(Images { + favicon: Some("invalid-url".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_members_category_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + members_category: Some("Members".to_string()), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "members category cannot be empty")] + fn settings_validate_members_category_empty() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + members_category: Some(String::new()), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_osano_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + osano: Some(Osano { + customer_id: "customer_id".to_string(), + customer_configuration_id: "customer_configuration_id".to_string(), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "osano customer id cannot be empty")] + fn settings_validate_osano_empty_customer_id() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + osano: Some(Osano { + customer_id: String::new(), + ..Default::default() + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "osano customer configuration id cannot be empty")] + fn settings_validate_osano_empty_customer_configuration_id() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + osano: Some(Osano { + customer_id: "customer_id".to_string(), + customer_configuration_id: String::new(), + }), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_screenshot_width_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + screenshot_width: Some(2000), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "screenshot width must be greater than 1000")] + fn settings_validate_screenshot_width_invalid() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + screenshot_width: Some(1000), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + fn settings_validate_tags_succeeds() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + tags: Some(BTreeMap::from_iter(vec![( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + subcategories: Some(vec!["Subcategory".to_string()]), + }], + )])), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "tag [tag1] category cannot be empty")] + fn settings_validate_tags_empty_category() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + tags: Some(BTreeMap::from_iter(vec![( + "tag1".to_string(), + vec![TagRule { + category: String::new(), + subcategories: None, + }], + )])), + ..Default::default() + }; + + settings.validate().unwrap(); + } + + #[test] + #[should_panic(expected = "tag [tag1] subcategories cannot be empty")] + fn settings_validate_tags_empty_subcategories() { + let settings = LandscapeSettings { + foundation: "Foundation".to_string(), + url: "https://example.url".to_string(), + tags: Some(BTreeMap::from_iter(vec![( + "tag1".to_string(), + vec![TagRule { + category: "Category".to_string(), + subcategories: Some(vec![]), + }], + )])), + ..Default::default() + }; + + settings.validate().unwrap(); + } +} diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs index 449377b7..63ca6fba 100644 --- a/crates/core/src/stats.rs +++ b/crates/core/src/stats.rs @@ -2,7 +2,7 @@ //! as well as the functionality used to prepare them. use super::{ - data::{CategoryName, SubCategoryName}, + data::{CategoryName, SubcategoryName}, settings::{LandscapeSettings, TagName}, }; use crate::data::LandscapeData; @@ -325,7 +325,7 @@ pub struct CategoryProjectsStats { /// Number of projects per subcategory. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub subcategories: BTreeMap, + pub subcategories: BTreeMap, } /// Some stats about the repositories listed in the landscape. @@ -405,7 +405,7 @@ impl RepositoriesStats { // Participation stats if stats.participation_stats.is_empty() { - stats.participation_stats = gh_data.participation_stats.clone(); + stats.participation_stats.clone_from(&gh_data.participation_stats); } else { stats.participation_stats = stats .participation_stats @@ -460,9 +460,9 @@ const EXCLUDED_LANGUAGES: [&str; 7] = [ /// Helper function to increment the value of an entry in a map by the value /// provided if the entry exists, or insert a new entry with that value if it /// doesn't. -fn increment(map: &mut BTreeMap, key: &T, increment: u64) +fn increment(map: &mut BTreeMap, key: &T, increment: u64) where - T: std::hash::Hash + Eq + Clone, + T: std::hash::Hash + Ord + Eq + Clone, { if let Some(v) = map.get_mut(key) { *v += increment; @@ -483,3 +483,383 @@ fn calculate_running_total(map: &BTreeMap) -> BTreeMap) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_name_succeeds() { + assert_eq!(normalize_name("Hello World"), "hello-world"); + assert_eq!(normalize_name("Hello World"), "hello-world"); + assert_eq!(normalize_name("Hello.World"), "hello-world"); + assert_eq!(normalize_name("Hello--World"), "hello-world"); + assert_eq!(normalize_name("Hello World-"), "hello-world"); + } + + #[test] + fn validate_url_succeeds() { + validate_url( + "crunchbase", + &Some("https://www.crunchbase.com/organization/test".to_string()), + ) + .unwrap(); + + for (kind, domain) in &[ + ("facebook", "facebook.com"), + ("flickr", "flickr.com"), + ("github", "github.com"), + ("instagram", "instagram.com"), + ("linkedin", "linkedin.com"), + ("stack_overflow", "stackoverflow.com"), + ("twitch", "twitch.tv"), + ("twitter", "twitter.com"), + ("youtube", "youtube.com"), + ] { + validate_url(kind, &Some(format!("https://{domain}/test"))).unwrap(); + } + } + + #[test] + #[should_panic(expected = "relative URL without a base")] + fn validate_url_error_parsing() { + validate_url("", &Some("invalid url".to_string())).unwrap(); + } + + #[test] + #[should_panic(expected = "invalid scheme")] + fn validate_url_invalid_scheme() { + validate_url("", &Some("oci://hostname/path".to_string())).unwrap(); + } + + #[test] + #[should_panic(expected = "invalid crunchbase url")] + fn validate_url_invalid_crunchbase_url() { + validate_url("crunchbase", &Some("https://www.crunchbase.com/test".to_string())).unwrap(); + } + + #[test] + fn validate_url_invalid_domain() { + for (kind, domain) in &[ + ("facebook", "facebook.com"), + ("flickr", "flickr.com"), + ("github", "github.com"), + ("instagram", "instagram.com"), + ("linkedin", "linkedin.com"), + ("stack_overflow", "stackoverflow.com"), + ("twitch", "twitch.tv"), + ("twitter", "twitter.com"), + ("youtube", "youtube.com"), + ] { + assert_eq!( + validate_url(kind, &Some("https://example.com/test".to_string())).unwrap_err().to_string(), + format!("invalid {kind} url: expecting https://{domain}/...") + ); + } + } +}