diff --git a/src/domain/analytics/country_stats.rs b/src/domain/analytics/country_stats.rs index 71cba57..26e3505 100644 --- a/src/domain/analytics/country_stats.rs +++ b/src/domain/analytics/country_stats.rs @@ -50,3 +50,36 @@ impl GeoStats { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_counts_empty() { + let stats = GeoStats::from_counts(vec![]); + assert!(stats.entries.is_empty()); + assert_eq!(stats.total_countries, 0); + assert_eq!(stats.max_count, 0); + } + + #[test] + fn from_counts_known_country() { + let stats = GeoStats::from_counts(vec![("United States".to_string(), 5)]); + assert_eq!(stats.entries.len(), 1); + assert_eq!(stats.entries[0].iso_code, "US"); + assert!(!stats.entries[0].flag_emoji.is_empty()); + assert_eq!(stats.entries[0].count, 5); + assert_eq!(stats.total_countries, 1); + assert_eq!(stats.max_count, 5); + } + + #[test] + fn from_counts_unknown_country() { + let stats = GeoStats::from_counts(vec![("Atlantis".to_string(), 3)]); + assert_eq!(stats.entries.len(), 1); + assert_eq!(stats.entries[0].iso_code, ""); + assert_eq!(stats.entries[0].country_name, "Atlantis"); + assert_eq!(stats.entries[0].count, 3); + } +} diff --git a/src/domain/auth/registration_tokens.rs b/src/domain/auth/registration_tokens.rs index 9bf2f78..ffac531 100644 --- a/src/domain/auth/registration_tokens.rs +++ b/src/domain/auth/registration_tokens.rs @@ -49,3 +49,75 @@ impl NewRegistrationToken { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_valid() { + let now = Utc::now(); + let token = RegistrationToken { + id: RegistrationTokenId::new(1), + token_hash: "hash".to_string(), + created_at: now, + expires_at: now + Duration::hours(1), + used_at: None, + used_by_user_id: None, + }; + assert!(token.is_valid()); + } + + #[test] + fn token_expired() { + let now = Utc::now(); + let token = RegistrationToken { + id: RegistrationTokenId::new(1), + token_hash: "hash".to_string(), + created_at: now - Duration::hours(2), + expires_at: now - Duration::hours(1), + used_at: None, + used_by_user_id: None, + }; + assert!(!token.is_valid()); + assert!(token.is_expired()); + } + + #[test] + fn token_used() { + let now = Utc::now(); + let token = RegistrationToken { + id: RegistrationTokenId::new(1), + token_hash: "hash".to_string(), + created_at: now, + expires_at: now + Duration::hours(1), + used_at: Some(now), + used_by_user_id: Some(UserId::new(1)), + }; + assert!(!token.is_valid()); + assert!(token.is_used()); + } + + #[test] + fn token_expired_and_used() { + let now = Utc::now(); + let token = RegistrationToken { + id: RegistrationTokenId::new(1), + token_hash: "hash".to_string(), + created_at: now - Duration::hours(2), + expires_at: now - Duration::hours(1), + used_at: Some(now - Duration::minutes(30)), + used_by_user_id: Some(UserId::new(1)), + }; + assert!(!token.is_valid()); + } + + #[test] + fn new_token_clamps_excessive_duration() { + let now = Utc::now(); + let excessive_expires = now + Duration::days(30); + let token = NewRegistrationToken::new("hash".to_string(), now, excessive_expires); + let expected_max = now + MAX_TOKEN_DURATION; + assert_eq!(token.expires_at, expected_max); + } +} diff --git a/src/domain/auth/sessions.rs b/src/domain/auth/sessions.rs index a675333..57c3540 100644 --- a/src/domain/auth/sessions.rs +++ b/src/domain/auth/sessions.rs @@ -75,3 +75,43 @@ impl NewSession { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_not_expired() { + let now = Utc::now(); + let session = Session::new( + SessionId::new(1), + UserId::new(1), + "hash".to_string(), + now, + now + Duration::hours(1), + ); + assert!(!session.is_expired()); + } + + #[test] + fn session_expired() { + let now = Utc::now(); + let session = Session::new( + SessionId::new(1), + UserId::new(1), + "hash".to_string(), + now - Duration::hours(2), + now - Duration::hours(1), + ); + assert!(session.is_expired()); + } + + #[test] + fn new_session_clamps_excessive_duration() { + let now = Utc::now(); + let excessive_expires = now + Duration::days(60); + let session = NewSession::new(UserId::new(1), "hash".to_string(), now, excessive_expires); + let expected_max = now + MAX_SESSION_DURATION; + assert_eq!(session.expires_at, expected_max); + } +} diff --git a/src/domain/auth/tokens.rs b/src/domain/auth/tokens.rs index 31b9cb6..ea72f3f 100644 --- a/src/domain/auth/tokens.rs +++ b/src/domain/auth/tokens.rs @@ -71,3 +71,40 @@ impl NewToken { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_active_when_not_revoked() { + let now = Utc::now(); + let token = Token::new( + TokenId::new(1), + UserId::new(1), + "hash".to_string(), + "my-token".to_string(), + now, + None, + None, + ); + assert!(token.is_active()); + assert!(!token.is_revoked()); + } + + #[test] + fn token_revoked() { + let now = Utc::now(); + let token = Token::new( + TokenId::new(1), + UserId::new(1), + "hash".to_string(), + "my-token".to_string(), + now, + None, + Some(now), + ); + assert!(token.is_revoked()); + assert!(!token.is_active()); + } +} diff --git a/src/domain/auth/users.rs b/src/domain/auth/users.rs index e76ce30..086dd7b 100644 --- a/src/domain/auth/users.rs +++ b/src/domain/auth/users.rs @@ -55,3 +55,48 @@ impl NewUser { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_username_alphanumeric() { + assert!(is_valid_username("abc123")); + } + + #[test] + fn valid_username_with_underscores() { + assert!(is_valid_username("my_user")); + } + + #[test] + fn valid_username_with_hyphens() { + assert!(is_valid_username("my-user")); + } + + #[test] + fn username_too_short() { + assert!(!is_valid_username("ab")); + } + + #[test] + fn username_too_long() { + assert!(!is_valid_username(&"a".repeat(33))); + } + + #[test] + fn username_with_spaces() { + assert!(!is_valid_username("my user")); + } + + #[test] + fn username_min_length() { + assert!(is_valid_username("abc")); + } + + #[test] + fn username_max_length() { + assert!(is_valid_username(&"a".repeat(32))); + } +} diff --git a/src/domain/coffee/brews.rs b/src/domain/coffee/brews.rs index da061fa..685ba29 100644 --- a/src/domain/coffee/brews.rs +++ b/src/domain/coffee/brews.rs @@ -278,3 +278,50 @@ define_sort_key!(pub BrewSortKey { CoffeeWeight("coffee-weight", Desc), WaterVolume("water-volume", Desc), }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quick_note_roundtrip_form_value() { + for &variant in QuickNote::all() { + assert_eq!( + QuickNote::from_str_value(variant.form_value()), + Some(variant) + ); + } + } + + #[test] + fn quick_note_roundtrip_label() { + for &variant in QuickNote::all() { + assert_eq!(QuickNote::from_str_value(variant.label()), Some(variant)); + } + } + + #[test] + fn quick_note_unknown_returns_none() { + assert_eq!(QuickNote::from_str_value("invalid"), None); + } + + #[test] + fn quick_note_from_str_trait() { + assert_eq!("good".parse::(), Ok(QuickNote::Good)); + } + + #[test] + fn format_brew_time_minutes_seconds() { + assert_eq!(format_brew_time(150), "2:30"); + } + + #[test] + fn format_brew_time_zero() { + assert_eq!(format_brew_time(0), "0:00"); + } + + #[test] + fn format_brew_time_under_minute() { + assert_eq!(format_brew_time(45), "0:45"); + } +} diff --git a/src/domain/coffee/cafes.rs b/src/domain/coffee/cafes.rs index 11aff88..51603b8 100644 --- a/src/domain/coffee/cafes.rs +++ b/src/domain/coffee/cafes.rs @@ -102,3 +102,69 @@ define_sort_key!(pub CafeSortKey { City("city", Asc), Country("country", Asc), }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trims_fields() { + let cafe = NewCafe { + name: " Test Cafe ".to_string(), + city: " Portland ".to_string(), + country: " US ".to_string(), + latitude: 45.5, + longitude: -122.6, + website: None, + created_at: None, + }; + let normalized = cafe.normalize(); + assert_eq!(normalized.name, "Test Cafe"); + assert_eq!(normalized.city, "Portland"); + assert_eq!(normalized.country, "US"); + } + + #[test] + fn normalize_filters_bad_website() { + let cafe = NewCafe { + name: "Test Cafe".to_string(), + city: "Portland".to_string(), + country: "US".to_string(), + latitude: 45.5, + longitude: -122.6, + website: Some("javascript:alert(1)".to_string()), + created_at: None, + }; + let normalized = cafe.normalize(); + assert_eq!(normalized.website, None); + } + + #[test] + fn normalize_keeps_valid_website() { + let cafe = NewCafe { + name: "Test Cafe".to_string(), + city: "Portland".to_string(), + country: "US".to_string(), + latitude: 45.5, + longitude: -122.6, + website: Some("https://testcafe.com".to_string()), + created_at: None, + }; + let normalized = cafe.normalize(); + assert_eq!(normalized.website, Some("https://testcafe.com".to_string())); + } + + #[test] + fn slug_generation() { + let cafe = NewCafe { + name: "Test Cafe".to_string(), + city: "Portland".to_string(), + country: "US".to_string(), + latitude: 45.5, + longitude: -122.6, + website: None, + created_at: None, + }; + assert_eq!(cafe.slug(), "test-cafe-portland"); + } +} diff --git a/src/domain/coffee/gear.rs b/src/domain/coffee/gear.rs index cebf010..50685c5 100644 --- a/src/domain/coffee/gear.rs +++ b/src/domain/coffee/gear.rs @@ -129,3 +129,23 @@ define_sort_key!(pub GearSortKey { Model("model", Asc), Category("category", Asc), }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gear_category_from_str_valid() { + assert_eq!("grinder".parse::(), Ok(GearCategory::Grinder)); + } + + #[test] + fn gear_category_case_insensitive() { + assert_eq!("BREWER".parse::(), Ok(GearCategory::Brewer)); + } + + #[test] + fn gear_category_invalid() { + assert!("invalid".parse::().is_err()); + } +} diff --git a/src/domain/coffee/roasters.rs b/src/domain/coffee/roasters.rs index 7d0e959..873e58e 100644 --- a/src/domain/coffee/roasters.rs +++ b/src/domain/coffee/roasters.rs @@ -106,3 +106,111 @@ define_sort_key!(pub RoasterSortKey { Country("country", Asc), City("city", Asc), }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_http_scheme() { + assert!(is_valid_url_scheme("http://example.com")); + } + + #[test] + fn valid_https_scheme() { + assert!(is_valid_url_scheme("https://example.com")); + } + + #[test] + fn rejects_javascript_scheme() { + assert!(!is_valid_url_scheme("javascript:alert(1)")); + } + + #[test] + fn rejects_data_scheme() { + assert!(!is_valid_url_scheme("data:text/html,

hi

")); + } + + #[test] + fn case_insensitive_http() { + assert!(is_valid_url_scheme("HTTP://EXAMPLE.COM")); + } + + #[test] + fn rejects_ftp_scheme() { + assert!(!is_valid_url_scheme("ftp://example.com")); + } + + #[test] + fn rejects_empty_string() { + assert!(!is_valid_url_scheme("")); + } + + #[test] + fn trims_whitespace() { + assert!(is_valid_url_scheme(" https://example.com ")); + } + + #[test] + fn normalize_trims_name() { + let roaster = NewRoaster { + name: " Test ".to_string(), + country: "US".to_string(), + city: Some("Portland".to_string()), + homepage: Some("https://test.com".to_string()), + created_at: None, + }; + let normalized = roaster.normalize(); + assert_eq!(normalized.name, "Test"); + } + + #[test] + fn normalize_filters_bad_scheme() { + let roaster = NewRoaster { + name: "Test".to_string(), + country: "US".to_string(), + city: Some("Portland".to_string()), + homepage: Some("javascript:alert(1)".to_string()), + created_at: None, + }; + let normalized = roaster.normalize(); + assert_eq!(normalized.homepage, None); + } + + #[test] + fn normalize_keeps_valid_homepage() { + let roaster = NewRoaster { + name: "Test".to_string(), + country: "US".to_string(), + city: Some("Portland".to_string()), + homepage: Some("https://test.com".to_string()), + created_at: None, + }; + let normalized = roaster.normalize(); + assert_eq!(normalized.homepage, Some("https://test.com".to_string())); + } + + #[test] + fn slug_with_city() { + let roaster = NewRoaster { + name: "Test".to_string(), + country: "US".to_string(), + city: Some("Portland".to_string()), + homepage: Some("https://test.com".to_string()), + created_at: None, + }; + assert_eq!(roaster.slug(), "test-portland"); + } + + #[test] + fn slug_without_city() { + let roaster = NewRoaster { + name: "Test".to_string(), + country: "US".to_string(), + city: None, + homepage: Some("https://test.com".to_string()), + created_at: None, + }; + assert_eq!(roaster.slug(), "test"); + } +} diff --git a/src/domain/entity_type.rs b/src/domain/entity_type.rs index d7395d8..d99bc69 100644 --- a/src/domain/entity_type.rs +++ b/src/domain/entity_type.rs @@ -51,3 +51,44 @@ impl FromStr for EntityType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_VARIANTS: [EntityType; 7] = [ + EntityType::Roaster, + EntityType::Roast, + EntityType::Bag, + EntityType::Brew, + EntityType::Cup, + EntityType::Cafe, + EntityType::Gear, + ]; + + #[test] + fn as_str_roundtrip() { + for variant in ALL_VARIANTS { + assert_eq!(variant.as_str().parse::(), Ok(variant)); + } + } + + #[test] + fn display_matches_as_str() { + assert_eq!(format!("{}", EntityType::Brew), "brew"); + } + + #[test] + fn from_str_unknown_is_err() { + assert!("unknown".parse::().is_err()); + } + + #[test] + fn serde_roundtrip() { + for variant in ALL_VARIANTS { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: EntityType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, variant); + } + } +} diff --git a/src/domain/images.rs b/src/domain/images.rs index a90a9cf..846d532 100644 --- a/src/domain/images.rs +++ b/src/domain/images.rs @@ -45,3 +45,20 @@ impl fmt::Debug for ImageData { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_data_debug_redacts_some() { + let data: ImageData = serde_json::from_str(r#""data:image/png;base64,abc""#).unwrap(); + assert_eq!(format!("{data:?}"), "Some()"); + } + + #[test] + fn image_data_debug_shows_none() { + let data = ImageData::default(); + assert_eq!(format!("{data:?}"), "None"); + } +} diff --git a/src/domain/listing.rs b/src/domain/listing.rs index 656515d..da3f56e 100644 --- a/src/domain/listing.rs +++ b/src/domain/listing.rs @@ -288,3 +288,209 @@ impl Page { } } } + +#[cfg(test)] +mod tests { + use super::*; + + crate::define_sort_key!(TestSortKey { + #[default] + CreatedAt("created-at", Desc), + Name("name", Asc), + }); + + // --- PageSize tests --- + + #[test] + fn zero_becomes_all() { + assert_eq!(PageSize::limited(0), PageSize::All); + } + + #[test] + fn limited_returns_value() { + assert_eq!(PageSize::limited(10).as_option(), Some(10)); + } + + #[test] + fn all_has_no_option() { + assert_eq!(PageSize::All.as_option(), None); + } + + #[test] + fn all_is_all() { + assert!(PageSize::All.is_all()); + } + + // --- ListRequest::new tests --- + + #[test] + fn page_min_is_1() { + let req = ListRequest::new( + 0, + PageSize::Limited(10), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + assert_eq!(req.page, 1); + } + + #[test] + fn size_clamped_to_max() { + let req = ListRequest::new( + 1, + PageSize::Limited(51), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + assert_eq!(req.page_size, PageSize::Limited(MAX_PAGE_SIZE)); + } + + #[test] + fn zero_size_becomes_all() { + let req = ListRequest::new( + 1, + PageSize::Limited(0), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + assert_eq!(req.page_size, PageSize::All); + } + + // --- ListRequest::default_query tests --- + + #[test] + fn default_query_has_page_1() { + let req = ListRequest::::default_query(); + assert_eq!(req.page, 1); + } + + #[test] + fn default_query_has_default_page_size() { + let req = ListRequest::::default_query(); + assert_eq!(req.page_size, PageSize::Limited(DEFAULT_PAGE_SIZE)); + } + + #[test] + fn default_query_has_default_sort_key() { + let req = ListRequest::::default_query(); + assert_eq!(req.sort_key, TestSortKey::CreatedAt); + } + + #[test] + fn default_query_has_default_sort_direction() { + let req = ListRequest::::default_query(); + assert_eq!(req.sort_direction, SortDirection::Desc); + } + + // --- with_sort tests --- + + #[test] + fn with_sort_same_key_toggles_direction() { + let req = ListRequest::new( + 1, + PageSize::Limited(10), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + let toggled = req.with_sort(TestSortKey::CreatedAt); + assert_eq!(toggled.sort_direction, SortDirection::Asc); + } + + #[test] + fn with_sort_new_key_uses_default_direction() { + let req = ListRequest::new( + 1, + PageSize::Limited(10), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + let switched = req.with_sort(TestSortKey::Name); + assert_eq!(switched.sort_direction, SortDirection::Asc); + } + + // --- ensure_page_within tests --- + + #[test] + fn clamps_page_beyond_total() { + let req = ListRequest::new( + 10, + PageSize::Limited(10), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + let clamped = req.ensure_page_within(25); + assert_eq!(clamped.page, 3); + } + + #[test] + fn zero_total_returns_page_1() { + let req = ListRequest::new( + 5, + PageSize::Limited(10), + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + let clamped = req.ensure_page_within(0); + assert_eq!(clamped.page, 1); + } + + #[test] + fn all_mode_returns_page_1() { + let req = ListRequest::new( + 3, + PageSize::All, + TestSortKey::CreatedAt, + SortDirection::Desc, + ); + let clamped = req.ensure_page_within(100); + assert_eq!(clamped.page, 1); + } + + // --- Page tests --- + + #[test] + fn total_pages_calculation() { + let page: Page<()> = Page::new(vec![], 1, 10, 25, false); + assert_eq!(page.total_pages(), 3); + } + + #[test] + fn total_pages_zero_is_1() { + let page: Page<()> = Page::new(vec![], 1, 10, 0, false); + assert_eq!(page.total_pages(), 1); + } + + #[test] + fn has_previous_false_on_page_1() { + let page: Page<()> = Page::new(vec![], 1, 10, 30, false); + assert!(!page.has_previous()); + } + + #[test] + fn has_next_false_on_last_page() { + let page: Page<()> = Page::new(vec![], 3, 10, 30, false); + assert!(!page.has_next()); + } + + #[test] + fn start_end_index() { + let page: Page = Page::new(vec![1, 2, 3], 2, 10, 30, false); + assert_eq!(page.start_index(), 11); + assert_eq!(page.end_index(), 13); + } + + #[test] + fn start_end_index_zero_total() { + let page: Page<()> = Page::new(vec![], 1, 10, 0, false); + assert_eq!(page.start_index(), 0); + assert_eq!(page.end_index(), 0); + } + + #[test] + fn showing_all_single_page() { + let page: Page = Page::new(vec![1, 2, 3], 1, 3, 3, true); + assert_eq!(page.total_pages(), 1); + assert!(!page.has_previous()); + assert!(!page.has_next()); + } +}