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).
This commit is contained in:
Jon Seager 2026-02-13 14:36:26 +00:00
parent bd4cb023fb
commit f7b210f234
No known key found for this signature in database
12 changed files with 732 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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)));
}
}

View file

@ -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::<QuickNote>(), 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");
}
}

View file

@ -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");
}
}

View file

@ -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::<GearCategory>(), Ok(GearCategory::Grinder));
}
#[test]
fn gear_category_case_insensitive() {
assert_eq!("BREWER".parse::<GearCategory>(), Ok(GearCategory::Brewer));
}
#[test]
fn gear_category_invalid() {
assert!("invalid".parse::<GearCategory>().is_err());
}
}

View file

@ -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,<h1>hi</h1>"));
}
#[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");
}
}

View file

@ -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::<EntityType>(), Ok(variant));
}
}
#[test]
fn display_matches_as_str() {
assert_eq!(format!("{}", EntityType::Brew), "brew");
}
#[test]
fn from_str_unknown_is_err() {
assert!("unknown".parse::<EntityType>().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);
}
}
}

View file

@ -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(<image>)");
}
#[test]
fn image_data_debug_shows_none() {
let data = ImageData::default();
assert_eq!(format!("{data:?}"), "None");
}
}

View file

@ -288,3 +288,209 @@ impl<T> Page<T> {
}
}
}
#[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::<TestSortKey>::default_query();
assert_eq!(req.page, 1);
}
#[test]
fn default_query_has_default_page_size() {
let req = ListRequest::<TestSortKey>::default_query();
assert_eq!(req.page_size, PageSize::Limited(DEFAULT_PAGE_SIZE));
}
#[test]
fn default_query_has_default_sort_key() {
let req = ListRequest::<TestSortKey>::default_query();
assert_eq!(req.sort_key, TestSortKey::CreatedAt);
}
#[test]
fn default_query_has_default_sort_direction() {
let req = ListRequest::<TestSortKey>::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<i32> = 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<i32> = Page::new(vec![1, 2, 3], 1, 3, 3, true);
assert_eq!(page.total_pages(), 1);
assert!(!page.has_previous());
assert!(!page.has_next());
}
}