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