From f7b210f234679f97fd4f6a31971df1eff7000031 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 13 Feb 2026 14:36:26 +0000 Subject: [PATCH] test: add unit tests for all reviewed domain modules Add 57 unit tests across auth (sessions, registration tokens, API tokens, username validation), coffee (roasters, cafes, brews, gear), listing (pagination, sort keys, page calculations), analytics (country stats), images (debug redaction), and entity type (roundtrip serialization). --- src/domain/analytics/country_stats.rs | 33 ++++ src/domain/auth/registration_tokens.rs | 72 +++++++++ src/domain/auth/sessions.rs | 40 +++++ src/domain/auth/tokens.rs | 37 +++++ src/domain/auth/users.rs | 45 ++++++ src/domain/coffee/brews.rs | 47 ++++++ src/domain/coffee/cafes.rs | 66 ++++++++ src/domain/coffee/gear.rs | 20 +++ src/domain/coffee/roasters.rs | 108 +++++++++++++ src/domain/entity_type.rs | 41 +++++ src/domain/images.rs | 17 ++ src/domain/listing.rs | 206 +++++++++++++++++++++++++ 12 files changed, 732 insertions(+) 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()); + } +}