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,445 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::{Date, DateSpan};
use domain::metric::{DailyMetric, Hrv, MetricKind, MetricValue, Source, Steps};
use domain::ports::{
CascadeDeletePort, DailyMetricCommandPort, DailyMetricQueryPort, UserCommandPort,
};
use domain::provider::ProviderName;
use domain::testing::test_user;
use domain::user::UserId;
use sqlite::repositories::{
SqliteCascadeDeleteRepository, SqliteDailyMetricCommandRepository,
SqliteDailyMetricQueryRepository, SqliteRejectionRepository, SqliteUserCommandRepository,
};
const REJECTIONS_KEPT: usize = 200;
fn a_trace(pool: sqlx::SqlitePool) -> std::sync::Arc<dyn domain::ports::RejectionCommandPort> {
std::sync::Arc::new(SqliteRejectionRepository::new(pool, REJECTIONS_KEPT))
}
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, UserId) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
(pool, user.id().clone())
}
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
fn steps(count: u32) -> MetricValue {
MetricValue::Steps(Steps::new(count).unwrap())
}
fn from_provider() -> Source {
Source::Provider(ProviderName::new("healthkit").unwrap())
}
async fn stored_for(pool: &sqlx::SqlitePool, user_id: &UserId, day: &str) -> Vec<DailyMetric> {
let span = DateSpan::new(on(day), on(day)).unwrap();
SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(user_id, &span)
.await
.unwrap()
}
#[tokio::test]
async fn restating_a_date_replaces_the_row_rather_than_adding_one() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for count in [8_000, 8_412] {
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(count),
Source::Manual,
)])
.await
.unwrap();
}
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
}
#[tokio::test]
async fn an_import_leaves_a_count_the_user_stated_alone() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
assert_eq!(stored[0].source(), &Source::Manual);
}
#[tokio::test]
async fn a_count_the_user_states_replaces_what_a_provider_reported() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored[0].value(), &steps(8_412));
assert_eq!(stored[0].source(), &Source::Manual);
}
#[tokio::test]
async fn a_provider_is_remembered_as_the_source() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(1_000),
from_provider(),
)])
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored[0].source(), &from_provider());
}
#[tokio::test]
async fn a_row_of_a_kind_this_build_does_not_know_is_skipped_and_its_neighbours_survive() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind("telepathy")
.bind(42)
.execute(&pool)
.await
.unwrap();
let stored = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].value(), &steps(8_412));
}
#[tokio::test]
async fn a_value_outside_its_range_is_skipped_and_its_neighbours_survive() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-19"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
sqlx::query("INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)")
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind("steps")
.bind(900_000)
.execute(&pool)
.await
.unwrap();
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].date(), &on("2026-08-19"));
}
#[tokio::test]
async fn days_outside_the_span_are_not_returned() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for day in ["2026-07-31", "2026-08-01", "2026-08-31", "2026-09-01"] {
metrics
.save(&[DailyMetric::new(
user_id.clone(),
on(day),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
}
let span = DateSpan::new(on("2026-08-01"), on("2026-08-31")).unwrap();
let stored = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
let days: Vec<String> = stored
.iter()
.map(|metric| metric.date().to_string())
.collect();
assert_eq!(days, ["2026-08-01", "2026-08-31"]);
}
#[tokio::test]
async fn clearing_a_users_data_removes_their_days() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_all_user_data(&user_id)
.await
.unwrap();
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
}
#[tokio::test]
async fn deleting_an_account_removes_its_days() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(&user_id)
.await
.unwrap();
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM daily_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining.0, 0);
}
#[tokio::test]
async fn clearing_a_kind_deletes_only_that_row() {
let (pool, user_id) = a_pool_with_a_user().await;
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
metrics
.save(&[
DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
),
DailyMetric::new(
user_id.clone(),
on("2026-08-20"),
MetricValue::Hrv(Hrv::new(61).unwrap()),
Source::Manual,
),
DailyMetric::new(
user_id.clone(),
on("2026-08-21"),
steps(9_000),
Source::Manual,
),
])
.await
.unwrap();
metrics
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
let remaining = stored_for(&pool, &user_id, "2026-08-20").await;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].kind(), MetricKind::Hrv);
assert_eq!(stored_for(&pool, &user_id, "2026-08-21").await.len(), 1);
}
#[tokio::test]
async fn clearing_a_kind_that_was_never_stored_is_not_an_error() {
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.delete(&user_id, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
assert!(stored_for(&pool, &user_id, "2026-08-20").await.is_empty());
}
#[tokio::test]
async fn clearing_a_kind_leaves_another_accounts_day_alone() {
let (pool, mine) = a_pool_with_a_user().await;
let theirs = test_user("bob");
SqliteUserCommandRepository::new(pool.clone())
.save(&theirs)
.await
.unwrap();
let metrics = SqliteDailyMetricCommandRepository::new(pool.clone());
for owner in [&mine, theirs.id()] {
metrics
.save(&[DailyMetric::new(
owner.clone(),
on("2026-08-20"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
}
metrics
.delete(&mine, &on("2026-08-20"), &[MetricKind::Steps])
.await
.unwrap();
assert!(stored_for(&pool, &mine, "2026-08-20").await.is_empty());
assert_eq!(
stored_for(&pool, theirs.id(), "2026-08-20").await.len(),
1,
"the other account's day was cleared too"
);
}
#[tokio::test]
async fn a_stored_row_this_build_cannot_read_lands_in_the_rejection_trace() {
use domain::ports::RejectionQueryPort;
use domain::rejection::RejectionOrigin;
let (pool, user_id) = a_pool_with_a_user().await;
SqliteDailyMetricCommandRepository::new(pool.clone())
.save(&[DailyMetric::new(
user_id.clone(),
on("2026-08-19"),
steps(8_412),
Source::Manual,
)])
.await
.unwrap();
for (kind, value) in [("telepathy", 42), ("steps", 900_000)] {
sqlx::query(
"INSERT INTO daily_metrics (user_id, date, kind, value, provider) VALUES (?, ?, ?, ?, NULL)",
)
.bind(user_id.value().to_string())
.bind("2026-08-20")
.bind(kind)
.bind(value)
.execute(&pool)
.await
.unwrap();
}
let span = DateSpan::new(on("2026-08-19"), on("2026-08-20")).unwrap();
let readable = SqliteDailyMetricQueryRepository::new(pool.clone(), a_trace(pool.clone()))
.find_by_span(&user_id, &span)
.await
.unwrap();
let trace = SqliteRejectionRepository::new(pool.clone(), REJECTIONS_KEPT)
.find_recent_by_user(&user_id)
.await
.unwrap();
assert_eq!(readable.len(), 1, "the good row still comes back");
assert_eq!(trace.len(), 2, "both unreadable rows are recorded");
assert!(
trace
.iter()
.all(|entry| entry.origin() == RejectionOrigin::StoredRow)
);
let mut kinds: Vec<&str> = trace.iter().map(|entry| entry.detail().kind()).collect();
kinds.sort_unstable();
assert_eq!(kinds, ["steps", "telepathy"]);
}