init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
#[path = "activity/activity_name_test.rs"]
mod activity_name_test;
#[path = "activity/activity_test.rs"]
mod activity_test;
#[path = "activity/category_name_test.rs"]
mod category_name_test;

View File

@@ -0,0 +1,26 @@
use domain::activity::ActivityName;
use domain::errors::DomainError;
#[test]
fn valid_name_is_created() {
let name = ActivityName::new("exercise").unwrap();
assert_eq!(name.value(), "exercise");
}
#[test]
fn name_trims_whitespace() {
let name = ActivityName::new(" friends ").unwrap();
assert_eq!(name.value(), "friends");
}
#[test]
fn empty_name_is_rejected() {
let result = ActivityName::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn whitespace_only_name_is_rejected() {
let result = ActivityName::new(" ");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}

View File

@@ -0,0 +1,99 @@
use domain::activity::{Activity, ActivityName, CategoryName};
use domain::errors::DomainError;
use domain::user::UserId;
#[test]
fn new_activity_is_not_archived() {
let category = CategoryName::new("hobbies").unwrap();
let activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
Some(category.clone()),
);
assert!(!activity.is_archived());
assert_eq!(activity.name().value(), "gaming");
assert_eq!(activity.category().map(|c| c.value()), Some("hobbies"));
}
#[test]
fn archive_sets_archived_flag() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
activity.archive().unwrap();
assert!(activity.is_archived());
}
#[test]
fn archiving_already_archived_activity_fails() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
activity.archive().unwrap();
let result = activity.archive();
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[test]
fn unarchive_clears_archived_flag() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
activity.archive().unwrap();
activity.unarchive().unwrap();
assert!(!activity.is_archived());
}
#[test]
fn unarchiving_active_activity_fails() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
let result = activity.unarchive();
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[test]
fn rename_updates_name() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
activity.rename(ActivityName::new("video games").unwrap());
assert_eq!(activity.name().value(), "video games");
}
#[test]
fn set_category_updates_category() {
let mut activity = Activity::new(
UserId::generate(),
ActivityName::new("gaming").unwrap(),
None,
);
activity.set_category(Some(CategoryName::new("hobbies").unwrap()));
assert_eq!(activity.category().map(|c| c.value()), Some("hobbies"));
activity.set_category(None);
assert_eq!(activity.category(), None);
}

View File

@@ -0,0 +1,32 @@
use domain::activity::CategoryName;
use domain::errors::DomainError;
#[test]
fn valid_category_name_is_created() {
let name = CategoryName::new("hobbies").unwrap();
assert_eq!(name.value(), "hobbies");
}
#[test]
fn category_name_trims_whitespace() {
let name = CategoryName::new(" social ").unwrap();
assert_eq!(name.value(), "social");
}
#[test]
fn empty_category_name_is_rejected() {
let result = CategoryName::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn whitespace_only_category_name_is_rejected() {
let result = CategoryName::new(" ");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn from_persistence_bypasses_validation() {
let name = CategoryName::from_persistence(String::new());
assert_eq!(name.value(), "");
}

View File

@@ -0,0 +1,5 @@
#[path = "attachment/content_type_test.rs"]
mod content_type_test;
#[path = "attachment/media_upload_test.rs"]
mod media_upload_test;

View File

@@ -0,0 +1,38 @@
use domain::attachment::ContentType;
use domain::errors::DomainError;
#[test]
fn valid_content_type_is_created() {
let ct = ContentType::new("image/jpeg").unwrap();
assert_eq!(ct.value(), "image/jpeg");
}
#[test]
fn content_type_is_lowercased() {
let ct = ContentType::new("Image/JPEG").unwrap();
assert_eq!(ct.value(), "image/jpeg");
}
#[test]
fn content_type_trims_whitespace() {
let ct = ContentType::new(" audio/webm ").unwrap();
assert_eq!(ct.value(), "audio/webm");
}
#[test]
fn empty_content_type_is_rejected() {
let result = ContentType::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn content_type_without_slash_is_rejected() {
let result = ContentType::new("jpeg");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn from_persistence_bypasses_validation() {
let ct = ContentType::from_persistence(String::new());
assert_eq!(ct.value(), "");
}

View File

@@ -0,0 +1,19 @@
use domain::attachment::{ContentType, MediaUpload};
use domain::errors::DomainError;
#[test]
fn valid_upload_is_created() {
let ct = ContentType::new("image/png").unwrap();
let upload = MediaUpload::new(vec![1, 2, 3], ct).unwrap();
assert_eq!(upload.data(), &[1, 2, 3]);
assert_eq!(upload.content_type().value(), "image/png");
assert_eq!(upload.size(), 3);
}
#[test]
fn empty_data_is_rejected() {
let ct = ContentType::new("image/png").unwrap();
let result = MediaUpload::new(vec![], ct);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}

View File

@@ -0,0 +1,2 @@
#[path = "auth/refresh_session_test.rs"]
mod refresh_session_test;

View File

@@ -0,0 +1,37 @@
use chrono::{Duration, Utc};
use domain::auth::{RefreshSession, RefreshSessionId};
use domain::user::UserId;
#[test]
fn new_session_is_not_expired() {
let session = RefreshSession::new(UserId::generate(), 3600);
assert!(!session.is_expired());
}
#[test]
fn session_with_past_expiry_is_expired() {
let session = RefreshSession::from_persistence(
RefreshSessionId::generate(),
UserId::generate(),
"token".into(),
Utc::now() - Duration::seconds(1),
Utc::now() - Duration::seconds(3600),
);
assert!(session.is_expired());
}
#[test]
fn new_session_has_unique_token() {
let user_id = UserId::generate();
let a = RefreshSession::new(user_id.clone(), 3600);
let b = RefreshSession::new(user_id, 3600);
assert_ne!(a.token(), b.token());
}
#[test]
fn session_stores_user_id() {
let user_id = UserId::generate();
let session = RefreshSession::new(user_id.clone(), 3600);
assert_eq!(*session.user_id(), user_id);
}

View File

@@ -0,0 +1,11 @@
#[path = "entry/mood_test.rs"]
mod mood_test;
#[path = "entry/content_test.rs"]
mod content_test;
#[path = "entry/mood_entry_test.rs"]
mod mood_entry_test;
#[path = "entry/date_range_test.rs"]
mod date_range_test;

View File

@@ -0,0 +1,32 @@
use domain::entry::Content;
use domain::errors::DomainError;
#[test]
fn valid_content_is_created() {
let content = Content::new("went for a walk").unwrap();
assert_eq!(content.value(), "went for a walk");
}
#[test]
fn content_trims_whitespace() {
let content = Content::new(" hello world ").unwrap();
assert_eq!(content.value(), "hello world");
}
#[test]
fn empty_content_is_rejected() {
let result = Content::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn whitespace_only_content_is_rejected() {
let result = Content::new(" ");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn from_persistence_bypasses_validation() {
let content = Content::from_persistence(String::new());
assert_eq!(content.value(), "");
}

View File

@@ -0,0 +1,36 @@
use chrono::{FixedOffset, TimeZone};
use domain::entry::DateRange;
use domain::errors::DomainError;
fn utc_dt(year: i32, month: u32, day: u32) -> chrono::DateTime<FixedOffset> {
FixedOffset::east_opt(0)
.unwrap()
.with_ymd_and_hms(year, month, day, 0, 0, 0)
.unwrap()
}
#[test]
fn valid_range_is_created() {
let start = utc_dt(2025, 1, 1);
let end = utc_dt(2025, 1, 31);
let range = DateRange::new(start, end).unwrap();
assert_eq!(*range.start(), start);
assert_eq!(*range.end(), end);
}
#[test]
fn start_equal_to_end_is_valid() {
let dt = utc_dt(2025, 6, 15);
let range = DateRange::new(dt, dt);
assert!(range.is_ok());
}
#[test]
fn start_after_end_is_rejected() {
let start = utc_dt(2025, 3, 15);
let end = utc_dt(2025, 3, 1);
let result = DateRange::new(start, end);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}

View File

@@ -0,0 +1,136 @@
use chrono::{FixedOffset, TimeZone, Utc};
use domain::activity::ActivityId;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData};
use domain::user::UserId;
fn sample_logged_at() -> chrono::DateTime<FixedOffset> {
FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 3, 15, 20, 0, 0)
.unwrap()
}
#[test]
fn new_entry_has_required_fields() {
let user_id = UserId::generate();
let entry = MoodEntry::new(user_id.clone(), Mood::Good, sample_logged_at());
assert_eq!(*entry.user_id(), user_id);
assert_eq!(entry.mood(), Mood::Good);
assert!(entry.activities().is_empty());
assert!(entry.content().is_none());
assert!(entry.photos().is_empty());
assert!(entry.voice_memos().is_empty());
}
#[test]
fn update_mood_changes_value_and_touches_updated_at() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let before = *entry.updated_at();
entry.update_mood(Mood::Rad);
assert_eq!(entry.mood(), Mood::Rad);
assert!(*entry.updated_at() >= before);
}
#[test]
fn set_activities_deduplicates_and_sorts() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Meh, sample_logged_at());
let a = ActivityId::generate();
let b = ActivityId::generate();
entry.set_activities(vec![b.clone(), a.clone(), b.clone()]);
assert_eq!(entry.activities().len(), 2);
let ids = entry.activities();
assert!(ids[0] <= ids[1]);
}
#[test]
fn set_content_updates_and_clears() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let content = Content::new("great day").unwrap();
entry.set_content(Some(content));
assert!(entry.content().is_some());
entry.set_content(None);
assert!(entry.content().is_none());
}
#[test]
fn from_persistence_reconstructs_all_fields() {
let id = domain::entry::MoodEntryId::generate();
let user_id = UserId::generate();
let activity_id = ActivityId::generate();
let photo_id = PhotoId::generate();
let voice_memo_id = VoiceMemoId::generate();
let content = Content::new("test").unwrap();
let now = Utc::now();
let entry = MoodEntry::from_persistence(MoodEntryData {
id: id.clone(),
user_id: user_id.clone(),
mood: Mood::Bad,
logged_at: sample_logged_at(),
activities: vec![activity_id.clone()],
content: Some(content),
photos: vec![photo_id.clone()],
voice_memos: vec![voice_memo_id.clone()],
created_at: now,
updated_at: now,
});
assert_eq!(*entry.id(), id);
assert_eq!(*entry.user_id(), user_id);
assert_eq!(entry.mood(), Mood::Bad);
assert_eq!(entry.activities().len(), 1);
assert!(entry.content().is_some());
assert_eq!(entry.photos().len(), 1);
assert_eq!(entry.voice_memos().len(), 1);
}
#[test]
fn update_logged_at_changes_timestamp() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let new_time = FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 6, 1, 12, 0, 0)
.unwrap();
entry.update_logged_at(new_time);
assert_eq!(*entry.logged_at(), new_time);
}
#[test]
fn set_photos_replaces_list() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let p1 = PhotoId::generate();
let p2 = PhotoId::generate();
entry.set_photos(vec![p1.clone(), p2.clone()]);
assert_eq!(entry.photos().len(), 2);
entry.set_photos(vec![p1.clone()]);
assert_eq!(entry.photos().len(), 1);
entry.set_photos(vec![]);
assert!(entry.photos().is_empty());
}
#[test]
fn set_voice_memos_replaces_list() {
let mut entry = MoodEntry::new(UserId::generate(), Mood::Good, sample_logged_at());
let m1 = VoiceMemoId::generate();
let m2 = VoiceMemoId::generate();
entry.set_voice_memos(vec![m1.clone(), m2.clone()]);
assert_eq!(entry.voice_memos().len(), 2);
entry.set_voice_memos(vec![]);
assert!(entry.voice_memos().is_empty());
}

View File

@@ -0,0 +1,40 @@
use domain::entry::Mood;
use domain::errors::DomainError;
#[test]
fn mood_values_map_to_expected_scale() {
assert_eq!(Mood::Awful.value(), 1);
assert_eq!(Mood::Bad.value(), 2);
assert_eq!(Mood::Meh.value(), 3);
assert_eq!(Mood::Good.value(), 4);
assert_eq!(Mood::Rad.value(), 5);
}
#[test]
fn mood_ordering_reflects_scale() {
assert!(Mood::Awful < Mood::Bad);
assert!(Mood::Bad < Mood::Meh);
assert!(Mood::Meh < Mood::Good);
assert!(Mood::Good < Mood::Rad);
}
#[test]
fn valid_values_convert_to_mood() {
for value in 1..=5u8 {
let mood = Mood::try_from(value);
assert!(mood.is_ok());
assert_eq!(mood.unwrap().value(), value);
}
}
#[test]
fn zero_is_invalid_mood_value() {
let result = Mood::try_from(0u8);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn six_is_invalid_mood_value() {
let result = Mood::try_from(6u8);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}

View File

@@ -0,0 +1,2 @@
#[path = "reminder/reminder_test.rs"]
mod reminder_test;

View File

@@ -0,0 +1,82 @@
use chrono::{NaiveTime, Weekday};
use domain::reminder::{DaySchedule, Reminder};
use domain::user::UserId;
#[test]
fn new_reminder_is_enabled() {
let schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
let reminder = Reminder::new(UserId::generate(), schedule);
assert!(reminder.is_enabled());
}
#[test]
fn disable_and_enable_toggle_state() {
let schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
let mut reminder = Reminder::new(UserId::generate(), schedule);
reminder.disable();
assert!(!reminder.is_enabled());
reminder.enable();
assert!(reminder.is_enabled());
}
#[test]
fn every_day_at_sets_all_days() {
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
let schedule = DaySchedule::every_day_at(time);
for day in [
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
Weekday::Sat,
Weekday::Sun,
] {
assert_eq!(schedule.time_for(day), Some(time));
}
}
#[test]
fn empty_schedule_has_no_days_set() {
let schedule = DaySchedule::new();
assert!(!schedule.has_any_day_set());
}
#[test]
fn set_time_for_specific_day() {
let mut schedule = DaySchedule::new();
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
schedule.set_time(Weekday::Mon, Some(time));
assert_eq!(schedule.time_for(Weekday::Mon), Some(time));
assert_eq!(schedule.time_for(Weekday::Tue), None);
assert!(schedule.has_any_day_set());
}
#[test]
fn update_schedule_replaces_entire_schedule() {
let old = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
let new = DaySchedule::every_day_at(NaiveTime::from_hms_opt(9, 0, 0).unwrap());
let mut reminder = Reminder::new(UserId::generate(), old);
reminder.update_schedule(new);
let expected = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
assert_eq!(reminder.schedule().time_for(Weekday::Mon), Some(expected));
}
#[test]
fn clearing_a_set_day_removes_it() {
let mut schedule = DaySchedule::every_day_at(NaiveTime::from_hms_opt(20, 0, 0).unwrap());
assert!(schedule.time_for(Weekday::Mon).is_some());
schedule.set_time(Weekday::Mon, None);
assert!(schedule.time_for(Weekday::Mon).is_none());
assert!(schedule.has_any_day_set());
}

View File

@@ -0,0 +1,2 @@
#[path = "services/mood_analyzer_test.rs"]
mod mood_analyzer_test;

View File

@@ -0,0 +1,137 @@
use chrono::{Duration, FixedOffset, TimeZone};
use domain::activity::ActivityId;
use domain::entry::{Mood, MoodEntry};
use domain::services::MoodAnalyzerService;
use domain::user::UserId;
fn entry_with_mood(mood: Mood, days_ago: i64) -> MoodEntry {
let offset = FixedOffset::east_opt(0).unwrap();
let logged_at =
offset.from_utc_datetime(&(chrono::Utc::now() - Duration::days(days_ago)).naive_utc());
MoodEntry::new(UserId::generate(), mood, logged_at)
}
fn entry_with_mood_and_activity(mood: Mood, activity: &ActivityId) -> MoodEntry {
let offset = FixedOffset::east_opt(0).unwrap();
let logged_at = offset.from_utc_datetime(&chrono::Utc::now().naive_utc());
let mut entry = MoodEntry::new(UserId::generate(), mood, logged_at);
entry.set_activities(vec![activity.clone()]);
entry
}
#[test]
fn average_mood_of_empty_slice_is_none() {
assert!(MoodAnalyzerService::average_mood(&[]).is_none());
}
#[test]
fn average_mood_of_single_entry() {
let entries = vec![entry_with_mood(Mood::Good, 0)];
let avg = MoodAnalyzerService::average_mood(&entries).unwrap();
assert!((avg - 4.0).abs() < f64::EPSILON);
}
#[test]
fn average_mood_across_multiple_entries() {
let entries = vec![
entry_with_mood(Mood::Awful, 0),
entry_with_mood(Mood::Rad, 0),
];
let avg = MoodAnalyzerService::average_mood(&entries).unwrap();
assert!((avg - 3.0).abs() < f64::EPSILON);
}
#[test]
fn mood_frequency_counts_each_mood() {
let entries = vec![
entry_with_mood(Mood::Good, 0),
entry_with_mood(Mood::Good, 1),
entry_with_mood(Mood::Meh, 2),
];
let freq = MoodAnalyzerService::mood_frequency(&entries);
let good_count = freq.iter().find(|(m, _)| *m == Mood::Good).map(|(_, c)| *c);
let meh_count = freq.iter().find(|(m, _)| *m == Mood::Meh).map(|(_, c)| *c);
assert_eq!(good_count, Some(2));
assert_eq!(meh_count, Some(1));
}
#[test]
fn streak_counts_consecutive_days() {
let entries = vec![
entry_with_mood(Mood::Good, 0),
entry_with_mood(Mood::Good, 1),
entry_with_mood(Mood::Good, 2),
];
assert_eq!(MoodAnalyzerService::current_streak(&entries), 3);
}
#[test]
fn streak_breaks_on_gap() {
let entries = vec![
entry_with_mood(Mood::Good, 0),
entry_with_mood(Mood::Good, 1),
entry_with_mood(Mood::Good, 3),
];
assert_eq!(MoodAnalyzerService::current_streak(&entries), 2);
}
#[test]
fn streak_of_empty_entries_is_zero() {
assert_eq!(MoodAnalyzerService::current_streak(&[]), 0);
}
#[test]
fn activity_correlation_positive_when_mood_higher_with_activity() {
let activity = ActivityId::generate();
let entries = vec![
entry_with_mood_and_activity(Mood::Rad, &activity),
entry_with_mood_and_activity(Mood::Good, &activity),
entry_with_mood(Mood::Meh, 0),
entry_with_mood(Mood::Bad, 1),
];
let correlation = MoodAnalyzerService::activity_mood_correlation(&entries, &activity).unwrap();
assert!(correlation > 0.0);
}
#[test]
fn activity_correlation_is_none_when_never_used() {
let activity = ActivityId::generate();
let entries = vec![entry_with_mood(Mood::Good, 0)];
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
assert!(result.is_none());
}
#[test]
fn streak_is_zero_when_last_entry_is_old() {
let entries = vec![
entry_with_mood(Mood::Good, 3),
entry_with_mood(Mood::Good, 4),
];
assert_eq!(MoodAnalyzerService::current_streak(&entries), 0);
}
#[test]
fn mood_frequency_on_empty_is_empty() {
let freq = MoodAnalyzerService::mood_frequency(&[]);
assert!(freq.is_empty());
}
#[test]
fn activity_correlation_is_none_when_all_entries_have_activity() {
let activity = ActivityId::generate();
let entries = vec![
entry_with_mood_and_activity(Mood::Good, &activity),
entry_with_mood_and_activity(Mood::Rad, &activity),
];
let result = MoodAnalyzerService::activity_mood_correlation(&entries, &activity);
assert!(result.is_none());
}

View File

@@ -0,0 +1,14 @@
#[path = "user/username_test.rs"]
mod username_test;
#[path = "user/email_test.rs"]
mod email_test;
#[path = "user/user_test.rs"]
mod user_test;
#[path = "user/display_name_test.rs"]
mod display_name_test;
#[path = "user/timezone_test.rs"]
mod timezone_test;

View File

@@ -0,0 +1,40 @@
use domain::errors::DomainError;
use domain::user::DisplayName;
#[test]
fn valid_display_name_is_created() {
let name = DisplayName::new("Alice").unwrap();
assert_eq!(name.value(), "Alice");
}
#[test]
fn display_name_trims_whitespace() {
let name = DisplayName::new(" Bob ").unwrap();
assert_eq!(name.value(), "Bob");
}
#[test]
fn empty_display_name_is_rejected() {
let result = DisplayName::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn whitespace_only_display_name_is_rejected() {
let result = DisplayName::new(" ");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn display_name_over_100_chars_is_rejected() {
let long = "a".repeat(101);
let result = DisplayName::new(long);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn display_name_at_100_chars_is_valid() {
let exact = "a".repeat(100);
let name = DisplayName::new(exact).unwrap();
assert_eq!(name.value().len(), 100);
}

View File

@@ -0,0 +1,32 @@
use domain::errors::DomainError;
use domain::user::Email;
#[test]
fn valid_email_is_created() {
let email = Email::new("user@example.com").unwrap();
assert_eq!(email.value(), "user@example.com");
}
#[test]
fn email_is_lowercased() {
let email = Email::new("User@Example.COM").unwrap();
assert_eq!(email.value(), "user@example.com");
}
#[test]
fn email_trims_whitespace() {
let email = Email::new(" user@example.com ").unwrap();
assert_eq!(email.value(), "user@example.com");
}
#[test]
fn email_without_at_is_rejected() {
let result = Email::new("not-an-email");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn empty_email_is_rejected() {
let result = Email::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}

View File

@@ -0,0 +1,32 @@
use domain::errors::DomainError;
use domain::user::Timezone;
#[test]
fn valid_timezone_is_created() {
let tz = Timezone::new("Europe/Warsaw").unwrap();
assert_eq!(tz.value(), "Europe/Warsaw");
}
#[test]
fn timezone_trims_whitespace() {
let tz = Timezone::new(" America/New_York ").unwrap();
assert_eq!(tz.value(), "America/New_York");
}
#[test]
fn empty_timezone_is_rejected() {
let result = Timezone::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn timezone_without_slash_is_rejected() {
let result = Timezone::new("UTC");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn from_persistence_bypasses_validation() {
let tz = Timezone::from_persistence("UTC".into());
assert_eq!(tz.value(), "UTC");
}

View File

@@ -0,0 +1,59 @@
use domain::user::{DisplayName, Email, PasswordHash, Timezone, User, UserRole, Username};
#[test]
fn new_user_has_default_role() {
let user = User::new(
Username::new("gabriel").unwrap(),
Email::new("gabriel@example.com").unwrap(),
PasswordHash::new("hashed".into()),
);
assert_eq!(user.role(), UserRole::User);
assert!(!user.is_admin());
assert!(user.display_name().is_none());
assert!(user.timezone().is_none());
}
#[test]
fn promote_to_admin_changes_role() {
let mut user = User::new(
Username::new("gabriel").unwrap(),
Email::new("gabriel@example.com").unwrap(),
PasswordHash::new("hashed".into()),
);
user.promote_to_admin();
assert_eq!(user.role(), UserRole::Admin);
assert!(user.is_admin());
}
#[test]
fn update_display_name_touches_updated_at() {
let mut user = User::new(
Username::new("gabriel").unwrap(),
Email::new("gabriel@example.com").unwrap(),
PasswordHash::new("hashed".into()),
);
let before = *user.updated_at();
let name = DisplayName::new("Gabriel K").unwrap();
user.update_display_name(Some(name));
assert_eq!(user.display_name().map(|d| d.value()), Some("Gabriel K"));
assert!(*user.updated_at() >= before);
}
#[test]
fn update_timezone_sets_value() {
let mut user = User::new(
Username::new("gabriel").unwrap(),
Email::new("gabriel@example.com").unwrap(),
PasswordHash::new("hashed".into()),
);
let tz = Timezone::new("Europe/Warsaw").unwrap();
user.update_timezone(Some(tz));
assert_eq!(user.timezone().map(|t| t.value()), Some("Europe/Warsaw"));
}

View File

@@ -0,0 +1,45 @@
use domain::errors::DomainError;
use domain::user::Username;
#[test]
fn valid_username_is_created() {
let username = Username::new("gabriel").unwrap();
assert_eq!(username.value(), "gabriel");
}
#[test]
fn username_with_dots_and_underscores_is_valid() {
let username = Username::new("gabriel.k_99").unwrap();
assert_eq!(username.value(), "gabriel.k_99");
}
#[test]
fn username_trims_whitespace() {
let username = Username::new(" gabriel ").unwrap();
assert_eq!(username.value(), "gabriel");
}
#[test]
fn empty_username_is_rejected() {
let result = Username::new("");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn username_longer_than_32_chars_is_rejected() {
let long = "a".repeat(33);
let result = Username::new(long);
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn username_with_special_chars_is_rejected() {
let result = Username::new("gabriel@home");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}
#[test]
fn username_with_spaces_is_rejected() {
let result = Username::new("gabriel k");
assert!(matches!(result, Err(DomainError::InvalidInput(_))));
}