8
crates/domain/tests/activity.rs
Normal file
8
crates/domain/tests/activity.rs
Normal 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;
|
||||
26
crates/domain/tests/activity/activity_name_test.rs
Normal file
26
crates/domain/tests/activity/activity_name_test.rs
Normal 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(_))));
|
||||
}
|
||||
99
crates/domain/tests/activity/activity_test.rs
Normal file
99
crates/domain/tests/activity/activity_test.rs
Normal 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);
|
||||
}
|
||||
32
crates/domain/tests/activity/category_name_test.rs
Normal file
32
crates/domain/tests/activity/category_name_test.rs
Normal 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(), "");
|
||||
}
|
||||
5
crates/domain/tests/attachment.rs
Normal file
5
crates/domain/tests/attachment.rs
Normal 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;
|
||||
38
crates/domain/tests/attachment/content_type_test.rs
Normal file
38
crates/domain/tests/attachment/content_type_test.rs
Normal 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(), "");
|
||||
}
|
||||
19
crates/domain/tests/attachment/media_upload_test.rs
Normal file
19
crates/domain/tests/attachment/media_upload_test.rs
Normal 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(_))));
|
||||
}
|
||||
2
crates/domain/tests/auth.rs
Normal file
2
crates/domain/tests/auth.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "auth/refresh_session_test.rs"]
|
||||
mod refresh_session_test;
|
||||
37
crates/domain/tests/auth/refresh_session_test.rs
Normal file
37
crates/domain/tests/auth/refresh_session_test.rs
Normal 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);
|
||||
}
|
||||
11
crates/domain/tests/entry.rs
Normal file
11
crates/domain/tests/entry.rs
Normal 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;
|
||||
32
crates/domain/tests/entry/content_test.rs
Normal file
32
crates/domain/tests/entry/content_test.rs
Normal 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(), "");
|
||||
}
|
||||
36
crates/domain/tests/entry/date_range_test.rs
Normal file
36
crates/domain/tests/entry/date_range_test.rs
Normal 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(_))));
|
||||
}
|
||||
136
crates/domain/tests/entry/mood_entry_test.rs
Normal file
136
crates/domain/tests/entry/mood_entry_test.rs
Normal 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());
|
||||
}
|
||||
40
crates/domain/tests/entry/mood_test.rs
Normal file
40
crates/domain/tests/entry/mood_test.rs
Normal 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(_))));
|
||||
}
|
||||
2
crates/domain/tests/reminder.rs
Normal file
2
crates/domain/tests/reminder.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "reminder/reminder_test.rs"]
|
||||
mod reminder_test;
|
||||
82
crates/domain/tests/reminder/reminder_test.rs
Normal file
82
crates/domain/tests/reminder/reminder_test.rs
Normal 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());
|
||||
}
|
||||
2
crates/domain/tests/services.rs
Normal file
2
crates/domain/tests/services.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "services/mood_analyzer_test.rs"]
|
||||
mod mood_analyzer_test;
|
||||
137
crates/domain/tests/services/mood_analyzer_test.rs
Normal file
137
crates/domain/tests/services/mood_analyzer_test.rs
Normal 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());
|
||||
}
|
||||
14
crates/domain/tests/user.rs
Normal file
14
crates/domain/tests/user.rs
Normal 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;
|
||||
40
crates/domain/tests/user/display_name_test.rs
Normal file
40
crates/domain/tests/user/display_name_test.rs
Normal 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);
|
||||
}
|
||||
32
crates/domain/tests/user/email_test.rs
Normal file
32
crates/domain/tests/user/email_test.rs
Normal 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(_))));
|
||||
}
|
||||
32
crates/domain/tests/user/timezone_test.rs
Normal file
32
crates/domain/tests/user/timezone_test.rs
Normal 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");
|
||||
}
|
||||
59
crates/domain/tests/user/user_test.rs
Normal file
59
crates/domain/tests/user/user_test.rs
Normal 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"));
|
||||
}
|
||||
45
crates/domain/tests/user/username_test.rs
Normal file
45
crates/domain/tests/user/username_test.rs
Normal 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(_))));
|
||||
}
|
||||
Reference in New Issue
Block a user