changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,5 @@
#[path = "deletion/ordering_test.rs"]
mod ordering_test;
#[path = "deletion/best_effort_test.rs"]
mod best_effort_test;

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

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

View File

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

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

View File

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

View File

@@ -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(),

View 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]);
}

View File

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

View File

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

View File

@@ -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(),

View File

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

View 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;

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

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

View File

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

View 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()
}
}

View File

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

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

View File

@@ -0,0 +1,5 @@
#[path = "provider/connection_test.rs"]
mod connection_test;
#[path = "provider/now_playing_test.rs"]
mod now_playing_test;

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

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

View File

@@ -1,2 +1,5 @@
#[path = "reminder/crud_test.rs"]
mod crud_test;
#[path = "reminder/process_due_test.rs"]
mod process_due_test;

View 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:?}"
);
}

View File

@@ -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(),

View File

@@ -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(),