2
crates/application/tests/api_token.rs
Normal file
2
crates/application/tests/api_token.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "api_token/api_token_test.rs"]
|
||||
mod api_token_test;
|
||||
183
crates/application/tests/api_token/api_token_test.rs
Normal file
183
crates/application/tests/api_token/api_token_test.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::ApiToken;
|
||||
use domain::ports::UserCommandPort;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::{FakeApiTokenSecret, InMemoryStore, test_user};
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use application::api_token::commands::MintApiTokenCommand;
|
||||
use application::api_token::use_cases::{
|
||||
authenticate_api_token, list_api_tokens, mint_api_token, revoke_api_token,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
store: Arc<InMemoryStore>,
|
||||
secrets: Arc<FakeApiTokenSecret>,
|
||||
user: User,
|
||||
}
|
||||
|
||||
async fn a_user_with_no_tokens() -> Fixture {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
Fixture {
|
||||
store,
|
||||
secrets: Arc::new(FakeApiTokenSecret::new()),
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn mint(&self, name: &str) -> Result<String, application::errors::ApplicationError> {
|
||||
let deps = mint_api_token::Deps {
|
||||
command: self.store.clone(),
|
||||
secrets: self.secrets.clone(),
|
||||
};
|
||||
|
||||
let minted = mint_api_token::execute(
|
||||
MintApiTokenCommand {
|
||||
user_id: self.user.id().clone(),
|
||||
name: ProviderName::new(name)?,
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(minted.secret().to_string())
|
||||
}
|
||||
|
||||
async fn authenticate(&self, secret: &str) -> Option<ApiToken> {
|
||||
let deps = authenticate_api_token::Deps {
|
||||
query: self.store.clone(),
|
||||
command: self.store.clone(),
|
||||
secrets: self.secrets.clone(),
|
||||
};
|
||||
|
||||
authenticate_api_token::execute(secret, &deps).await.ok()
|
||||
}
|
||||
|
||||
async fn list(&self) -> Vec<ApiToken> {
|
||||
let deps = list_api_tokens::Deps {
|
||||
query: self.store.clone(),
|
||||
};
|
||||
|
||||
list_api_tokens::execute(self.user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn revoke(
|
||||
&self,
|
||||
owner: UserId,
|
||||
token: &ApiToken,
|
||||
) -> Result<(), application::errors::ApplicationError> {
|
||||
let deps = revoke_api_token::Deps {
|
||||
command: self.store.clone(),
|
||||
};
|
||||
|
||||
revoke_api_token::execute(owner, token.id().clone(), &deps).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_minted_token_is_returned_once_and_stored_only_as_a_digest() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
|
||||
let secret = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
|
||||
let stored = fixture.list().await;
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].name().value(), "iphone-shortcuts");
|
||||
assert!(!secret.is_empty(), "the caller is handed the secret once");
|
||||
assert_ne!(
|
||||
stored[0].digest().value(),
|
||||
secret,
|
||||
"what is stored is a digest, not the secret itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_secret_authenticates_and_names_the_provider_its_writes_belong_to() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
let secret = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
|
||||
let token = fixture.authenticate(&secret).await.expect("it should work");
|
||||
|
||||
assert_eq!(token.user_id(), fixture.user.id());
|
||||
assert_eq!(token.name().value(), "iphone-shortcuts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_secret_nobody_minted_authenticates_nothing() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
|
||||
assert!(
|
||||
fixture
|
||||
.authenticate("kmood_not_a_real_secret")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revoked_token_stops_working_at_once() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
let secret = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
let token = fixture.authenticate(&secret).await.unwrap();
|
||||
|
||||
fixture
|
||||
.revoke(fixture.user.id().clone(), &token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(fixture.authenticate(&secret).await.is_none());
|
||||
assert!(fixture.list().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn using_a_token_records_that_it_was_used() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
let secret = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
|
||||
assert!(fixture.list().await[0].last_used_at().is_none());
|
||||
|
||||
fixture.authenticate(&secret).await.unwrap();
|
||||
|
||||
assert!(fixture.list().await[0].last_used_at().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nobody_can_revoke_a_token_that_is_not_theirs() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
let secret = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
let token = fixture.authenticate(&secret).await.unwrap();
|
||||
|
||||
let attempt = fixture.revoke(UserId::generate(), &token).await;
|
||||
|
||||
assert!(attempt.is_err());
|
||||
assert!(fixture.authenticate(&secret).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_tokens_cannot_share_a_name_because_the_name_is_the_provider() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
|
||||
let again = fixture.mint("iphone-shortcuts").await;
|
||||
|
||||
assert!(again.is_err());
|
||||
assert_eq!(fixture.list().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_minting_produces_a_different_secret() {
|
||||
let fixture = a_user_with_no_tokens().await;
|
||||
|
||||
let first = fixture.mint("iphone-shortcuts").await.unwrap();
|
||||
let second = fixture.mint("tasker").await.unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
2
crates/application/tests/backup.rs
Normal file
2
crates/application/tests/backup.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "backup/round_trip_test.rs"]
|
||||
mod round_trip_test;
|
||||
332
crates/application/tests/backup/round_trip_test.rs
Normal file
332
crates/application/tests/backup/round_trip_test.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::{Activity, ActivityName, CategoryName};
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::entry::{Content, Date, DateSpan, Mood, MoodEntry};
|
||||
use domain::location::Coordinates;
|
||||
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, CycleStartCommandPort, CycleStartQueryPort,
|
||||
DailyMetricCommandPort, DailyMetricQueryPort, EntryDimensionPort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort, ReminderCommandPort, ReminderQueryPort, UserCommandPort,
|
||||
UserPreferencesQueryPort,
|
||||
};
|
||||
use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::song::Song;
|
||||
use domain::testing::{InMemoryDimensionStore, InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::export::use_cases::write_backup;
|
||||
use application::restore::commands::RestoreBackupCommand;
|
||||
use application::restore::use_cases::restore_backup::{self, RestoreOutcome};
|
||||
|
||||
fn dimension_stores() -> Vec<Arc<dyn EntryDimensionPort>> {
|
||||
use domain::dimension::DimensionKind;
|
||||
|
||||
[
|
||||
DimensionKind::Content,
|
||||
DimensionKind::Activities,
|
||||
DimensionKind::Location,
|
||||
DimensionKind::Song,
|
||||
DimensionKind::Photos,
|
||||
DimensionKind::VoiceMemos,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|kind| Arc::new(InMemoryDimensionStore::new(kind)) as Arc<dyn EntryDimensionPort>)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct World {
|
||||
store: Arc<InMemoryStore>,
|
||||
dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
user: User,
|
||||
}
|
||||
|
||||
async fn a_populated_account() -> World {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let dimensions = dimension_stores();
|
||||
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let walking = Activity::new(
|
||||
user.id().clone(),
|
||||
ActivityName::new("long walk").unwrap(),
|
||||
Some(CategoryName::new("health").unwrap()),
|
||||
);
|
||||
ActivityCommandPort::save(store.as_ref(), &walking)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = MoodEntry::new(
|
||||
user.id().clone(),
|
||||
Mood::Rad,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T21:30:00+02:00").unwrap(),
|
||||
);
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let attached = vec![
|
||||
DimensionValue::Content(Content::new("Long walk by the river").unwrap()),
|
||||
DimensionValue::activities(vec![walking.id().clone()]),
|
||||
DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap()),
|
||||
DimensionValue::Song(
|
||||
Song::new("Teardrop", "Massive Attack", Some("Mezzanine".into()), None).unwrap(),
|
||||
),
|
||||
];
|
||||
for port in &dimensions {
|
||||
port.save(entry.id(), &attached).await.unwrap();
|
||||
}
|
||||
|
||||
DailyMetricCommandPort::save(
|
||||
store.as_ref(),
|
||||
&[DailyMetric::new(
|
||||
user.id().clone(),
|
||||
on("2026-08-20"),
|
||||
MetricValue::Steps(Steps::new(8_412).unwrap()),
|
||||
Source::Manual,
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
CycleStartCommandPort::record(store.as_ref(), user.id(), &on("2026-08-01"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reminder = Reminder::new(
|
||||
user.id().clone(),
|
||||
DaySchedule::every_day_at(chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
|
||||
);
|
||||
ReminderCommandPort::save(store.as_ref(), &reminder)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let preference_deps = application::user::use_cases::set_preferences::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
application::user::use_cases::set_preferences::execute(
|
||||
user.id().clone(),
|
||||
true,
|
||||
&preference_deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
World {
|
||||
store,
|
||||
dimensions,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
fn on(day: &str) -> Date {
|
||||
Date::from_persistence(day.parse().unwrap())
|
||||
}
|
||||
|
||||
async fn backup_of(world: &World) -> Vec<u8> {
|
||||
let deps = write_backup::Deps {
|
||||
entries: world.store.clone(),
|
||||
dimensions: world.dimensions.clone(),
|
||||
activities: world.store.clone(),
|
||||
reminders: world.store.clone(),
|
||||
metrics: world.store.clone(),
|
||||
cycles: world.store.clone(),
|
||||
preferences: world.store.clone(),
|
||||
media_storage: world.store.clone(),
|
||||
writer: Arc::new(exporter::ZipBackupWriter),
|
||||
};
|
||||
|
||||
write_backup::execute(world.user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn restore_into(world: &World, archive: Vec<u8>) -> RestoreOutcome {
|
||||
let deps = restore_backup::Deps {
|
||||
reader: Arc::new(importer::KmoodBackupAdapter),
|
||||
entry_command: world.store.clone(),
|
||||
dimensions: world.dimensions.clone(),
|
||||
activity_command: world.store.clone(),
|
||||
activity_query: world.store.clone(),
|
||||
reminder_command: world.store.clone(),
|
||||
metrics: world.store.clone(),
|
||||
cycles: world.store.clone(),
|
||||
preferences_command: world.store.clone(),
|
||||
preferences_query: world.store.clone(),
|
||||
media_storage: world.store.clone(),
|
||||
};
|
||||
|
||||
restore_backup::execute(
|
||||
RestoreBackupCommand {
|
||||
user_id: world.user.id().clone(),
|
||||
data: archive,
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn an_empty_account() -> World {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let mut user = test_user("bob");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
World {
|
||||
store,
|
||||
dimensions: dimension_stores(),
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_backup_restores_into_an_empty_account_without_losing_anything() {
|
||||
let source = a_populated_account().await;
|
||||
let archive = backup_of(&source).await;
|
||||
|
||||
let restored = an_empty_account().await;
|
||||
let outcome = restore_into(&restored, archive).await;
|
||||
|
||||
assert!(
|
||||
outcome.unreadable.is_empty(),
|
||||
"the restore could not read: {:?}",
|
||||
outcome.unreadable
|
||||
);
|
||||
assert_eq!(outcome.entries, 1);
|
||||
assert_eq!(outcome.metrics, 1);
|
||||
assert_eq!(outcome.cycle_starts, 1);
|
||||
assert_eq!(outcome.activities, 1);
|
||||
assert_eq!(outcome.reminders, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_dimension_survives_the_round_trip() {
|
||||
let source = a_populated_account().await;
|
||||
let archive = backup_of(&source).await;
|
||||
|
||||
let restored = an_empty_account().await;
|
||||
restore_into(&restored, archive).await;
|
||||
|
||||
let entries =
|
||||
MoodEntryQueryPort::find_by_user(restored.store.as_ref(), restored.user.id(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let composed = application::entry::composition::EntryComposer::new(restored.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(composed.len(), 1);
|
||||
let entry = &composed[0];
|
||||
|
||||
assert_eq!(entry.entry.mood(), Mood::Rad);
|
||||
assert_eq!(
|
||||
entry.content().map(|content| content.value().to_string()),
|
||||
Some("Long walk by the river".to_string())
|
||||
);
|
||||
assert!(entry.location().is_some(), "the location was lost");
|
||||
assert_eq!(
|
||||
entry.song().map(|song| song.artist().value().to_string()),
|
||||
Some("Massive Attack".to_string())
|
||||
);
|
||||
assert_eq!(entry.activities().len(), 1, "the activity tag was lost");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_restored_activity_tag_points_at_the_restored_activity() {
|
||||
let source = a_populated_account().await;
|
||||
let archive = backup_of(&source).await;
|
||||
|
||||
let restored = an_empty_account().await;
|
||||
restore_into(&restored, archive).await;
|
||||
|
||||
let catalog = ActivityQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
let entries =
|
||||
MoodEntryQueryPort::find_by_user(restored.store.as_ref(), restored.user.id(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let composed = application::entry::composition::EntryComposer::new(restored.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(catalog.len(), 1);
|
||||
assert_eq!(
|
||||
composed[0].activities(),
|
||||
[catalog[0].id().clone()],
|
||||
"the tag must reference the activity as it now exists, not as it was"
|
||||
);
|
||||
assert_eq!(catalog[0].name().value(), "long walk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metrics_cycle_starts_and_preferences_all_come_back() {
|
||||
let source = a_populated_account().await;
|
||||
let archive = backup_of(&source).await;
|
||||
|
||||
let restored = an_empty_account().await;
|
||||
restore_into(&restored, archive).await;
|
||||
|
||||
let span = DateSpan::new(on("2026-01-01"), on("2026-12-31")).unwrap();
|
||||
let metrics =
|
||||
DailyMetricQueryPort::find_by_span(restored.store.as_ref(), restored.user.id(), &span)
|
||||
.await
|
||||
.unwrap();
|
||||
let starts = CycleStartQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
let preferences =
|
||||
UserPreferencesQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let reminders = ReminderQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(metrics.len(), 1);
|
||||
assert_eq!(metrics[0].value().count(), 8_412);
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0].to_string(), "2026-08-01");
|
||||
assert!(preferences.tracks_cycle(), "the preference was lost");
|
||||
assert_eq!(reminders.len(), 1);
|
||||
assert!(reminders[0].is_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_archive_that_is_not_a_backup_is_refused() {
|
||||
let restored = an_empty_account().await;
|
||||
|
||||
let deps = restore_backup::Deps {
|
||||
reader: Arc::new(importer::KmoodBackupAdapter),
|
||||
entry_command: restored.store.clone(),
|
||||
dimensions: restored.dimensions.clone(),
|
||||
activity_command: restored.store.clone(),
|
||||
activity_query: restored.store.clone(),
|
||||
reminder_command: restored.store.clone(),
|
||||
metrics: restored.store.clone(),
|
||||
cycles: restored.store.clone(),
|
||||
preferences_command: restored.store.clone(),
|
||||
preferences_query: restored.store.clone(),
|
||||
media_storage: restored.store.clone(),
|
||||
};
|
||||
|
||||
let refused = restore_backup::execute(
|
||||
RestoreBackupCommand {
|
||||
user_id: restored.user.id().clone(),
|
||||
data: b"this is not a zip file at all".to_vec(),
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(refused.to_string().contains("zip"), "got {refused}");
|
||||
}
|
||||
2
crates/application/tests/correlation.rs
Normal file
2
crates/application/tests/correlation.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "correlation/get_correlations_test.rs"]
|
||||
mod get_correlations_test;
|
||||
739
crates/application/tests/correlation/get_correlations_test.rs
Normal file
739
crates/application/tests/correlation/get_correlations_test.rs
Normal file
@@ -0,0 +1,739 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, FixedOffset};
|
||||
|
||||
use domain::correlation::{CorrelationInput, CorrelationStrategy};
|
||||
use domain::entry::{Date, DateSpan, Mood, MoodEntry};
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source, Steps};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, DailyMetricCommandPort, MoodEntryCommandPort, UserCommandPort,
|
||||
};
|
||||
use domain::testing::{InMemoryStore, test_activity, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::correlation::queries::CorrelationQuery;
|
||||
use application::correlation::use_cases::get_correlations::{self, CorrelationRow};
|
||||
|
||||
const MINIMUM: usize = 30;
|
||||
|
||||
fn a_user_in_warsaw() -> User {
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
user
|
||||
}
|
||||
|
||||
fn noon_on(day: usize) -> DateTime<FixedOffset> {
|
||||
DateTime::parse_from_rfc3339("2026-01-01T12:00:00+01:00").unwrap() + Duration::days(day as i64)
|
||||
}
|
||||
|
||||
fn date_of(day: usize) -> Date {
|
||||
Date::from_persistence(noon_on(day).date_naive())
|
||||
}
|
||||
|
||||
async fn logged(store: &Arc<InMemoryStore>, user: &User, mood: Mood, day: usize) {
|
||||
let entry = MoodEntry::new(user.id().clone(), mood, noon_on(day));
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn walked(store: &Arc<InMemoryStore>, user: &User, steps: u32, day: usize) {
|
||||
let metric = DailyMetric::new(
|
||||
user.id().clone(),
|
||||
date_of(day),
|
||||
MetricValue::Steps(Steps::new(steps).unwrap()),
|
||||
Source::Manual,
|
||||
);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[metric])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn a_rising_mood_for(day: usize) -> Mood {
|
||||
match day / 8 {
|
||||
0 => Mood::Awful,
|
||||
1 => Mood::Bad,
|
||||
2 => Mood::Meh,
|
||||
3 => Mood::Good,
|
||||
_ => Mood::Rad,
|
||||
}
|
||||
}
|
||||
|
||||
fn a_mood_for(day: usize) -> Mood {
|
||||
match day % 5 {
|
||||
0 => Mood::Awful,
|
||||
1 => Mood::Bad,
|
||||
2 => Mood::Meh,
|
||||
3 => Mood::Good,
|
||||
_ => Mood::Rad,
|
||||
}
|
||||
}
|
||||
|
||||
async fn correlations(store: &Arc<InMemoryStore>, user: &User) -> Vec<CorrelationRow> {
|
||||
let deps = get_correlations::Deps {
|
||||
entries: store.clone(),
|
||||
metrics: store.clone(),
|
||||
activities: store.clone(),
|
||||
cycles: store.clone(),
|
||||
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
|
||||
domain::dimension::DimensionKind::Weather,
|
||||
)),
|
||||
preferences: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let query = CorrelationQuery {
|
||||
user_id: user.id().clone(),
|
||||
span: DateSpan::new(date_of(0), date_of(120)).unwrap(),
|
||||
minimum_sample_size: MINIMUM,
|
||||
false_discovery_rate: 0.10,
|
||||
};
|
||||
|
||||
get_correlations::execute(query, &deps).await.unwrap()
|
||||
}
|
||||
|
||||
fn row_for(rows: &[CorrelationRow], input: CorrelationInput) -> &CorrelationRow {
|
||||
rows.iter()
|
||||
.find(|row| row.input == input)
|
||||
.expect("every input has a row")
|
||||
}
|
||||
|
||||
fn spearman_of(row: &CorrelationRow) -> Option<f64> {
|
||||
row.scores
|
||||
.iter()
|
||||
.find(|score| score.strategy == CorrelationStrategy::Spearman)
|
||||
.map(|score| score.coefficient.value())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steps_that_track_mood_exactly_score_one() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_rising_mood_for(day), day).await;
|
||||
walked(&store, &user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert_eq!(steps.sample_size, 40);
|
||||
let score = spearman_of(steps).expect("40 days is above the floor");
|
||||
assert!(score > 0.9, "expected a strong positive score, got {score}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn too_few_days_yield_a_row_but_no_coefficient() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..12 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
walked(&store, &user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert_eq!(steps.sample_size, 12);
|
||||
assert!(steps.scores.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_day_with_several_entries_contributes_one_point() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..31 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
walked(&store, &user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
logged(&store, &user, Mood::Rad, 0).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert_eq!(steps.sample_size, 31);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn days_without_the_metric_are_left_out_of_its_sample() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
for day in 0..33 {
|
||||
walked(&store, &user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert_eq!(steps.sample_size, 33);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_moon_is_scored_against_every_day_that_has_a_mood() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let moon = row_for(&rows, CorrelationInput::MoonPhase);
|
||||
|
||||
assert_eq!(moon.sample_size, 40);
|
||||
assert!(spearman_of(moon).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_kind_that_was_never_recorded_still_has_a_row_with_no_days() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let hrv = row_for(&rows, CorrelationInput::Metric(MetricKind::Hrv));
|
||||
|
||||
assert_eq!(hrv.sample_size, 0);
|
||||
assert!(hrv.scores.is_empty());
|
||||
let inputs_that_need_no_data: usize = 2;
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
MetricKind::ALL.len() + inputs_that_need_no_data,
|
||||
"every metric kind, plus the moon and the temperature"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_entry_just_after_local_midnight_counts_for_that_day() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 1..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let just_after_midnight = DateTime::parse_from_rfc3339("2026-01-01T00:30:00+01:00").unwrap();
|
||||
let entry = MoodEntry::new(user.id().clone(), Mood::Good, just_after_midnight);
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let moon = row_for(&rows, CorrelationInput::MoonPhase);
|
||||
|
||||
assert_eq!(
|
||||
moon.sample_size, 40,
|
||||
"the first day's early entry was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_day_outside_the_span_is_not_counted() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let the_day_before = DateTime::parse_from_rfc3339("2025-12-31T12:00:00+01:00").unwrap();
|
||||
let entry = MoodEntry::new(user.id().clone(), Mood::Rad, the_day_before);
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let moon = row_for(&rows, CorrelationInput::MoonPhase);
|
||||
|
||||
assert_eq!(moon.sample_size, 40, "a day outside the span was counted");
|
||||
}
|
||||
|
||||
async fn correlations_with_floor(
|
||||
store: &Arc<InMemoryStore>,
|
||||
user: &User,
|
||||
floor: usize,
|
||||
) -> Vec<CorrelationRow> {
|
||||
let deps = get_correlations::Deps {
|
||||
entries: store.clone(),
|
||||
metrics: store.clone(),
|
||||
activities: store.clone(),
|
||||
cycles: store.clone(),
|
||||
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
|
||||
domain::dimension::DimensionKind::Weather,
|
||||
)),
|
||||
preferences: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let query = CorrelationQuery {
|
||||
user_id: user.id().clone(),
|
||||
span: DateSpan::new(date_of(0), date_of(120)).unwrap(),
|
||||
minimum_sample_size: floor,
|
||||
false_discovery_rate: 0.10,
|
||||
};
|
||||
|
||||
get_correlations::execute(query, &deps).await.unwrap()
|
||||
}
|
||||
|
||||
fn strategies_of(row: &CorrelationRow) -> Vec<&str> {
|
||||
row.scores
|
||||
.iter()
|
||||
.map(|score| score.strategy.name())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_applicable_strategy_scores_a_metric() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_rising_mood_for(day), day).await;
|
||||
walked(&store, &user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert_eq!(strategies_of(steps), ["pearson", "spearman", "kendall"]);
|
||||
assert_eq!(steps.agreement.applicable(), 3);
|
||||
assert_eq!(steps.agreement.agreeing(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_outlying_day_can_set_pearson_against_the_rank_methods() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let moods = [
|
||||
Mood::Awful,
|
||||
Mood::Awful,
|
||||
Mood::Bad,
|
||||
Mood::Bad,
|
||||
Mood::Meh,
|
||||
Mood::Meh,
|
||||
Mood::Good,
|
||||
Mood::Good,
|
||||
Mood::Rad,
|
||||
Mood::Awful,
|
||||
];
|
||||
let steps = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10_000];
|
||||
|
||||
for day in 0..10 {
|
||||
logged(&store, &user, moods[day], day).await;
|
||||
walked(&store, &user, steps[day], day).await;
|
||||
}
|
||||
|
||||
let rows = correlations_with_floor(&store, &user, 10).await;
|
||||
let row = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
let pearson = row
|
||||
.scores
|
||||
.iter()
|
||||
.find(|score| score.strategy == CorrelationStrategy::Pearson)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
pearson.coefficient.value() < 0.0,
|
||||
"the outlier pulls the line down"
|
||||
);
|
||||
assert_eq!(row.agreement.applicable(), 3);
|
||||
assert_eq!(
|
||||
row.agreement.agreeing(),
|
||||
2,
|
||||
"the two rank methods still agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_activity_is_scored_by_mean_difference_alone() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let exercise = test_activity(user.id().clone(), "exercise");
|
||||
ActivityCommandPort::save(store.as_ref(), &exercise)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
let mood = if day % 2 == 0 { Mood::Rad } else { Mood::Bad };
|
||||
let entry = MoodEntry::new(user.id().clone(), mood, noon_on(day));
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if day % 2 == 0 {
|
||||
store.assign_activities(entry.id(), vec![exercise.id().clone()]);
|
||||
}
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let row = row_for(&rows, CorrelationInput::Activity(exercise.id().clone()));
|
||||
|
||||
assert_eq!(strategies_of(row), ["meanDifference"]);
|
||||
assert_eq!(row.agreement.applicable(), 1);
|
||||
assert_eq!(row.agreement.agreeing(), 1);
|
||||
assert!(row.scores[0].coefficient.value() > 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_activity_logged_every_single_day_cannot_be_compared() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let coffee = test_activity(user.id().clone(), "coffee");
|
||||
ActivityCommandPort::save(store.as_ref(), &coffee)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
let entry = MoodEntry::new(user.id().clone(), a_mood_for(day), noon_on(day));
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
store.assign_activities(entry.id(), vec![coffee.id().clone()]);
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let row = row_for(&rows, CorrelationInput::Activity(coffee.id().clone()));
|
||||
|
||||
assert!(
|
||||
row.scores.is_empty(),
|
||||
"there are no days without it to compare"
|
||||
);
|
||||
assert_eq!(row.agreement.applicable(), 1);
|
||||
assert_eq!(row.agreement.agreeing(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_archived_activity_is_not_offered() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let mut retired = test_activity(user.id().clone(), "fencing");
|
||||
retired.archive().unwrap();
|
||||
ActivityCommandPort::save(store.as_ref(), &retired)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|row| row.input == CorrelationInput::Activity(retired.id().clone())),
|
||||
"an archived activity cannot be tagged onto new entries and is not an input"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_activity_never_logged_in_the_span_is_not_a_row_at_all() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let untouched = test_activity(user.id().clone(), "fencing");
|
||||
ActivityCommandPort::save(store.as_ref(), &untouched)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|row| row.input == CorrelationInput::Activity(untouched.id().clone())),
|
||||
"an activity with no logged day says nothing and should not be reported as measured"
|
||||
);
|
||||
}
|
||||
|
||||
async fn deps_free_entries(store: &Arc<InMemoryStore>, user: &User) -> Vec<MoodEntry> {
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
MoodEntryQueryPort::find_by_user(store.as_ref(), user.id(), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn correlations_with_rate(
|
||||
store: &Arc<InMemoryStore>,
|
||||
user: &User,
|
||||
floor: usize,
|
||||
false_discovery_rate: f64,
|
||||
) -> Vec<CorrelationRow> {
|
||||
let deps = get_correlations::Deps {
|
||||
entries: store.clone(),
|
||||
metrics: store.clone(),
|
||||
activities: store.clone(),
|
||||
cycles: store.clone(),
|
||||
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
|
||||
domain::dimension::DimensionKind::Weather,
|
||||
)),
|
||||
preferences: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let query = CorrelationQuery {
|
||||
user_id: user.id().clone(),
|
||||
span: DateSpan::new(date_of(0), date_of(120)).unwrap(),
|
||||
minimum_sample_size: floor,
|
||||
false_discovery_rate,
|
||||
};
|
||||
|
||||
get_correlations::execute(query, &deps).await.unwrap()
|
||||
}
|
||||
|
||||
fn held_up(row: &CorrelationRow, strategy: CorrelationStrategy) -> bool {
|
||||
row.scores
|
||||
.iter()
|
||||
.find(|score| score.strategy == strategy)
|
||||
.map(|score| score.held_up)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn a_year_of_steps_tracking_mood(store: &Arc<InMemoryStore>, user: &User) {
|
||||
for day in 0..40 {
|
||||
logged(store, user, a_rising_mood_for(day), day).await;
|
||||
walked(store, user, 1_000 + day as u32 * 100, day).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_relationship_that_survives_the_number_of_things_tested_is_marked() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
a_year_of_steps_tracking_mood(&store, &user).await;
|
||||
|
||||
let rows = correlations_with_rate(&store, &user, 30, 0.10).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert!(held_up(steps, CorrelationStrategy::Spearman));
|
||||
assert!(held_up(steps, CorrelationStrategy::Kendall));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_moon_does_not_survive_the_correction_on_ordinary_data() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
a_year_of_steps_tracking_mood(&store, &user).await;
|
||||
|
||||
let rows = correlations_with_rate(&store, &user, 30, 0.10).await;
|
||||
let moon = row_for(&rows, CorrelationInput::MoonPhase);
|
||||
|
||||
assert!(!held_up(moon, CorrelationStrategy::Spearman));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keeping_more_activities_does_not_change_whether_a_metric_is_marked() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
a_year_of_steps_tracking_mood(&store, &user).await;
|
||||
|
||||
let before = correlations_with_rate(&store, &user, 30, 0.10).await;
|
||||
let steps_before = held_up(
|
||||
row_for(&before, CorrelationInput::Metric(MetricKind::Steps)),
|
||||
CorrelationStrategy::Spearman,
|
||||
);
|
||||
|
||||
for number in 0..20 {
|
||||
let activity = test_activity(user.id().clone(), &format!("activity {number}"));
|
||||
ActivityCommandPort::save(store.as_ref(), &activity)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = deps_free_entries(&store, &user).await;
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
if index % (number + 2) == 0 {
|
||||
store.assign_activities(entry.id(), vec![activity.id().clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let after = correlations_with_rate(&store, &user, 30, 0.10).await;
|
||||
let steps_after = held_up(
|
||||
row_for(&after, CorrelationInput::Metric(MetricKind::Steps)),
|
||||
CorrelationStrategy::Spearman,
|
||||
);
|
||||
|
||||
assert!(
|
||||
after.len() > before.len(),
|
||||
"the activities should have produced rows of their own"
|
||||
);
|
||||
assert_eq!(
|
||||
steps_before, steps_after,
|
||||
"a metric's mark must not depend on how many activities the user keeps"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_strategy_can_hold_up_while_another_does_not() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..30 {
|
||||
let mood = if day == 29 {
|
||||
Mood::Awful
|
||||
} else {
|
||||
a_rising_mood_for(day)
|
||||
};
|
||||
logged(&store, &user, mood, day).await;
|
||||
|
||||
let steps = if day == 29 {
|
||||
200_000
|
||||
} else {
|
||||
1_000 + day as u32 * 100
|
||||
};
|
||||
walked(&store, &user, steps, day).await;
|
||||
}
|
||||
|
||||
let rows = correlations_with_rate(&store, &user, 30, 0.10).await;
|
||||
let steps = row_for(&rows, CorrelationInput::Metric(MetricKind::Steps));
|
||||
|
||||
assert!(
|
||||
held_up(steps, CorrelationStrategy::Spearman),
|
||||
"the order survives one outlying day"
|
||||
);
|
||||
assert!(
|
||||
!held_up(steps, CorrelationStrategy::Pearson),
|
||||
"the straight line does not"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stricter_rate_marks_less() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..30 {
|
||||
let mood = match day % 6 {
|
||||
0 | 1 => Mood::Bad,
|
||||
2 | 3 => Mood::Meh,
|
||||
_ => Mood::Good,
|
||||
};
|
||||
logged(&store, &user, mood, day).await;
|
||||
walked(&store, &user, 1_000 + day as u32 * 40, day).await;
|
||||
}
|
||||
|
||||
let loose = correlations_with_rate(&store, &user, 30, 0.90).await;
|
||||
let strict = correlations_with_rate(&store, &user, 30, 0.000_001).await;
|
||||
|
||||
let marked_loosely = held_up(
|
||||
row_for(&loose, CorrelationInput::Metric(MetricKind::Steps)),
|
||||
CorrelationStrategy::Spearman,
|
||||
);
|
||||
let marked_strictly = held_up(
|
||||
row_for(&strict, CorrelationInput::Metric(MetricKind::Steps)),
|
||||
CorrelationStrategy::Spearman,
|
||||
);
|
||||
|
||||
assert!(marked_loosely);
|
||||
assert!(!marked_strictly);
|
||||
}
|
||||
|
||||
async fn turn_cycle_tracking_on(store: &Arc<InMemoryStore>, user: &User) {
|
||||
let deps = application::user::use_cases::set_preferences::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
application::user::use_cases::set_preferences::execute(user.id().clone(), true, &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_cycle_is_not_correlated_unless_it_is_tracked() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|row| row.input == CorrelationInput::CycleProgress),
|
||||
"an untracked cycle is not an input at all"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tracked_cycle_is_scored_like_anything_else_continuous() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw();
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
turn_cycle_tracking_on(&store, &user).await;
|
||||
|
||||
for day in 0..40 {
|
||||
logged(&store, &user, a_rising_mood_for(day), day).await;
|
||||
}
|
||||
|
||||
let cycle_deps = application::cycle::use_cases::record_cycle_start::Deps {
|
||||
command: store.clone(),
|
||||
preferences: store.clone(),
|
||||
};
|
||||
for start in [0, 28] {
|
||||
application::cycle::use_cases::record_cycle_start::execute(
|
||||
user.id().clone(),
|
||||
date_of(start),
|
||||
&cycle_deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let rows = correlations(&store, &user).await;
|
||||
let cycle = row_for(&rows, CorrelationInput::CycleProgress);
|
||||
|
||||
assert_eq!(cycle.sample_size, 40);
|
||||
assert_eq!(
|
||||
cycle
|
||||
.scores
|
||||
.iter()
|
||||
.map(|s| s.strategy.name())
|
||||
.collect::<Vec<_>>(),
|
||||
["pearson", "spearman", "kendall"]
|
||||
);
|
||||
}
|
||||
2
crates/application/tests/cycle.rs
Normal file
2
crates/application/tests/cycle.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "cycle/cycle_test.rs"]
|
||||
mod cycle_test;
|
||||
172
crates/application/tests/cycle/cycle_test.rs
Normal file
172
crates/application/tests/cycle/cycle_test.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Date;
|
||||
use domain::ports::UserCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::cycle::use_cases::read_cycle::CycleView;
|
||||
use application::cycle::use_cases::{forget_cycle_start, read_cycle, record_cycle_start};
|
||||
use application::user::use_cases::set_preferences;
|
||||
|
||||
async fn a_user_in_warsaw(store: &Arc<InMemoryStore>) -> User {
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
user
|
||||
}
|
||||
|
||||
fn on(day: &str) -> Date {
|
||||
Date::from_persistence(day.parse().unwrap())
|
||||
}
|
||||
|
||||
async fn turn_tracking(store: &Arc<InMemoryStore>, user: &User, on: bool) {
|
||||
let deps = set_preferences::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
|
||||
set_preferences::execute(user.id().clone(), on, &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn record(
|
||||
store: &Arc<InMemoryStore>,
|
||||
user: &User,
|
||||
day: &str,
|
||||
) -> Result<(), application::errors::ApplicationError> {
|
||||
let deps = record_cycle_start::Deps {
|
||||
command: store.clone(),
|
||||
preferences: store.clone(),
|
||||
};
|
||||
|
||||
record_cycle_start::execute(user.id().clone(), on(day), &deps).await
|
||||
}
|
||||
|
||||
async fn forget(store: &Arc<InMemoryStore>, user: &User, day: &str) {
|
||||
let deps = forget_cycle_start::Deps {
|
||||
command: store.clone(),
|
||||
};
|
||||
|
||||
forget_cycle_start::execute(user.id().clone(), on(day), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn read(store: &Arc<InMemoryStore>, user: &User) -> CycleView {
|
||||
let deps = read_cycle::Deps {
|
||||
query: store.clone(),
|
||||
preferences: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
read_cycle::execute(user.id().clone(), &deps).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tracking_is_off_until_it_is_turned_on() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let view = read(&store, &user).await;
|
||||
|
||||
assert!(!view.tracking);
|
||||
assert!(view.starts.is_empty());
|
||||
assert!(view.today.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_can_be_recorded_while_tracking_is_off() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let refused = record(&store, &user, "2026-01-01").await.unwrap_err();
|
||||
|
||||
assert!(refused.to_string().contains("off"), "got {refused}");
|
||||
assert!(read(&store, &user).await.starts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recorded_start_comes_back_in_order() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
turn_tracking(&store, &user, true).await;
|
||||
|
||||
record(&store, &user, "2026-01-29").await.unwrap();
|
||||
record(&store, &user, "2026-01-01").await.unwrap();
|
||||
|
||||
let view = read(&store, &user).await;
|
||||
|
||||
assert_eq!(
|
||||
view.starts
|
||||
.iter()
|
||||
.map(|d| d.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
["2026-01-01", "2026-01-29"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recording_the_same_day_twice_records_it_once() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
turn_tracking(&store, &user, true).await;
|
||||
|
||||
record(&store, &user, "2026-01-01").await.unwrap();
|
||||
record(&store, &user, "2026-01-01").await.unwrap();
|
||||
|
||||
assert_eq!(read(&store, &user).await.starts.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_start_can_be_taken_back() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
turn_tracking(&store, &user, true).await;
|
||||
record(&store, &user, "2026-01-01").await.unwrap();
|
||||
|
||||
forget(&store, &user, "2026-01-01").await;
|
||||
|
||||
assert!(read(&store, &user).await.starts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turning_tracking_off_hides_the_cycle_without_forgetting_it() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
turn_tracking(&store, &user, true).await;
|
||||
record(&store, &user, "2026-01-01").await.unwrap();
|
||||
|
||||
turn_tracking(&store, &user, false).await;
|
||||
let hidden = read(&store, &user).await;
|
||||
|
||||
turn_tracking(&store, &user, true).await;
|
||||
let shown = read(&store, &user).await;
|
||||
|
||||
assert!(!hidden.tracking);
|
||||
assert!(
|
||||
hidden.starts.is_empty(),
|
||||
"nothing is offered while it is off"
|
||||
);
|
||||
assert_eq!(shown.starts.len(), 1, "and nothing was thrown away");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_accounts_cycle_is_not_anothers() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let mine = a_user_in_warsaw(&store).await;
|
||||
|
||||
let mut theirs = test_user("bob");
|
||||
theirs.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &theirs)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
turn_tracking(&store, &mine, true).await;
|
||||
turn_tracking(&store, &theirs, true).await;
|
||||
record(&store, &mine, "2026-01-01").await.unwrap();
|
||||
|
||||
assert_eq!(read(&store, &mine).await.starts.len(), 1);
|
||||
assert!(read(&store, &theirs).await.starts.is_empty());
|
||||
}
|
||||
2
crates/application/tests/day.rs
Normal file
2
crates/application/tests/day.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "day/timezone_test.rs"]
|
||||
mod timezone_test;
|
||||
34
crates/application/tests/day/timezone_test.rs
Normal file
34
crates/application/tests/day/timezone_test.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{UserCommandPort, UserQueryPort};
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::Timezone;
|
||||
|
||||
use application::day::timezone_for;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_users_timezone_is_resolved_when_they_have_one() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let users: Arc<dyn UserQueryPort> = store.clone();
|
||||
let timezone = timezone_for(user.id(), &users).await.unwrap();
|
||||
|
||||
assert_eq!(timezone.value(), "Europe/Warsaw");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_user_with_no_timezone_gets_an_error_that_says_what_to_do() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("bob");
|
||||
store.save(&user).await.unwrap();
|
||||
|
||||
let users: Arc<dyn UserQueryPort> = store.clone();
|
||||
let error = timezone_for(user.id(), &users).await.unwrap_err();
|
||||
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("timezone"));
|
||||
assert!(message.contains("settings"));
|
||||
}
|
||||
5
crates/application/tests/deletion.rs
Normal file
5
crates/application/tests/deletion.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[path = "deletion/ordering_test.rs"]
|
||||
mod ordering_test;
|
||||
|
||||
#[path = "deletion/best_effort_test.rs"]
|
||||
mod best_effort_test;
|
||||
175
crates/application/tests/deletion/best_effort_test.rs
Normal file
175
crates/application/tests/deletion/best_effort_test.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::{ApiToken, TokenDigest};
|
||||
use domain::attachment::PhotoId;
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{Mood, MoodEntry};
|
||||
use domain::ports::{
|
||||
ApiTokenSecretPort, DailyMetricQueryPort, EntryDimensionPort, MoodEntryCommandPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::{
|
||||
FakeApiTokenSecret, FakeMediaStorage, InMemoryDimensionStore, InMemoryStore, OneTokenStore,
|
||||
RefusingApiTokenStore, RefusingRejectionTrace,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::api_token::use_cases::authenticate_api_token;
|
||||
use application::entry::commands::UpdateEntryCommand;
|
||||
use application::entry::use_cases::update_entry;
|
||||
use application::import::commands::{ImportDailyMetricsCommand, ImportedDay, ImportedMetric};
|
||||
use application::import::use_cases::import_daily_metrics;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_blob_that_refuses_to_go_does_not_stop_the_entry_being_edited() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let photos = Arc::new(InMemoryDimensionStore::new(DimensionKind::Photos));
|
||||
let media = Arc::new(FakeMediaStorage::refusing_to_delete());
|
||||
|
||||
let owner = UserId::generate();
|
||||
let entry = MoodEntry::new(
|
||||
owner.clone(),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
|
||||
);
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let removed = PhotoId::generate();
|
||||
media.put_photo(&removed, b"a photograph");
|
||||
photos
|
||||
.save(entry.id(), &[DimensionValue::Photos(vec![removed.clone()])])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = update_entry::Deps {
|
||||
command: store.clone(),
|
||||
dimensions: vec![photos.clone() as Arc<dyn EntryDimensionPort>],
|
||||
query: store.clone(),
|
||||
media_storage: media.clone(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let edited = update_entry::execute(
|
||||
UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: Some(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
|
||||
),
|
||||
dimensions: Vec::new(),
|
||||
},
|
||||
owner,
|
||||
&deps,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
edited.is_ok(),
|
||||
"a blob store that will not delete must not block an edit: {edited:?}"
|
||||
);
|
||||
assert_eq!(edited.unwrap().mood(), Mood::Rad);
|
||||
assert_eq!(
|
||||
media.deletions_attempted().len(),
|
||||
1,
|
||||
"the deletion was still attempted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unwritable_rejection_trace_does_not_fail_the_import() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = UserId::generate();
|
||||
|
||||
let deps = import_daily_metrics::Deps {
|
||||
metrics: store.clone(),
|
||||
rejections: Arc::new(RefusingRejectionTrace),
|
||||
};
|
||||
|
||||
let outcome = import_daily_metrics::execute(
|
||||
ImportDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
provider: ProviderName::new("healthkit").unwrap(),
|
||||
days: vec![ImportedDay {
|
||||
date: "2026-08-20".into(),
|
||||
metrics: vec![
|
||||
ImportedMetric {
|
||||
kind: "steps".into(),
|
||||
value: Some(8_412),
|
||||
},
|
||||
ImportedMetric {
|
||||
kind: "hrv".into(),
|
||||
value: Some(9_999),
|
||||
},
|
||||
],
|
||||
}],
|
||||
maximum_days: 90,
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.expect("a trace that cannot be written must not lose the good readings");
|
||||
|
||||
assert_eq!(outcome.accepted, 1);
|
||||
assert_eq!(outcome.rejected.len(), 1, "and the caller is still told");
|
||||
|
||||
let span = domain::entry::DateSpan::new(
|
||||
domain::entry::Date::from_persistence("2026-08-20".parse().unwrap()),
|
||||
domain::entry::Date::from_persistence("2026-08-20".parse().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let stored = DailyMetricQueryPort::find_by_span(store.as_ref(), &user_id, &span)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.len(), 1, "the valid reading was still stored");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_store_that_cannot_record_a_use_still_authenticates() {
|
||||
let secrets = Arc::new(FakeApiTokenSecret::new());
|
||||
let secret = secrets.mint();
|
||||
|
||||
let token = ApiToken::new(
|
||||
UserId::generate(),
|
||||
ProviderName::new("iphone-shortcuts").unwrap(),
|
||||
secrets.digest(&secret),
|
||||
);
|
||||
|
||||
let deps = authenticate_api_token::Deps {
|
||||
query: Arc::new(OneTokenStore(token.clone())),
|
||||
command: Arc::new(RefusingApiTokenStore),
|
||||
secrets,
|
||||
};
|
||||
|
||||
let authenticated = authenticate_api_token::execute(&secret, &deps).await;
|
||||
|
||||
assert!(
|
||||
authenticated.is_ok(),
|
||||
"a token must keep working when its last-used stamp cannot be written: {authenticated:?}"
|
||||
);
|
||||
assert_eq!(authenticated.unwrap().id(), token.id());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_digest_that_matches_nothing_is_still_refused() {
|
||||
let secrets = Arc::new(FakeApiTokenSecret::new());
|
||||
let real = secrets.mint();
|
||||
|
||||
let token = ApiToken::new(
|
||||
UserId::generate(),
|
||||
ProviderName::new("iphone-shortcuts").unwrap(),
|
||||
TokenDigest::from_persistence("some other digest".into()),
|
||||
);
|
||||
|
||||
let deps = authenticate_api_token::Deps {
|
||||
query: Arc::new(OneTokenStore(token)),
|
||||
command: Arc::new(RefusingApiTokenStore),
|
||||
secrets,
|
||||
};
|
||||
|
||||
assert!(
|
||||
authenticate_api_token::execute(&real, &deps).await.is_err(),
|
||||
"degrading on a write failure must not degrade into accepting anything"
|
||||
);
|
||||
}
|
||||
232
crates/application/tests/deletion/ordering_test.rs
Normal file
232
crates/application/tests/deletion/ordering_test.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{DateRange, Mood, MoodEntry};
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, CascadeDeletePort, EntryDimensionPort, MoodEntryCommandPort,
|
||||
ReminderQueryPort, UserCommandPort,
|
||||
};
|
||||
use domain::testing::{FakeMediaStorage, InMemoryDimensionStore, InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::entry::use_cases::delete_entries_by_date_range;
|
||||
use application::user::use_cases::{clear_data, delete_user};
|
||||
|
||||
struct Doomed {
|
||||
store: Arc<InMemoryStore>,
|
||||
photos: Arc<InMemoryDimensionStore>,
|
||||
voice_memos: Arc<InMemoryDimensionStore>,
|
||||
user: User,
|
||||
photo_id: PhotoId,
|
||||
memo_id: VoiceMemoId,
|
||||
}
|
||||
|
||||
async fn an_entry_with_media(media: &Arc<FakeMediaStorage>) -> Doomed {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let photos = Arc::new(InMemoryDimensionStore::new(DimensionKind::Photos));
|
||||
let voice_memos = Arc::new(InMemoryDimensionStore::new(DimensionKind::VoiceMemos));
|
||||
|
||||
store.cascades_to(&photos);
|
||||
store.cascades_to(&voice_memos);
|
||||
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let entry = MoodEntry::new(
|
||||
user.id().clone(),
|
||||
Mood::Good,
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
|
||||
);
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let photo_id = PhotoId::generate();
|
||||
let memo_id = VoiceMemoId::generate();
|
||||
media.put_photo(&photo_id, b"a photograph");
|
||||
media.put_voice_memo(&memo_id, b"a recording");
|
||||
|
||||
photos
|
||||
.save(
|
||||
entry.id(),
|
||||
&[DimensionValue::Photos(vec![photo_id.clone()])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
voice_memos
|
||||
.save(
|
||||
entry.id(),
|
||||
&[DimensionValue::VoiceMemos(vec![memo_id.clone()])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Doomed {
|
||||
store,
|
||||
photos,
|
||||
voice_memos,
|
||||
user,
|
||||
photo_id,
|
||||
memo_id,
|
||||
}
|
||||
}
|
||||
|
||||
impl Doomed {
|
||||
fn dimensions(&self) -> Vec<Arc<dyn EntryDimensionPort>> {
|
||||
vec![
|
||||
self.photos.clone() as Arc<dyn EntryDimensionPort>,
|
||||
self.voice_memos.clone() as Arc<dyn EntryDimensionPort>,
|
||||
]
|
||||
}
|
||||
|
||||
fn everything_ever() -> DateRange {
|
||||
DateRange::new(
|
||||
chrono::DateTime::parse_from_rfc3339("2000-01-01T00:00:00+00:00").unwrap(),
|
||||
chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00").unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_date_range_takes_the_blobs_with_it() {
|
||||
let media = Arc::new(FakeMediaStorage::holding_nothing());
|
||||
let doomed = an_entry_with_media(&media).await;
|
||||
|
||||
let deps = delete_entries_by_date_range::Deps {
|
||||
cascade: doomed.store.clone(),
|
||||
query: doomed.store.clone(),
|
||||
dimensions: doomed.dimensions(),
|
||||
media_storage: media.clone(),
|
||||
};
|
||||
|
||||
let deleted = delete_entries_by_date_range::execute(
|
||||
doomed.user.id().clone(),
|
||||
&Doomed::everything_ever(),
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deleted, 1);
|
||||
assert!(
|
||||
!media.holds_photo(&doomed.photo_id),
|
||||
"the photo blob was orphaned: media must be resolved before the cascade clears the dimensions"
|
||||
);
|
||||
assert!(
|
||||
!media.holds_voice_memo(&doomed.memo_id),
|
||||
"the voice memo blob was orphaned"
|
||||
);
|
||||
assert_eq!(media.blobs_held(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_cascade_really_does_clear_the_dimension_values() {
|
||||
let media = Arc::new(FakeMediaStorage::holding_nothing());
|
||||
let doomed = an_entry_with_media(&media).await;
|
||||
assert_eq!(doomed.store.entry_count(), 1);
|
||||
|
||||
CascadeDeletePort::delete_entries_in_range(
|
||||
doomed.store.as_ref(),
|
||||
doomed.user.id(),
|
||||
&Doomed::everything_ever(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doomed.photos.holds_count(),
|
||||
0,
|
||||
"a fake that keeps dimension values is more forgiving than the database"
|
||||
);
|
||||
assert_eq!(doomed.voice_memos.holds_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_users_data_takes_the_blobs_with_it() {
|
||||
let media = Arc::new(FakeMediaStorage::holding_nothing());
|
||||
let doomed = an_entry_with_media(&media).await;
|
||||
|
||||
let deps = clear_data::Deps {
|
||||
entry_query: doomed.store.clone(),
|
||||
dimensions: doomed.dimensions(),
|
||||
cascade: doomed.store.clone(),
|
||||
media_storage: media.clone(),
|
||||
};
|
||||
|
||||
clear_data::execute(doomed.user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
media.blobs_held(),
|
||||
0,
|
||||
"clear_data has the same ordering requirement as deleting a range"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_an_account_takes_the_blobs_with_it() {
|
||||
let media = Arc::new(FakeMediaStorage::holding_nothing());
|
||||
let doomed = an_entry_with_media(&media).await;
|
||||
|
||||
let deps = delete_user::Deps {
|
||||
entry_query: doomed.store.clone(),
|
||||
dimensions: doomed.dimensions(),
|
||||
cascade: doomed.store.clone(),
|
||||
media_storage: media.clone(),
|
||||
user_query: doomed.store.clone(),
|
||||
events: doomed.store.clone(),
|
||||
};
|
||||
|
||||
delete_user::execute(doomed.user.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
media.blobs_held(),
|
||||
0,
|
||||
"deleting an account has the same ordering requirement"
|
||||
);
|
||||
let _ = ActivityQueryPort::find_by_user(doomed.store.as_ref(), doomed.user.id()).await;
|
||||
let _ = ReminderQueryPort::find_by_user(doomed.store.as_ref(), doomed.user.id()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_blob_store_that_refuses_to_delete_does_not_save_the_entries() {
|
||||
let media = Arc::new(FakeMediaStorage::refusing_to_delete());
|
||||
let doomed = an_entry_with_media(&media).await;
|
||||
|
||||
let deps = delete_entries_by_date_range::Deps {
|
||||
cascade: doomed.store.clone(),
|
||||
query: doomed.store.clone(),
|
||||
dimensions: doomed.dimensions(),
|
||||
media_storage: media.clone(),
|
||||
};
|
||||
|
||||
let deleted = delete_entries_by_date_range::execute(
|
||||
doomed.user.id().clone(),
|
||||
&Doomed::everything_ever(),
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
deleted, 1,
|
||||
"a blob that will not go must not keep the entry alive"
|
||||
);
|
||||
assert_eq!(doomed.store.entry_count(), 0);
|
||||
assert_eq!(
|
||||
media.deletions_attempted().len(),
|
||||
2,
|
||||
"both blobs were attempted even though the first refused"
|
||||
);
|
||||
assert_eq!(
|
||||
media.blobs_held(),
|
||||
2,
|
||||
"and the blobs are still there, orphaned but reported"
|
||||
);
|
||||
}
|
||||
@@ -21,3 +21,9 @@ mod mood_stats_test;
|
||||
|
||||
#[path = "entry/replace_activity_test.rs"]
|
||||
mod replace_activity_test;
|
||||
|
||||
#[path = "entry/dimension_test.rs"]
|
||||
mod dimension_test;
|
||||
|
||||
#[path = "entry/calendar_test.rs"]
|
||||
mod calendar_test;
|
||||
|
||||
230
crates/application/tests/entry/calendar_test.rs
Normal file
230
crates/application/tests/entry/calendar_test.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::entry::{DateRange, Mood, MoodEntry};
|
||||
use domain::ports::{MoodEntryCommandPort, UserCommandPort};
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::entry::use_cases::get_calendar::{self, CalendarDay};
|
||||
|
||||
fn user_in(name: &str, timezone: &str) -> User {
|
||||
let mut user = test_user(name);
|
||||
user.update_timezone(Some(Timezone::new(timezone).unwrap()));
|
||||
user
|
||||
}
|
||||
|
||||
fn at(instant: &str) -> DateTime<FixedOffset> {
|
||||
DateTime::parse_from_rfc3339(instant).unwrap()
|
||||
}
|
||||
|
||||
async fn calendar_of(store: &Arc<InMemoryStore>, user: &User) -> Vec<CalendarDay> {
|
||||
let deps = get_calendar::Deps {
|
||||
query: store.clone(),
|
||||
dimensions: Vec::new(),
|
||||
cycles: store.clone(),
|
||||
preferences: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let range = DateRange::new(
|
||||
at("2026-08-01T00:00:00+00:00"),
|
||||
at("2026-08-31T23:59:59+00:00"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
get_calendar::execute(user.id().clone(), range, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn logged(store: &Arc<InMemoryStore>, user: &User, mood: Mood, instant: &str) {
|
||||
let entry = MoodEntry::new(user.id().clone(), mood, at(instant));
|
||||
MoodEntryCommandPort::save(store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_day_of_one_awful_and_one_rad_does_not_report_rad() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
logged(&store, &user, Mood::Awful, "2026-08-20T09:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Rad, "2026-08-20T21:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
|
||||
assert_eq!(days.len(), 1);
|
||||
let mood = days[0].day_mood.expect("the day has entries");
|
||||
assert_eq!(mood.rounded(), Mood::Meh);
|
||||
assert!((mood.value() - 3.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_days_that_were_logged_appear() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T09:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Bad, "2026-08-24T09:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
let dates: Vec<String> = days.iter().map(|day| day.date.to_string()).collect();
|
||||
|
||||
assert_eq!(dates, ["2026-08-20", "2026-08-24"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_late_evening_entry_belongs_to_the_day_the_user_was_living() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T23:30:00+00:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
let dates: Vec<String> = days.iter().map(|day| day.date.to_string()).collect();
|
||||
|
||||
assert_eq!(dates, ["2026-08-21"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_entry_on_a_day_moves_its_mood() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
logged(&store, &user, Mood::Meh, "2026-08-20T08:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Meh, "2026-08-20T12:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Meh, "2026-08-20T16:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Meh, "2026-08-20T20:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Awful, "2026-08-20T23:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
let mood = days[0].day_mood.expect("the day has entries");
|
||||
|
||||
assert!(mood.value() < 3.0, "the awful entry was discarded");
|
||||
}
|
||||
|
||||
async fn tracking_cycle_from(store: &Arc<InMemoryStore>, user: &User, starts: &[&str]) {
|
||||
let preference_deps = application::user::use_cases::set_preferences::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
application::user::use_cases::set_preferences::execute(
|
||||
user.id().clone(),
|
||||
true,
|
||||
&preference_deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cycle_deps = application::cycle::use_cases::record_cycle_start::Deps {
|
||||
command: store.clone(),
|
||||
preferences: store.clone(),
|
||||
};
|
||||
for start in starts {
|
||||
application::cycle::use_cases::record_cycle_start::execute(
|
||||
user.id().clone(),
|
||||
domain::entry::Date::from_persistence(start.parse().unwrap()),
|
||||
&cycle_deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_day_carries_no_cycle_day_when_the_cycle_is_not_tracked() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
tracking_cycle_from(&store, &user, &["2026-08-11"]).await;
|
||||
|
||||
let turn_off = application::user::use_cases::set_preferences::Deps {
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
};
|
||||
application::user::use_cases::set_preferences::execute(user.id().clone(), false, &turn_off)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T09:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
|
||||
assert!(
|
||||
days[0].cycle_day.is_none(),
|
||||
"a recorded start must stay invisible while tracking is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tracked_day_carries_the_cycle_day_derived_for_it() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
tracking_cycle_from(&store, &user, &["2026-08-11"]).await;
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T09:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
|
||||
assert_eq!(days[0].cycle_day.map(|day| day.value()), Some(10));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_day_before_the_first_recorded_start_has_no_cycle_day() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
tracking_cycle_from(&store, &user, &["2026-08-25"]).await;
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T09:00:00+02:00").await;
|
||||
|
||||
let days = calendar_of(&store, &user).await;
|
||||
|
||||
assert!(days[0].cycle_day.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correcting_a_start_changes_the_cycle_day_of_every_affected_date() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
tracking_cycle_from(&store, &user, &["2026-08-11"]).await;
|
||||
|
||||
logged(&store, &user, Mood::Good, "2026-08-20T09:00:00+02:00").await;
|
||||
logged(&store, &user, Mood::Meh, "2026-08-22T09:00:00+02:00").await;
|
||||
|
||||
let before: Vec<u16> = calendar_of(&store, &user)
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|day| day.cycle_day.map(|cycle| cycle.value()))
|
||||
.collect();
|
||||
|
||||
let forget = application::cycle::use_cases::forget_cycle_start::Deps {
|
||||
command: store.clone(),
|
||||
};
|
||||
application::cycle::use_cases::forget_cycle_start::execute(
|
||||
user.id().clone(),
|
||||
domain::entry::Date::from_persistence("2026-08-11".parse().unwrap()),
|
||||
&forget,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tracking_cycle_from(&store, &user, &["2026-08-13"]).await;
|
||||
|
||||
let after: Vec<u16> = calendar_of(&store, &user)
|
||||
.await
|
||||
.iter()
|
||||
.filter_map(|day| day.cycle_day.map(|cycle| cycle.value()))
|
||||
.collect();
|
||||
|
||||
assert_eq!(before, [10, 12]);
|
||||
assert_eq!(after, [8, 10], "one correction moved both days");
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::Mood;
|
||||
use domain::testing::{test_entry, test_logged_at, test_user};
|
||||
use domain::ports::EntryDimensionPort;
|
||||
use domain::testing::{InMemoryDimensionStore, InMemoryStore, test_logged_at, test_user};
|
||||
|
||||
use application::entry::commands::CreateEntryCommand;
|
||||
use application::entry::use_cases::create_entry;
|
||||
|
||||
fn deps() -> (Arc<domain::testing::InMemoryStore>, create_entry::Deps) {
|
||||
let store = Arc::new(domain::testing::InMemoryStore::new());
|
||||
fn deps() -> (Arc<InMemoryStore>, create_entry::Deps) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let content: Arc<dyn EntryDimensionPort> =
|
||||
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content));
|
||||
let activities: Arc<dyn EntryDimensionPort> =
|
||||
Arc::new(InMemoryDimensionStore::new(DimensionKind::Activities));
|
||||
|
||||
let deps = create_entry::Deps {
|
||||
entries: store.clone(),
|
||||
dimensions: vec![content, activities],
|
||||
events: store.clone(),
|
||||
};
|
||||
(store, deps)
|
||||
@@ -24,10 +32,7 @@ async fn creates_entry_with_required_fields() {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Good,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let entry = create_entry::execute(cmd, &deps).await.unwrap();
|
||||
@@ -38,7 +43,7 @@ async fn creates_entry_with_required_fields() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_entry_with_all_optional_fields() {
|
||||
async fn creates_entry_with_optional_dimensions() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("bob");
|
||||
let activity = domain::testing::test_activity(user.id().clone(), "gaming");
|
||||
@@ -47,16 +52,14 @@ async fn creates_entry_with_all_optional_fields() {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![activity.id().clone()],
|
||||
content: Some(domain::testing::test_content("great day")),
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
dimensions: vec![
|
||||
DimensionValue::Content(domain::testing::test_content("great day")),
|
||||
DimensionValue::activities(vec![activity.id().clone()]),
|
||||
],
|
||||
};
|
||||
|
||||
let entry = create_entry::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(entry.mood(), Mood::Rad);
|
||||
assert_eq!(entry.activities().len(), 1);
|
||||
assert!(entry.content().is_some());
|
||||
assert_eq!(store.entry_count(), 1);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ async fn deletes_existing_entry() {
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = delete_entry::Deps {
|
||||
dimensions: vec![],
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
@@ -33,6 +34,7 @@ async fn deletes_existing_entry() {
|
||||
async fn deleting_nonexistent_entry_fails() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = delete_entry::Deps {
|
||||
dimensions: vec![],
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
@@ -56,6 +58,7 @@ async fn rejects_delete_by_different_user() {
|
||||
store.save(&entry).await.unwrap();
|
||||
|
||||
let deps = delete_entry::Deps {
|
||||
dimensions: vec![],
|
||||
command: store.clone(),
|
||||
query: store.clone(),
|
||||
events: store.clone(),
|
||||
|
||||
139
crates/application/tests/entry/dimension_test.rs
Normal file
139
crates/application/tests/entry/dimension_test.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{Mood, MoodEntry};
|
||||
use domain::ports::EntryDimensionPort;
|
||||
use domain::testing::{InMemoryDimensionStore, test_content, test_logged_at, test_user};
|
||||
|
||||
use application::entry::commands::CreateEntryCommand;
|
||||
use application::entry::composition::EntryComposer;
|
||||
use application::entry::use_cases::create_entry;
|
||||
|
||||
struct Dimensions {
|
||||
content: Arc<InMemoryDimensionStore>,
|
||||
activities: Arc<InMemoryDimensionStore>,
|
||||
photos: Arc<InMemoryDimensionStore>,
|
||||
voice_memos: Arc<InMemoryDimensionStore>,
|
||||
}
|
||||
|
||||
impl Dimensions {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
content: Arc::new(InMemoryDimensionStore::new(DimensionKind::Content)),
|
||||
activities: Arc::new(InMemoryDimensionStore::new(DimensionKind::Activities)),
|
||||
photos: Arc::new(InMemoryDimensionStore::new(DimensionKind::Photos)),
|
||||
voice_memos: Arc::new(InMemoryDimensionStore::new(DimensionKind::VoiceMemos)),
|
||||
}
|
||||
}
|
||||
|
||||
fn ports(&self) -> Vec<Arc<dyn EntryDimensionPort>> {
|
||||
vec![
|
||||
self.content.clone(),
|
||||
self.activities.clone(),
|
||||
self.photos.clone(),
|
||||
self.voice_memos.clone(),
|
||||
]
|
||||
}
|
||||
|
||||
fn composer(&self) -> EntryComposer {
|
||||
EntryComposer::new(self.ports())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_saved_for_an_entry_is_returned_when_that_entry_is_composed() {
|
||||
let dimensions = Dimensions::new();
|
||||
let user = test_user("alice");
|
||||
let entry = MoodEntry::new(user.id().clone(), Mood::Good, test_logged_at());
|
||||
|
||||
dimensions.content.put(
|
||||
entry.id(),
|
||||
DimensionValue::Content(test_content("went for a walk")),
|
||||
);
|
||||
|
||||
let composed = dimensions.composer().compose(vec![entry]).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
composed[0].content().map(|c| c.value()),
|
||||
Some("went for a walk")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_entry_with_no_content_composes_with_no_dimensions() {
|
||||
let dimensions = Dimensions::new();
|
||||
let user = test_user("carol");
|
||||
let entry = MoodEntry::new(user.id().clone(), Mood::Meh, test_logged_at());
|
||||
|
||||
let composed = dimensions.composer().compose(vec![entry]).await.unwrap();
|
||||
|
||||
assert!(composed[0].dimensions.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_entry_in_a_page_composes_with_only_its_own_content() {
|
||||
let dimensions = Dimensions::new();
|
||||
let user = test_user("dave");
|
||||
|
||||
let first = MoodEntry::new(user.id().clone(), Mood::Good, test_logged_at());
|
||||
let second = MoodEntry::new(user.id().clone(), Mood::Bad, test_logged_at());
|
||||
let third = MoodEntry::new(user.id().clone(), Mood::Rad, test_logged_at());
|
||||
|
||||
dimensions
|
||||
.content
|
||||
.put(first.id(), DimensionValue::Content(test_content("mine")));
|
||||
dimensions
|
||||
.content
|
||||
.put(third.id(), DimensionValue::Content(test_content("theirs")));
|
||||
|
||||
let composed = dimensions
|
||||
.composer()
|
||||
.compose(vec![first, second, third])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(composed[0].content().map(|c| c.value()), Some("mine"));
|
||||
assert!(composed[1].dimensions.is_empty());
|
||||
assert_eq!(composed[2].content().map(|c| c.value()), Some("theirs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_entry_composes_every_kind_of_dimension_it_has() {
|
||||
let dimensions = Dimensions::new();
|
||||
let store = Arc::new(domain::testing::InMemoryStore::new());
|
||||
let user = test_user("erin");
|
||||
|
||||
let activity_id = ActivityId::generate();
|
||||
let photo_id = PhotoId::generate();
|
||||
let voice_memo_id = VoiceMemoId::generate();
|
||||
|
||||
let deps = create_entry::Deps {
|
||||
entries: store.clone(),
|
||||
dimensions: dimensions.ports(),
|
||||
events: store.clone(),
|
||||
};
|
||||
|
||||
let cmd = CreateEntryCommand {
|
||||
user_id: user.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
dimensions: vec![
|
||||
DimensionValue::Content(test_content("a full day")),
|
||||
DimensionValue::Activities(vec![activity_id.clone()]),
|
||||
DimensionValue::Photos(vec![photo_id.clone()]),
|
||||
DimensionValue::VoiceMemos(vec![voice_memo_id.clone()]),
|
||||
],
|
||||
};
|
||||
|
||||
let entry = create_entry::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
let composed = dimensions.composer().compose(vec![entry]).await.unwrap();
|
||||
let composed = &composed[0];
|
||||
|
||||
assert_eq!(composed.content().map(|c| c.value()), Some("a full day"));
|
||||
assert_eq!(composed.activities(), [activity_id]);
|
||||
assert_eq!(composed.photos(), [photo_id]);
|
||||
assert_eq!(composed.voice_memos(), [voice_memo_id]);
|
||||
}
|
||||
@@ -44,9 +44,9 @@ async fn filters_by_activity() {
|
||||
let user = test_user("alice");
|
||||
let activity = test_activity(user.id().clone(), "exercise");
|
||||
|
||||
let mut tagged = test_entry(user.id().clone(), Mood::Good);
|
||||
tagged.set_activities(vec![activity.id().clone()]);
|
||||
let tagged = test_entry(user.id().clone(), Mood::Good);
|
||||
store.save(&tagged).await.unwrap();
|
||||
store.assign_activities(tagged.id(), vec![activity.id().clone()]);
|
||||
|
||||
store
|
||||
.save(&test_entry(user.id().clone(), Mood::Meh))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::ports::{MoodEntryCommandPort, UserCommandPort};
|
||||
use domain::testing::{InMemoryStore, test_entry_days_ago, test_user};
|
||||
use domain::user::Timezone;
|
||||
|
||||
use application::entry::queries::MoodStatsQuery;
|
||||
use application::entry::use_cases::get_mood_stats;
|
||||
@@ -10,23 +11,32 @@ use application::entry::use_cases::get_mood_stats;
|
||||
#[tokio::test]
|
||||
async fn computes_stats_across_entries() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
let user = user_in("alice", "Europe/Warsaw");
|
||||
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Good, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Rad, 1))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&test_entry_days_ago(user.id().clone(), Mood::Meh, 2))
|
||||
.await
|
||||
.unwrap();
|
||||
MoodEntryCommandPort::save(
|
||||
&*store,
|
||||
&test_entry_days_ago(user.id().clone(), Mood::Good, 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
MoodEntryCommandPort::save(
|
||||
&*store,
|
||||
&test_entry_days_ago(user.id().clone(), Mood::Rad, 1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
MoodEntryCommandPort::save(
|
||||
&*store,
|
||||
&test_entry_days_ago(user.id().clone(), Mood::Meh, 2),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
UserCommandPort::save(&*store, &user).await.unwrap();
|
||||
|
||||
let deps = get_mood_stats::Deps {
|
||||
query: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let query = MoodStatsQuery {
|
||||
@@ -43,14 +53,18 @@ async fn computes_stats_across_entries() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_entries_give_zero_stats() {
|
||||
async fn a_user_with_no_entries_has_zero_stats() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = user_in("newcomer", "Europe/Warsaw");
|
||||
UserCommandPort::save(&*store, &user).await.unwrap();
|
||||
|
||||
let deps = get_mood_stats::Deps {
|
||||
query: store.clone(),
|
||||
users: store.clone(),
|
||||
};
|
||||
|
||||
let query = MoodStatsQuery {
|
||||
user_id: test_user("nobody").id().clone(),
|
||||
user_id: user.id().clone(),
|
||||
range: None,
|
||||
};
|
||||
|
||||
@@ -60,3 +74,9 @@ async fn empty_entries_give_zero_stats() {
|
||||
assert!(stats.average.is_none());
|
||||
assert_eq!(stats.current_streak, 0);
|
||||
}
|
||||
|
||||
fn user_in(name: &str, timezone: &str) -> domain::user::User {
|
||||
let mut user = test_user(name);
|
||||
user.update_timezone(Some(Timezone::new(timezone).unwrap()));
|
||||
user
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::ports::ActivityCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_activity, test_user};
|
||||
use domain::user::UserId;
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::{ActivityCommandPort, MoodEntryCommandPort, MoodEntryQueryPort};
|
||||
use domain::testing::{InMemoryStore, test_activity, test_entry, test_user};
|
||||
|
||||
use application::entry::use_cases::replace_activity;
|
||||
|
||||
@@ -13,18 +13,27 @@ async fn replaces_activity_on_entries() {
|
||||
let user = test_user("alice");
|
||||
let old = test_activity(user.id().clone(), "gaming");
|
||||
let new = test_activity(user.id().clone(), "video games");
|
||||
store.save(&old).await.unwrap();
|
||||
store.save(&new).await.unwrap();
|
||||
ActivityCommandPort::save(&*store, &old).await.unwrap();
|
||||
ActivityCommandPort::save(&*store, &new).await.unwrap();
|
||||
|
||||
let entry = test_entry(user.id().clone(), Mood::Good);
|
||||
MoodEntryCommandPort::save(&*store, &entry).await.unwrap();
|
||||
store.assign_activities(entry.id(), vec![old.id().clone()]);
|
||||
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: store.clone(),
|
||||
activity_query: store.clone(),
|
||||
};
|
||||
|
||||
let result =
|
||||
replace_activity::execute(user.id().clone(), old.id().clone(), new.id().clone(), &deps)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
replace_activity::execute(user.id().clone(), old.id().clone(), new.id().clone(), &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let now_tagged_new = store.find_by_activity(user.id(), new.id()).await.unwrap();
|
||||
let still_tagged_old = store.find_by_activity(user.id(), old.id()).await.unwrap();
|
||||
|
||||
assert_eq!(now_tagged_new.len(), 1);
|
||||
assert!(still_tagged_old.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -53,7 +62,9 @@ async fn rejects_target_owned_by_different_user() {
|
||||
let alice = test_user("alice");
|
||||
let bob = test_user("bob");
|
||||
let bobs_activity = test_activity(bob.id().clone(), "gaming");
|
||||
store.save(&bobs_activity).await.unwrap();
|
||||
ActivityCommandPort::save(&*store, &bobs_activity)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: store.clone(),
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{FixedOffset, TimeZone};
|
||||
|
||||
use domain::dimension::DimensionKind;
|
||||
use domain::entry::Mood;
|
||||
use domain::ports::EntryDimensionPort;
|
||||
use domain::ports::MoodEntryCommandPort;
|
||||
use domain::testing::{InMemoryStore, test_entry, test_logged_at, test_user};
|
||||
use domain::testing::{
|
||||
InMemoryDimensionStore, InMemoryStore, test_entry, test_logged_at, test_user,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::entry::commands::UpdateEntryCommand;
|
||||
@@ -21,6 +27,10 @@ async fn setup() -> (
|
||||
|
||||
let deps = update_entry::Deps {
|
||||
command: store.clone(),
|
||||
dimensions: vec![
|
||||
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content))
|
||||
as Arc<dyn EntryDimensionPort>,
|
||||
],
|
||||
query: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
events: store.clone(),
|
||||
@@ -36,11 +46,8 @@ async fn updates_mood() {
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
logged_at: Some(test_logged_at()),
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
|
||||
@@ -54,6 +61,10 @@ async fn updating_nonexistent_entry_fails() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = update_entry::Deps {
|
||||
command: store.clone(),
|
||||
dimensions: vec![
|
||||
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content))
|
||||
as Arc<dyn EntryDimensionPort>,
|
||||
],
|
||||
query: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
events: store.clone(),
|
||||
@@ -62,11 +73,8 @@ async fn updating_nonexistent_entry_fails() {
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: domain::entry::MoodEntryId::generate(),
|
||||
mood: Mood::Good,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
logged_at: Some(test_logged_at()),
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let result = update_entry::execute(cmd, UserId::generate(), &deps).await;
|
||||
@@ -80,14 +88,48 @@ async fn rejects_update_by_different_user() {
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: test_logged_at(),
|
||||
activities: vec![],
|
||||
content: None,
|
||||
photos: vec![],
|
||||
voice_memos: vec![],
|
||||
logged_at: Some(test_logged_at()),
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let other_user = UserId::generate();
|
||||
let result = update_entry::execute(cmd, other_user, &deps).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_update_carrying_no_date_leaves_the_entry_where_it_was() {
|
||||
let (_store, deps, entry, user_id) = setup().await;
|
||||
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: None,
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(updated.logged_at(), &test_logged_at());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_update_carrying_a_date_moves_the_entry_to_it() {
|
||||
let (_store, deps, entry, user_id) = setup().await;
|
||||
|
||||
let moved_to = FixedOffset::east_opt(3600)
|
||||
.unwrap()
|
||||
.with_ymd_and_hms(2024, 1, 2, 9, 30, 0)
|
||||
.unwrap();
|
||||
|
||||
let cmd = UpdateEntryCommand {
|
||||
entry_id: entry.id().clone(),
|
||||
mood: Mood::Rad,
|
||||
logged_at: Some(moved_to),
|
||||
dimensions: vec![],
|
||||
};
|
||||
|
||||
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(updated.logged_at(), &moved_to);
|
||||
}
|
||||
|
||||
5
crates/application/tests/import.rs
Normal file
5
crates/application/tests/import.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[path = "import/daily_metric_import_test.rs"]
|
||||
mod daily_metric_import_test;
|
||||
|
||||
#[path = "import/wall_clock_test.rs"]
|
||||
mod wall_clock_test;
|
||||
319
crates/application/tests/import/daily_metric_import_test.rs
Normal file
319
crates/application/tests/import/daily_metric_import_test.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::{Date, DateSpan};
|
||||
use domain::metric::{MetricValue, Source, Steps};
|
||||
use domain::ports::{
|
||||
DailyMetricCommandPort, DailyMetricQueryPort, RejectionQueryPort, UserCommandPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::rejection::RejectionOrigin;
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::import::commands::{ImportDailyMetricsCommand, ImportedDay, ImportedMetric};
|
||||
use application::import::use_cases::import_daily_metrics::{self, ImportOutcome};
|
||||
|
||||
const DAY_LIMIT: usize = 90;
|
||||
|
||||
async fn a_user_in_warsaw(store: &Arc<InMemoryStore>) -> User {
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
user
|
||||
}
|
||||
|
||||
fn healthkit() -> ProviderName {
|
||||
ProviderName::new("healthkit").unwrap()
|
||||
}
|
||||
|
||||
fn metric(kind: &str, value: Option<i64>) -> ImportedMetric {
|
||||
ImportedMetric {
|
||||
kind: kind.to_string(),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
fn day(date: &str, metrics: Vec<ImportedMetric>) -> ImportedDay {
|
||||
ImportedDay {
|
||||
date: date.to_string(),
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
async fn import(
|
||||
store: &Arc<InMemoryStore>,
|
||||
user: &User,
|
||||
days: Vec<ImportedDay>,
|
||||
) -> Result<ImportOutcome, application::errors::ApplicationError> {
|
||||
let deps = import_daily_metrics::Deps {
|
||||
metrics: store.clone(),
|
||||
rejections: store.clone(),
|
||||
};
|
||||
|
||||
import_daily_metrics::execute(
|
||||
ImportDailyMetricsCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: healthkit(),
|
||||
days,
|
||||
maximum_days: DAY_LIMIT,
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stored(store: &Arc<InMemoryStore>, user: &User, from: &str, to: &str) -> Vec<String> {
|
||||
let span = DateSpan::new(
|
||||
Date::from_persistence(from.parse().unwrap()),
|
||||
Date::from_persistence(to.parse().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let metrics = DailyMetricQueryPort::find_by_span(store.as_ref(), user.id(), &span)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut described: Vec<String> = metrics
|
||||
.iter()
|
||||
.map(|metric| {
|
||||
format!(
|
||||
"{} {} {}",
|
||||
metric.date(),
|
||||
metric.kind().name(),
|
||||
metric.value().count()
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
described.sort();
|
||||
described
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_whole_day_of_readings_is_stored_under_the_providers_name() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day(
|
||||
"2026-08-20",
|
||||
vec![
|
||||
metric("steps", Some(8_412)),
|
||||
metric("sleepMinutes", Some(447)),
|
||||
],
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 2);
|
||||
assert!(outcome.rejected.is_empty());
|
||||
assert_eq!(
|
||||
stored(&store, &user, "2026-08-20", "2026-08-20").await,
|
||||
["2026-08-20 sleepMinutes 447", "2026-08-20 steps 8412"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_bad_reading_does_not_cost_the_good_ones() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day(
|
||||
"2026-08-20",
|
||||
vec![
|
||||
metric("steps", Some(8_412)),
|
||||
metric("hrv", Some(9_999)),
|
||||
metric("sleepMinutes", Some(447)),
|
||||
],
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 2);
|
||||
assert_eq!(outcome.rejected.len(), 1);
|
||||
assert_eq!(outcome.rejected[0].kind, "hrv");
|
||||
assert!(outcome.rejected[0].reason.contains("between"));
|
||||
assert_eq!(
|
||||
stored(&store, &user, "2026-08-20", "2026-08-20").await,
|
||||
["2026-08-20 sleepMinutes 447", "2026-08-20 steps 8412"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejection_is_written_where_the_user_can_find_it() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day("2026-08-20", vec![metric("hrv", Some(9_999))])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trace = RejectionQueryPort::find_recent_by_user(store.as_ref(), user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(trace.len(), 1);
|
||||
assert_eq!(trace[0].origin(), RejectionOrigin::Import);
|
||||
assert_eq!(trace[0].detail().kind(), "hrv");
|
||||
assert_eq!(trace[0].detail().value(), Some(9_999));
|
||||
assert_eq!(trace[0].detail().provider(), Some(&healthkit()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_kind_this_build_does_not_know_is_recorded_rather_than_dropped() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day(
|
||||
"2026-08-20",
|
||||
vec![metric("bloodOxygen", Some(98)), metric("steps", Some(500))],
|
||||
)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 1);
|
||||
assert_eq!(outcome.rejected.len(), 1);
|
||||
assert!(outcome.rejected[0].reason.contains("unknown"));
|
||||
|
||||
let trace = RejectionQueryPort::find_recent_by_user(store.as_ref(), user.id())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(trace[0].detail().kind(), "bloodOxygen");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_date_costs_only_that_day() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![
|
||||
day("yesterday", vec![metric("steps", Some(100))]),
|
||||
day("2026-08-20", vec![metric("steps", Some(8_412))]),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 1);
|
||||
assert_eq!(outcome.rejected.len(), 1);
|
||||
assert!(outcome.rejected[0].reason.contains("date"));
|
||||
assert_eq!(
|
||||
stored(&store, &user, "2026-08-01", "2026-08-31").await,
|
||||
["2026-08-20 steps 8412"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_importer_may_not_clear_a_reading_and_is_told_why() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day("2026-08-20", vec![metric("steps", None)])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 0);
|
||||
assert_eq!(outcome.rejected.len(), 1);
|
||||
assert!(outcome.rejected[0].reason.contains("clear"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn what_the_user_stated_by_hand_is_reported_as_superseding_the_import() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let stated = domain::metric::DailyMetric::new(
|
||||
user.id().clone(),
|
||||
Date::from_persistence("2026-08-20".parse().unwrap()),
|
||||
MetricValue::Steps(Steps::new(8_412).unwrap()),
|
||||
Source::Manual,
|
||||
);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[stated])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day("2026-08-20", vec![metric("steps", Some(1))])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.accepted, 0);
|
||||
assert_eq!(outcome.superseded, 1);
|
||||
assert!(
|
||||
outcome.rejected.is_empty(),
|
||||
"being superseded is not a rejection"
|
||||
);
|
||||
assert_eq!(
|
||||
stored(&store, &user, "2026-08-20", "2026-08-20").await,
|
||||
["2026-08-20 steps 8412"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_backfill_longer_than_the_limit_is_refused_outright() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
let days: Vec<ImportedDay> = (1..=DAY_LIMIT + 1)
|
||||
.map(|number| {
|
||||
day(
|
||||
&format!("2026-01-{:02}", (number % 28) + 1),
|
||||
vec![metric("steps", Some(100))],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let refused = import(&store, &user, days).await.unwrap_err();
|
||||
|
||||
assert!(refused.to_string().contains("90"), "got {refused}");
|
||||
assert!(
|
||||
stored(&store, &user, "2026-01-01", "2026-12-31")
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn posting_the_same_day_again_replaces_rather_than_duplicates() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in_warsaw(&store).await;
|
||||
|
||||
for count in [8_000, 8_412] {
|
||||
import(
|
||||
&store,
|
||||
&user,
|
||||
vec![day("2026-08-20", vec![metric("steps", Some(count))])],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
stored(&store, &user, "2026-08-20", "2026-08-20").await,
|
||||
["2026-08-20 steps 8412"]
|
||||
);
|
||||
}
|
||||
196
crates/application/tests/import/wall_clock_test.rs
Normal file
196
crates/application/tests/import/wall_clock_test.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{ImportSourcePort, ImportedRow, MoodEntryQueryPort, UserCommandPort};
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, User};
|
||||
|
||||
use application::import::commands::ImportCommand;
|
||||
use application::import::use_cases::import_entries;
|
||||
|
||||
struct StubDaylio(Vec<(String, String, u8)>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ImportSourcePort for StubDaylio {
|
||||
async fn read_entries(
|
||||
&self,
|
||||
_data: &[u8],
|
||||
) -> Result<Vec<ImportedRow>, domain::errors::DomainError> {
|
||||
Ok(self
|
||||
.0
|
||||
.iter()
|
||||
.map(|(date, time, mood)| ImportedRow {
|
||||
mood: *mood,
|
||||
date: date.clone(),
|
||||
time: time.clone(),
|
||||
activities: Vec::new(),
|
||||
note: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
async fn a_user_in(store: &Arc<InMemoryStore>, zone: Option<&str>) -> User {
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(zone.map(|zone| Timezone::new(zone).unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
user
|
||||
}
|
||||
|
||||
async fn import(
|
||||
store: &Arc<InMemoryStore>,
|
||||
user: &User,
|
||||
rows: Vec<(String, String, u8)>,
|
||||
) -> Result<
|
||||
application::import::use_cases::import_entries::ImportResult,
|
||||
application::errors::ApplicationError,
|
||||
> {
|
||||
let deps = import_entries::Deps {
|
||||
source: Arc::new(StubDaylio(rows)),
|
||||
entry_command: store.clone(),
|
||||
entry_query: store.clone(),
|
||||
activity_command: store.clone(),
|
||||
activity_query: store.clone(),
|
||||
dimensions: Vec::new(),
|
||||
users: store.clone(),
|
||||
preset: config::PresetConfig::default(),
|
||||
};
|
||||
|
||||
import_entries::execute(
|
||||
ImportCommand {
|
||||
user_id: user.id().clone(),
|
||||
data: Vec::new(),
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn instants_of(store: &Arc<InMemoryStore>, user: &User) -> Vec<String> {
|
||||
let mut held: Vec<String> =
|
||||
MoodEntryQueryPort::find_by_user(store.as_ref(), user.id(), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|entry| entry.logged_at().to_rfc3339())
|
||||
.collect();
|
||||
held.sort();
|
||||
|
||||
held
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_eight_pm_entry_is_eight_pm_where_the_user_lives() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
|
||||
|
||||
import(
|
||||
&store,
|
||||
&user,
|
||||
vec![("2026-08-25".into(), "8:00 PM".into(), 3)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
instants_of(&store, &user).await,
|
||||
["2026-08-25T20:00:00+02:00"],
|
||||
"a wall clock time with no zone is the user's own wall clock"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_offset_follows_daylight_saving_rather_than_being_fixed() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
|
||||
|
||||
import(
|
||||
&store,
|
||||
&user,
|
||||
vec![
|
||||
("2026-01-15".into(), "8:00 PM".into(), 3),
|
||||
("2026-07-15".into(), "8:00 PM".into(), 4),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
instants_of(&store, &user).await,
|
||||
["2026-01-15T20:00:00+01:00", "2026-07-15T20:00:00+02:00"],
|
||||
"winter is one hour ahead of UTC in Warsaw, summer is two"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_twenty_four_hour_clock_is_read_the_same_way() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
|
||||
|
||||
import(
|
||||
&store,
|
||||
&user,
|
||||
vec![("2026-08-25".into(), "20:00".into(), 3)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
instants_of(&store, &user).await,
|
||||
["2026-08-25T20:00:00+02:00"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_with_no_timezone_cannot_place_a_wall_clock_time() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, None).await;
|
||||
|
||||
let refused = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![("2026-08-25".into(), "8:00 PM".into(), 3)],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
refused.to_string().contains("timezone"),
|
||||
"importing silently into the wrong hour is worse than asking: {refused}"
|
||||
);
|
||||
assert!(instants_of(&store, &user).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_hour_that_daylight_saving_skips_is_still_imported() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
|
||||
|
||||
let outcome = import(
|
||||
&store,
|
||||
&user,
|
||||
vec![("2026-03-29".into(), "2:30 AM".into(), 3)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome.imported, 1,
|
||||
"the clocks skip 2:30 that night, and the entry must still land somewhere sensible"
|
||||
);
|
||||
assert_eq!(instants_of(&store, &user).await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn importing_the_same_export_twice_does_not_duplicate_anything() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
|
||||
let rows = vec![("2026-08-25".into(), "8:00 PM".into(), 3)];
|
||||
|
||||
import(&store, &user, rows.clone()).await.unwrap();
|
||||
let again = import(&store, &user, rows).await.unwrap();
|
||||
|
||||
assert_eq!(again.imported, 0);
|
||||
assert_eq!(again.skipped, 1);
|
||||
assert_eq!(instants_of(&store, &user).await.len(), 1);
|
||||
}
|
||||
2
crates/application/tests/job.rs
Normal file
2
crates/application/tests/job.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "job/queue_test.rs"]
|
||||
mod queue_test;
|
||||
394
crates/application/tests/job/queue_test.rs
Normal file
394
crates/application/tests/job/queue_test.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::{Mood, MoodEntry, MoodEntryId};
|
||||
use domain::job::{JobKind, JobStatus};
|
||||
use domain::ports::{JobQueueCommandPort, JobQueueQueryPort};
|
||||
use domain::song::RecordingId;
|
||||
use domain::testing::{FakeRecordingLookup, InMemoryStore};
|
||||
use domain::user::UserId;
|
||||
|
||||
use application::job::use_cases::{run_due_jobs, sweep_recording_backlog, sweep_weather_backlog};
|
||||
|
||||
const MOST_ATTEMPTS: u32 = 3;
|
||||
|
||||
struct Bench {
|
||||
store: Arc<InMemoryStore>,
|
||||
lookups: Arc<FakeRecordingLookup>,
|
||||
}
|
||||
|
||||
fn a_bench() -> Bench {
|
||||
Bench {
|
||||
store: Arc::new(InMemoryStore::new()),
|
||||
lookups: Arc::new(FakeRecordingLookup::finding(None)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
async fn an_unidentified_song(&self, title: &str) -> MoodEntryId {
|
||||
let entry = MoodEntry::new(UserId::generate(), Mood::Good, test_instant());
|
||||
domain::ports::MoodEntryCommandPort::save(self.store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
self.store
|
||||
.put_unidentified_song(entry.id(), title, "Massive Attack");
|
||||
|
||||
entry.id().clone()
|
||||
}
|
||||
|
||||
async fn sweep(&self) -> usize {
|
||||
let deps = sweep_recording_backlog::Deps {
|
||||
backlog: self.store.clone(),
|
||||
queue: self.store.clone(),
|
||||
};
|
||||
|
||||
sweep_recording_backlog::execute(200, &deps).await.unwrap()
|
||||
}
|
||||
|
||||
async fn work(&self) -> run_due_jobs::Worked {
|
||||
let deps = run_due_jobs::Deps {
|
||||
queue: self.store.clone(),
|
||||
backfill: self.store.clone(),
|
||||
recordings: self.lookups.clone(),
|
||||
places: self.store.clone(),
|
||||
weather: None,
|
||||
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
|
||||
domain::dimension::DimensionKind::Weather,
|
||||
)),
|
||||
};
|
||||
|
||||
run_due_jobs::execute(10, MOST_ATTEMPTS, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn queued(&self) -> Vec<domain::job::Job> {
|
||||
self.store.every_job()
|
||||
}
|
||||
|
||||
async fn exhausted(&self) -> Vec<domain::job::Job> {
|
||||
JobQueueQueryPort::find_exhausted(self.store.as_ref(), 50)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_instant() -> chrono::DateTime<chrono::FixedOffset> {
|
||||
chrono::DateTime::parse_from_rfc3339("2026-08-20T21:30:00+02:00").unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_song_with_no_recording_identity_is_swept_onto_the_queue() {
|
||||
let bench = a_bench();
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
|
||||
let enqueued = bench.sweep().await;
|
||||
|
||||
assert_eq!(enqueued, 1);
|
||||
let queued = bench.queued().await;
|
||||
assert_eq!(queued.len(), 1);
|
||||
assert_eq!(queued[0].kind(), JobKind::BackfillRecordingIdentity);
|
||||
assert_eq!(queued[0].status(), JobStatus::Pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sweeping_twice_does_not_queue_the_same_work_twice() {
|
||||
let bench = a_bench();
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
|
||||
bench.sweep().await;
|
||||
let again = bench.sweep().await;
|
||||
|
||||
assert_eq!(again, 0, "the work was already queued");
|
||||
assert_eq!(bench.queued().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_sweep_is_bounded_by_what_it_is_asked_for() {
|
||||
let bench = a_bench();
|
||||
for number in 0..10 {
|
||||
bench.an_unidentified_song(&format!("song {number}")).await;
|
||||
}
|
||||
|
||||
let deps = sweep_recording_backlog::Deps {
|
||||
backlog: bench.store.clone(),
|
||||
queue: bench.store.clone(),
|
||||
};
|
||||
let enqueued = sweep_recording_backlog::execute(4, &deps).await.unwrap();
|
||||
|
||||
assert_eq!(enqueued, 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_that_succeeds_leaves_the_queue_and_records_the_identity() {
|
||||
let bench = a_bench();
|
||||
let found = RecordingId::new("8f3471b5-7e6a-4dbe-9c6b-1e56a5ed2f6d").unwrap();
|
||||
let bench = Bench {
|
||||
lookups: Arc::new(FakeRecordingLookup::finding(Some(found.clone()))),
|
||||
..bench
|
||||
};
|
||||
let entry_id = bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.finished, 1);
|
||||
assert_eq!(worked.failed, 0);
|
||||
assert!(bench.queued().await.is_empty(), "a finished job is gone");
|
||||
assert_eq!(bench.store.recording_of(&entry_id), Some(found));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lookup_that_finds_nothing_still_finishes_the_job() {
|
||||
let bench = a_bench();
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.finished, 1, "nothing to find is not a failure");
|
||||
assert!(bench.queued().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_job_goes_back_to_the_queue_with_the_reason_recorded() {
|
||||
let bench = Bench {
|
||||
lookups: Arc::new(FakeRecordingLookup::failing("musicbrainz is unreachable")),
|
||||
..a_bench()
|
||||
};
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.failed, 1);
|
||||
let queued = bench.queued().await;
|
||||
assert_eq!(queued[0].status(), JobStatus::Pending);
|
||||
assert_eq!(queued[0].attempts(), 1);
|
||||
assert!(
|
||||
queued[0].last_error().unwrap().contains("unreachable"),
|
||||
"got {:?}",
|
||||
queued[0].last_error()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_that_keeps_failing_stops_being_retried_but_stays_visible() {
|
||||
let bench = Bench {
|
||||
lookups: Arc::new(FakeRecordingLookup::failing("musicbrainz is unreachable")),
|
||||
..a_bench()
|
||||
};
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
for _ in 0..MOST_ATTEMPTS {
|
||||
bench.work().await;
|
||||
}
|
||||
|
||||
let after_giving_up = bench.work().await;
|
||||
|
||||
assert_eq!(
|
||||
after_giving_up.finished + after_giving_up.failed,
|
||||
0,
|
||||
"an exhausted job is not claimed again"
|
||||
);
|
||||
|
||||
let exhausted = bench.exhausted().await;
|
||||
assert_eq!(exhausted.len(), 1);
|
||||
assert_eq!(exhausted[0].attempts(), MOST_ATTEMPTS);
|
||||
assert!(exhausted[0].last_error().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn work_lost_to_a_crash_is_found_again_by_the_sweep() {
|
||||
let bench = a_bench();
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
bench.store.lose_every_job();
|
||||
assert!(bench.queued().await.is_empty(), "the queue was wiped");
|
||||
|
||||
let enqueued = bench.sweep().await;
|
||||
|
||||
assert_eq!(
|
||||
enqueued, 1,
|
||||
"the sweep rediscovered it from the entry itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_job_left_running_by_a_dead_worker_is_reclaimed() {
|
||||
let bench = a_bench();
|
||||
bench.an_unidentified_song("Teardrop").await;
|
||||
bench.sweep().await;
|
||||
|
||||
JobQueueCommandPort::claim(bench.store.as_ref(), JobKind::BackfillRecordingIdentity, 10)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bench.queued().await[0].status(), JobStatus::Running);
|
||||
|
||||
let reclaimed = JobQueueCommandPort::reclaim_stalled(bench.store.as_ref(), 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reclaimed, 1);
|
||||
assert_eq!(bench.queued().await[0].status(), JobStatus::Pending);
|
||||
}
|
||||
|
||||
use domain::dimension::DimensionKind;
|
||||
use domain::location::Coordinates;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::{FakeWeatherLookup, InMemoryDimensionStore};
|
||||
use domain::weather::{Celsius, Condition, Weather};
|
||||
|
||||
struct WeatherBench {
|
||||
store: Arc<InMemoryStore>,
|
||||
lookup: Arc<FakeWeatherLookup>,
|
||||
weather_store: Arc<InMemoryDimensionStore>,
|
||||
switched_on: bool,
|
||||
}
|
||||
|
||||
fn a_downpour() -> Weather {
|
||||
Weather::new(
|
||||
Condition::Rain,
|
||||
Celsius::new(11.5).unwrap(),
|
||||
ProviderName::new("open-meteo").unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn a_weather_bench(lookup: FakeWeatherLookup, switched_on: bool) -> WeatherBench {
|
||||
WeatherBench {
|
||||
store: Arc::new(InMemoryStore::new()),
|
||||
lookup: Arc::new(lookup),
|
||||
weather_store: Arc::new(InMemoryDimensionStore::new(DimensionKind::Weather)),
|
||||
switched_on,
|
||||
}
|
||||
}
|
||||
|
||||
impl WeatherBench {
|
||||
async fn a_place_with_no_weather(&self) -> MoodEntryId {
|
||||
let entry = MoodEntry::new(UserId::generate(), Mood::Good, test_instant());
|
||||
domain::ports::MoodEntryCommandPort::save(self.store.as_ref(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
self.store.put_place_without_weather(
|
||||
entry.id(),
|
||||
Coordinates::new(52.2297, 21.0122).unwrap(),
|
||||
test_instant(),
|
||||
);
|
||||
|
||||
entry.id().clone()
|
||||
}
|
||||
|
||||
async fn sweep(&self) -> usize {
|
||||
let deps = sweep_weather_backlog::Deps {
|
||||
backlog: self.store.clone(),
|
||||
queue: self.store.clone(),
|
||||
};
|
||||
|
||||
sweep_weather_backlog::execute(200, &deps).await.unwrap()
|
||||
}
|
||||
|
||||
async fn work(&self) -> run_due_jobs::Worked {
|
||||
let deps = run_due_jobs::Deps {
|
||||
queue: self.store.clone(),
|
||||
backfill: self.store.clone(),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
places: self.store.clone(),
|
||||
weather: self
|
||||
.switched_on
|
||||
.then(|| self.lookup.clone() as Arc<dyn domain::ports::WeatherLookupPort>),
|
||||
weather_store: self.weather_store.clone(),
|
||||
};
|
||||
|
||||
run_due_jobs::execute(10, MOST_ATTEMPTS, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn stored_weather(&self, entry_id: &MoodEntryId) -> Option<Weather> {
|
||||
let held = domain::ports::EntryDimensionPort::load(
|
||||
self.weather_store.as_ref(),
|
||||
std::slice::from_ref(entry_id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match held.get(entry_id) {
|
||||
Some(domain::dimension::DimensionValue::Weather(weather)) => Some(weather.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_with_no_weather_is_swept_onto_the_queue_and_observed() {
|
||||
let bench = a_weather_bench(FakeWeatherLookup::observing(Some(a_downpour())), true);
|
||||
let entry_id = bench.a_place_with_no_weather().await;
|
||||
|
||||
assert_eq!(bench.sweep().await, 1);
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.finished, 1);
|
||||
let observed = bench.stored_weather(&entry_id).await.expect("weather");
|
||||
assert_eq!(observed.condition(), Condition::Rain);
|
||||
assert_eq!(observed.observed_by().value(), "open-meteo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_leaves_the_box_when_weather_lookups_are_switched_off() {
|
||||
let bench = a_weather_bench(FakeWeatherLookup::observing(Some(a_downpour())), false);
|
||||
let entry_id = bench.a_place_with_no_weather().await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.failed, 1, "the job cannot be done, and says so");
|
||||
assert_eq!(
|
||||
bench.lookup.times_asked(),
|
||||
0,
|
||||
"the provider was never asked"
|
||||
);
|
||||
assert!(bench.stored_weather(&entry_id).await.is_none());
|
||||
assert!(
|
||||
bench.queued().await[0]
|
||||
.last_error()
|
||||
.unwrap()
|
||||
.contains("switched off"),
|
||||
"got {:?}",
|
||||
bench.queued().await[0].last_error()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_that_has_no_reading_for_a_place_does_not_fail_the_job() {
|
||||
let bench = a_weather_bench(FakeWeatherLookup::observing(None), true);
|
||||
let entry_id = bench.a_place_with_no_weather().await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.finished, 1);
|
||||
assert!(bench.stored_weather(&entry_id).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_provider_leaves_the_job_to_be_retried() {
|
||||
let bench = a_weather_bench(FakeWeatherLookup::failing("open-meteo timed out"), true);
|
||||
bench.a_place_with_no_weather().await;
|
||||
bench.sweep().await;
|
||||
|
||||
let worked = bench.work().await;
|
||||
|
||||
assert_eq!(worked.failed, 1);
|
||||
let queued = bench.queued().await;
|
||||
assert_eq!(queued[0].attempts(), 1);
|
||||
assert!(queued[0].last_error().unwrap().contains("timed out"));
|
||||
}
|
||||
|
||||
impl WeatherBench {
|
||||
async fn queued(&self) -> Vec<domain::job::Job> {
|
||||
self.store.every_job()
|
||||
}
|
||||
}
|
||||
2
crates/application/tests/metric.rs
Normal file
2
crates/application/tests/metric.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "metric/daily_metric_test.rs"]
|
||||
mod daily_metric_test;
|
||||
725
crates/application/tests/metric/daily_metric_test.rs
Normal file
725
crates/application/tests/metric/daily_metric_test.rs
Normal file
@@ -0,0 +1,725 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::{Date, DateSpan};
|
||||
use domain::metric::{
|
||||
AlcoholicDrinks, AwakeMinutes, DailyMetric, ExerciseMinutes, Hrv, MetricKind, MetricValue,
|
||||
RestingHeartRate, ScreenTimeMinutes, SleepMinutes, Source, Steps,
|
||||
};
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, DailyMetricCommandPort, DailyMetricQueryPort, UserCommandPort, UserQueryPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, UserId};
|
||||
|
||||
use application::metric::commands::{MetricChange, SetDailyMetricsCommand};
|
||||
use application::metric::use_cases::{list_daily_metrics, set_daily_metrics};
|
||||
|
||||
async fn a_user_in_warsaw(store: &Arc<InMemoryStore>) -> UserId {
|
||||
let mut user = test_user("alice");
|
||||
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
user.id().clone()
|
||||
}
|
||||
|
||||
fn set_deps(store: &Arc<InMemoryStore>) -> set_daily_metrics::Deps {
|
||||
set_daily_metrics::Deps {
|
||||
metrics: store.clone() as Arc<dyn DailyMetricCommandPort>,
|
||||
users: store.clone() as Arc<dyn UserQueryPort>,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_deps(store: &Arc<InMemoryStore>) -> list_daily_metrics::Deps {
|
||||
list_daily_metrics::Deps {
|
||||
metrics: store.clone() as Arc<dyn DailyMetricQueryPort>,
|
||||
}
|
||||
}
|
||||
|
||||
fn on(day: &str) -> Date {
|
||||
Date::from_persistence(day.parse().unwrap())
|
||||
}
|
||||
|
||||
fn steps(count: u32) -> MetricValue {
|
||||
MetricValue::Steps(Steps::new(count).unwrap())
|
||||
}
|
||||
|
||||
async fn on_that_day(store: &Arc<InMemoryStore>, user_id: &UserId) -> Vec<DailyMetric> {
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
list_daily_metrics::execute(user_id.clone(), span, &list_deps(store))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn only_metric(store: &Arc<InMemoryStore>, user_id: &UserId, day: &str) -> DailyMetric {
|
||||
let span = DateSpan::new(on(day), on(day)).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id.clone(), span, &list_deps(store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
stored.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_step_count_the_user_states_is_stored_for_that_date() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].value(), &steps(8_412));
|
||||
assert_eq!(stored[0].source(), &Source::Manual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_import_does_not_overwrite_the_count_the_user_stated() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let imported = DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap()),
|
||||
);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[imported])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = only_metric(&store, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.value(), &steps(8_412));
|
||||
assert_eq!(stored.source(), &Source::Manual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_count_the_user_states_replaces_what_a_provider_reported() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
let imported = DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap()),
|
||||
);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[imported])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = only_metric(&store, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.value(), &steps(8_412));
|
||||
assert_eq!(stored.source(), &Source::Manual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restating_a_date_corrects_it_rather_than_recording_it_twice() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
for count in [8_000, 8_412] {
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(count))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let stored = only_metric(&store, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.value(), &steps(8_412));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_date_that_has_not_happened_yet_is_refused() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
let error = set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id,
|
||||
date: on("2099-01-01"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("has not happened yet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_with_no_timezone_cannot_record_a_day() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("bob");
|
||||
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
|
||||
|
||||
let error = set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user.id().clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("timezone"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_the_days_in_the_span_come_back() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
for day in ["2026-08-19", "2026-08-20", "2026-08-21"] {
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on(day),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-21")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut days: Vec<String> = stored
|
||||
.iter()
|
||||
.map(|metric| metric.date().to_string())
|
||||
.collect();
|
||||
days.sort();
|
||||
|
||||
assert_eq!(days, vec!["2026-08-20", "2026-08-21"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_users_days_are_not_returned() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let mine = a_user_in_warsaw(&store).await;
|
||||
let theirs = UserId::generate();
|
||||
|
||||
let hers = DailyMetric::new(theirs, on("2026-08-20"), steps(1_000), Source::Manual);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[hers])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(mine, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_users_data_removes_their_days() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
CascadeDeletePort::delete_all_user_data(store.as_ref(), &user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored.is_empty());
|
||||
}
|
||||
|
||||
fn one_of_every_kind() -> Vec<MetricChange> {
|
||||
[
|
||||
MetricValue::Steps(Steps::new(8_412).unwrap()),
|
||||
MetricValue::SleepMinutes(SleepMinutes::new(447).unwrap()),
|
||||
MetricValue::AwakeMinutes(AwakeMinutes::new(23).unwrap()),
|
||||
MetricValue::RestingHeartRate(RestingHeartRate::new(52).unwrap()),
|
||||
MetricValue::Hrv(Hrv::new(61).unwrap()),
|
||||
MetricValue::ExerciseMinutes(ExerciseMinutes::new(35).unwrap()),
|
||||
MetricValue::ScreenTimeMinutes(ScreenTimeMinutes::new(212).unwrap()),
|
||||
MetricValue::AlcoholicDrinks(AlcoholicDrinks::new(2).unwrap()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(MetricChange::Stated)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_kind_can_be_stated_and_read_back_on_one_day() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: one_of_every_kind(),
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut names: Vec<&str> = stored.iter().map(|metric| metric.kind().name()).collect();
|
||||
names.sort_unstable();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"alcoholicDrinks",
|
||||
"awakeMinutes",
|
||||
"exerciseMinutes",
|
||||
"hrv",
|
||||
"restingHeartRate",
|
||||
"screenTimeMinutes",
|
||||
"sleepMinutes",
|
||||
"steps",
|
||||
]
|
||||
);
|
||||
|
||||
let values: Vec<&MetricValue> = stored.iter().map(|metric| metric.value()).collect();
|
||||
for change in one_of_every_kind() {
|
||||
let MetricChange::Stated(expected) = change else {
|
||||
unreachable!("every kind is stated, never cleared, in this test")
|
||||
};
|
||||
assert!(
|
||||
values.contains(&&expected),
|
||||
"{expected:?} did not come back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stating_one_kind_leaves_another_providers_reading_alone() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
let reported = DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
MetricValue::Hrv(Hrv::new(61).unwrap()),
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap()),
|
||||
);
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[reported])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hrv = stored
|
||||
.iter()
|
||||
.find(|metric| metric.kind() == MetricKind::Hrv)
|
||||
.expect("the provider's reading was lost");
|
||||
|
||||
assert_eq!(hrv.value(), &MetricValue::Hrv(Hrv::new(61).unwrap()));
|
||||
assert!(hrv.source().provider().is_some());
|
||||
assert_eq!(stored.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_removes_what_was_stated() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Steps)],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_that_was_never_recorded_changes_nothing() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Hrv)],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-20"), on("2026-08-20")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(stored.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_providers_reading_can_be_cleared_and_a_later_import_may_return_it() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
let reading = || {
|
||||
DailyMetric::new(
|
||||
user_id.clone(),
|
||||
on("2026-08-20"),
|
||||
steps(1_000),
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap()),
|
||||
)
|
||||
};
|
||||
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[reading()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Steps)],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
on_that_day(&store, &user_id).await.is_empty(),
|
||||
"clearing left the provider's reading behind"
|
||||
);
|
||||
|
||||
DailyMetricCommandPort::save(store.as_ref(), &[reading()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = on_that_day(&store, &user_id).await;
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(stored[0].source().provider().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_request_can_state_one_kind_and_clear_another() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![
|
||||
MetricChange::Stated(steps(8_412)),
|
||||
MetricChange::Stated(MetricValue::Hrv(Hrv::new(61).unwrap())),
|
||||
],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![
|
||||
MetricChange::Stated(steps(9_000)),
|
||||
MetricChange::Cleared(MetricKind::Hrv),
|
||||
],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = on_that_day(&store, &user_id).await;
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].value(), &steps(9_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn naming_a_kind_twice_in_one_request_is_refused() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
let error = set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id,
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![
|
||||
MetricChange::Stated(steps(8_412)),
|
||||
MetricChange::Cleared(MetricKind::Steps),
|
||||
],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("more than once"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_one_kind_leaves_the_others_on_that_day_alone() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: one_of_every_kind(),
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Hrv)],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = on_that_day(&store, &user_id).await;
|
||||
let remaining: Vec<MetricKind> = stored.iter().map(|metric| metric.kind()).collect();
|
||||
|
||||
assert_eq!(stored.len(), 7);
|
||||
assert!(!remaining.contains(&MetricKind::Hrv));
|
||||
assert!(remaining.contains(&MetricKind::Steps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_a_kind_on_one_day_leaves_the_same_kind_on_other_days() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
for day in ["2026-08-19", "2026-08-20", "2026-08-21"] {
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on(day),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Steps)],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let span = DateSpan::new(on("2026-08-19"), on("2026-08-21")).unwrap();
|
||||
let stored = list_daily_metrics::execute(user_id, span, &list_deps(&store))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut days: Vec<String> = stored
|
||||
.iter()
|
||||
.map(|metric| metric.date().to_string())
|
||||
.collect();
|
||||
days.sort();
|
||||
|
||||
assert_eq!(days, ["2026-08-19", "2026-08-21"]);
|
||||
}
|
||||
|
||||
fn healthkit() -> Source {
|
||||
Source::Provider(ProviderName::new("healthkit").unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_importer_writes_under_its_own_name() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: healthkit(),
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = only_metric(&store, &user_id, "2026-08-20").await;
|
||||
|
||||
assert_eq!(stored.source(), &healthkit());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_importer_cannot_clear_a_reading() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user_id = a_user_in_warsaw(&store).await;
|
||||
|
||||
set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Stated(steps(8_412))],
|
||||
source: Source::Manual,
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let refused = set_daily_metrics::execute(
|
||||
SetDailyMetricsCommand {
|
||||
user_id: user_id.clone(),
|
||||
date: on("2026-08-20"),
|
||||
changes: vec![MetricChange::Cleared(MetricKind::Steps)],
|
||||
source: healthkit(),
|
||||
},
|
||||
&set_deps(&store),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(refused.to_string().contains("clear"), "got {refused}");
|
||||
assert_eq!(
|
||||
only_metric(&store, &user_id, "2026-08-20").await.value(),
|
||||
&steps(8_412),
|
||||
"the reading must survive an importer trying to clear it"
|
||||
);
|
||||
}
|
||||
5
crates/application/tests/provider.rs
Normal file
5
crates/application/tests/provider.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[path = "provider/connection_test.rs"]
|
||||
mod connection_test;
|
||||
|
||||
#[path = "provider/now_playing_test.rs"]
|
||||
mod now_playing_test;
|
||||
121
crates/application/tests/provider/connection_test.rs
Normal file
121
crates/application/tests/provider/connection_test.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ProviderConnectionQueryPort;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::testing::{FakeCredentialCipher, InMemoryStore, test_user};
|
||||
|
||||
use application::provider::commands::ConnectProviderCommand;
|
||||
use application::provider::use_cases::{connect_provider, disconnect_provider, list_connections};
|
||||
|
||||
const SUBSONIC_CREDENTIAL: &str =
|
||||
r#"{"url":"https://music.example","username":"gabriel","password":"hunter2"}"#;
|
||||
|
||||
fn deps() -> (Arc<InMemoryStore>, connect_provider::Deps) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let deps = connect_provider::Deps {
|
||||
command: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
};
|
||||
(store, deps)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connecting_stores_the_credential_sealed_rather_than_verbatim() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("alice");
|
||||
|
||||
let cmd = ConnectProviderCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: ProviderName::new("subsonic").unwrap(),
|
||||
credential: SUBSONIC_CREDENTIAL.as_bytes().to_vec(),
|
||||
};
|
||||
|
||||
connect_provider::execute(cmd, &deps).await.unwrap();
|
||||
|
||||
let stored = store.find_by_user(user.id()).await.unwrap();
|
||||
let sealed = stored[0].credential().value();
|
||||
|
||||
assert!(
|
||||
!sealed.windows(7).any(|window| window == b"hunter2"),
|
||||
"the raw password reached storage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnecting_replaces_the_credential_rather_than_adding_a_second() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("bob");
|
||||
let provider = ProviderName::new("subsonic").unwrap();
|
||||
|
||||
for password in ["first", "second"] {
|
||||
let cmd = ConnectProviderCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: provider.clone(),
|
||||
credential: password.as_bytes().to_vec(),
|
||||
};
|
||||
connect_provider::execute(cmd, &deps).await.unwrap();
|
||||
}
|
||||
|
||||
let stored = store.find_by_user(user.id()).await.unwrap();
|
||||
|
||||
assert_eq!(stored.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_reports_connected_providers_without_their_credentials() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("carol");
|
||||
|
||||
connect_provider::execute(
|
||||
ConnectProviderCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: ProviderName::new("subsonic").unwrap(),
|
||||
credential: SUBSONIC_CREDENTIAL.as_bytes().to_vec(),
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listing = list_connections::execute(
|
||||
user.id().clone(),
|
||||
&list_connections::Deps {
|
||||
query: store.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(listing.len(), 1);
|
||||
assert_eq!(listing[0].provider.value(), "subsonic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnecting_removes_the_connection() {
|
||||
let (store, deps) = deps();
|
||||
let user = test_user("dave");
|
||||
let provider = ProviderName::new("subsonic").unwrap();
|
||||
|
||||
connect_provider::execute(
|
||||
ConnectProviderCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: provider.clone(),
|
||||
credential: SUBSONIC_CREDENTIAL.as_bytes().to_vec(),
|
||||
},
|
||||
&deps,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
disconnect_provider::execute(
|
||||
user.id().clone(),
|
||||
provider,
|
||||
&disconnect_provider::Deps {
|
||||
command: store.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.find_by_user(user.id()).await.unwrap().is_empty());
|
||||
}
|
||||
199
crates/application/tests/provider/now_playing_test.rs
Normal file
199
crates/application/tests/provider/now_playing_test.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::provider::ProviderName;
|
||||
use domain::song::{RecordingId, Song};
|
||||
use domain::testing::{
|
||||
FakeCredentialCipher, FakeNowPlaying, FakeRecordingLookup, InMemoryStore, test_user,
|
||||
};
|
||||
|
||||
use application::provider::commands::ConnectProviderCommand;
|
||||
use application::provider::use_cases::{connect_provider, get_now_playing};
|
||||
|
||||
const RECORDING: &str = "f5c7e7a2-0000-4000-8000-000000000001";
|
||||
|
||||
async fn connected_store() -> (Arc<InMemoryStore>, domain::user::UserId) {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("alice");
|
||||
|
||||
connect_provider::execute(
|
||||
ConnectProviderCommand {
|
||||
user_id: user.id().clone(),
|
||||
provider: ProviderName::new("subsonic").unwrap(),
|
||||
credential: br#"{"url":"https://music.example"}"#.to_vec(),
|
||||
},
|
||||
&connect_provider::Deps {
|
||||
command: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(store, user.id().clone())
|
||||
}
|
||||
|
||||
fn song() -> Song {
|
||||
Song::new("Paranoid Android", "Radiohead", None, None).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_playing_song_is_returned_enriched_with_its_recording_id() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding(Some(
|
||||
RecordingId::new(RECORDING).unwrap(),
|
||||
))),
|
||||
};
|
||||
|
||||
let result = get_now_playing::execute(user_id, &deps).await.unwrap();
|
||||
let found = result.unwrap();
|
||||
|
||||
assert_eq!(found.title().value(), "Paranoid Android");
|
||||
assert_eq!(
|
||||
found.recording_id().map(|id| id.value().to_string()),
|
||||
Some(RECORDING.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_song_with_no_musicbrainz_match_is_still_returned() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
let found = get_now_playing::execute(user_id, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(found.recording_id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_playing_is_not_an_error() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::silent("subsonic")),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
assert!(
|
||||
get_now_playing::execute(user_id, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_user_with_no_connection_gets_a_clear_error() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let stranger = test_user("bob");
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
assert!(
|
||||
get_now_playing::execute(stranger.id().clone(), &deps)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_that_cannot_be_reached_offers_nothing_rather_than_failing() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::failing("subsonic", "connection refused")),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
let asked = get_now_playing::execute(user_id, &deps).await;
|
||||
|
||||
assert!(
|
||||
asked.is_ok(),
|
||||
"the user must still be able to type a song by hand: {asked:?}"
|
||||
);
|
||||
assert!(asked.unwrap().is_none(), "nothing found, not an error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recording_lookup_that_fails_yields_a_song_with_no_identity() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::failing("musicbrainz is rate limiting")),
|
||||
};
|
||||
|
||||
let playing = get_now_playing::execute(user_id, &deps)
|
||||
.await
|
||||
.expect("a failing enrichment must not fail the lookup")
|
||||
.expect("the song is still playing");
|
||||
|
||||
assert_eq!(playing.title().value(), song().title().value());
|
||||
assert!(
|
||||
playing.recording_id().is_none(),
|
||||
"an identity that could not be found is simply absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recording_lookup_that_finds_nothing_yields_a_song_with_no_identity() {
|
||||
let (store, user_id) = connected_store().await;
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
let playing = get_now_playing::execute(user_id, &deps)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(playing.recording_id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_with_no_connection_is_told_so_rather_than_offered_silence() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
let user = test_user("nobody");
|
||||
|
||||
let deps = get_now_playing::Deps {
|
||||
query: store.clone(),
|
||||
cipher: Arc::new(FakeCredentialCipher),
|
||||
now_playing: Arc::new(FakeNowPlaying::playing("subsonic", song())),
|
||||
recordings: Arc::new(FakeRecordingLookup::finding_nothing()),
|
||||
};
|
||||
|
||||
let asked = get_now_playing::execute(user.id().clone(), &deps).await;
|
||||
|
||||
assert!(
|
||||
asked.is_err(),
|
||||
"nothing to connect to is actionable, unlike a provider being down"
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
#[path = "reminder/crud_test.rs"]
|
||||
mod crud_test;
|
||||
|
||||
#[path = "reminder/process_due_test.rs"]
|
||||
mod process_due_test;
|
||||
|
||||
87
crates/application/tests/reminder/process_due_test.rs
Normal file
87
crates/application/tests/reminder/process_due_test.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ReminderCommandPort, ReminderSenderPort, UserCommandPort};
|
||||
use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::testing::{InMemoryStore, test_user};
|
||||
use domain::user::{Timezone, UserId};
|
||||
|
||||
use application::reminder::use_cases::process_due_reminders;
|
||||
|
||||
/// Records every attempt and fails for one nominated user, the way a device
|
||||
/// whose push subscription has gone stale does.
|
||||
struct FlakySender {
|
||||
failing: UserId,
|
||||
attempts: Mutex<Vec<UserId>>,
|
||||
}
|
||||
|
||||
impl FlakySender {
|
||||
fn new(failing: UserId) -> Self {
|
||||
Self {
|
||||
failing,
|
||||
attempts: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn attempts(&self) -> Vec<UserId> {
|
||||
self.attempts.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ReminderSenderPort for FlakySender {
|
||||
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.attempts.lock().unwrap().push(user_id.clone());
|
||||
|
||||
if user_id == &self.failing {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"no push notification could be delivered".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A reminder due right now, so `should_send` lets it through.
|
||||
fn due_now(user_id: UserId) -> Reminder {
|
||||
Reminder::new(user_id, DaySchedule::every_day_at(Utc::now().time()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_send_does_not_abort_the_sweep() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
|
||||
let mut broken = test_user("broken");
|
||||
broken.update_timezone(Some(Timezone::new("UTC").unwrap()));
|
||||
let mut healthy = test_user("healthy");
|
||||
healthy.update_timezone(Some(Timezone::new("UTC").unwrap()));
|
||||
|
||||
UserCommandPort::save(&*store, &broken).await.unwrap();
|
||||
UserCommandPort::save(&*store, &healthy).await.unwrap();
|
||||
ReminderCommandPort::save(&*store, &due_now(broken.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
ReminderCommandPort::save(&*store, &due_now(healthy.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sender = Arc::new(FlakySender::new(broken.id().clone()));
|
||||
let deps = process_due_reminders::Deps {
|
||||
reminder_query: store.clone(),
|
||||
user_query: store.clone(),
|
||||
sender: sender.clone(),
|
||||
};
|
||||
|
||||
let sent = process_due_reminders::execute(&deps).await.unwrap();
|
||||
|
||||
assert_eq!(sent, 1, "the healthy user should still have been counted");
|
||||
|
||||
let attempts = sender.attempts();
|
||||
assert!(
|
||||
attempts.contains(broken.id()) && attempts.contains(healthy.id()),
|
||||
"both users should have been attempted regardless of order, got {attempts:?}"
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ async fn clears_all_user_data() {
|
||||
ReminderCommandPort::save(&*store, &reminder).await.unwrap();
|
||||
|
||||
let deps = clear_data::Deps {
|
||||
dimensions: vec![],
|
||||
entry_query: store.clone(),
|
||||
cascade: store.clone(),
|
||||
media_storage: store.clone(),
|
||||
|
||||
@@ -11,6 +11,7 @@ use application::user::use_cases::delete_user;
|
||||
|
||||
fn deps(store: &Arc<InMemoryStore>) -> delete_user::Deps {
|
||||
delete_user::Deps {
|
||||
dimensions: vec![],
|
||||
user_query: store.clone(),
|
||||
entry_query: store.clone(),
|
||||
cascade: store.clone(),
|
||||
|
||||
Reference in New Issue
Block a user