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,198 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::api_token::{ApiToken, TokenDigest};
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, CascadeDeletePort, UserCommandPort};
use domain::provider::ProviderName;
use domain::testing::test_user;
use domain::user::{User, UserId};
use sqlite::repositories::{
SqliteApiTokenCommandRepository, SqliteApiTokenQueryRepository, SqliteCascadeDeleteRepository,
SqliteUserCommandRepository,
};
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
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)
}
fn a_token(owner: &UserId, name: &str, digest: &str) -> ApiToken {
ApiToken::new(
owner.clone(),
ProviderName::new(name).unwrap(),
TokenDigest::from_persistence(digest.into()),
)
}
#[tokio::test]
async fn a_token_is_found_by_the_digest_of_its_secret() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "iphone-shortcuts", "abc123");
SqliteApiTokenCommandRepository::new(pool.clone())
.save(&token)
.await
.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.expect("the token should be found");
assert_eq!(found.id(), token.id());
assert_eq!(found.name().value(), "iphone-shortcuts");
assert!(found.last_used_at().is_none());
}
#[tokio::test]
async fn a_digest_nobody_stored_finds_nothing() {
let (pool, _) = a_pool_with_a_user().await;
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("nothing".into()))
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn two_tokens_of_one_account_cannot_share_a_name() {
let (pool, user) = a_pool_with_a_user().await;
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens
.save(&a_token(user.id(), "iphone-shortcuts", "first"))
.await
.unwrap();
let again = tokens
.save(&a_token(user.id(), "iphone-shortcuts", "second"))
.await;
let refusal = again
.expect_err("a duplicate name must be refused")
.to_string();
assert!(
refusal.contains("already exists"),
"the refusal should say what is wrong, got: {refusal}"
);
}
#[tokio::test]
async fn using_a_token_is_recorded_against_it() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
tokens.mark_used(token.id()).await.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.unwrap();
assert!(found.last_used_at().is_some());
}
#[tokio::test]
async fn revoking_a_token_removes_it_for_good() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
tokens.revoke(user.id(), token.id()).await.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn a_token_belonging_to_someone_else_cannot_be_revoked() {
let (pool, user) = a_pool_with_a_user().await;
let token = a_token(user.id(), "tasker", "abc123");
let tokens = SqliteApiTokenCommandRepository::new(pool.clone());
tokens.save(&token).await.unwrap();
let attempt = tokens.revoke(&UserId::generate(), token.id()).await;
assert!(attempt.is_err());
assert!(
SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn a_row_with_a_scope_this_build_does_not_know_authenticates_nothing() {
let (pool, user) = a_pool_with_a_user().await;
sqlx::query(
"INSERT INTO api_tokens (id, user_id, name, digest, scope, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user.id().value().to_string())
.bind("legacy")
.bind("abc123")
.bind("readEverything")
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
let found = SqliteApiTokenQueryRepository::new(pool.clone())
.find_by_digest(&TokenDigest::from_persistence("abc123".into()))
.await
.unwrap();
assert!(
found.is_none(),
"an unreadable scope must not grant anything"
);
}
#[tokio::test]
async fn deleting_an_account_takes_its_tokens_with_it() {
let (pool, user) = a_pool_with_a_user().await;
SqliteApiTokenCommandRepository::new(pool.clone())
.save(&a_token(user.id(), "tasker", "abc123"))
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM api_tokens")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining.0, 0);
}

View File

@@ -0,0 +1,285 @@
use sqlx::SqlitePool;
use domain::dimension::DimensionValue;
use domain::entry::{Content, Mood, MoodEntry, MoodEntryId};
use domain::location::Coordinates;
use domain::ports::{EntryDimensionPort, MoodEntryCommandPort, UserCommandPort};
use domain::song::Song;
use domain::testing::test_user;
use domain::user::{User, UserId};
use sqlite::repositories::{
SqliteContentDimensionRepository, SqliteEntryCommandRepository,
SqliteLocationDimensionRepository, SqliteSongDimensionRepository, SqliteUserCommandRepository,
};
const EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY: [&str; 4] = [
"entry_content",
"entry_location",
"entry_song",
"entry_activities",
];
async fn a_file() -> String {
let name = format!("k-mood-cascade-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
async fn an_entry_with_every_dimension(pool: &SqlitePool) -> (User, MoodEntryId) {
let user = test_user("alice");
SqliteUserCommandRepository::new(pool.clone())
.save(&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(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
SqliteContentDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Content(Content::new("a note").unwrap())],
)
.await
.unwrap();
SqliteLocationDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Location(
Coordinates::new(52.2297, 21.0122).unwrap(),
)],
)
.await
.unwrap();
SqliteSongDimensionRepository::new(pool.clone())
.save(
entry.id(),
&[DimensionValue::Song(
Song::new("Teardrop", "Massive Attack", None, None).unwrap(),
)],
)
.await
.unwrap();
(user, entry.id().clone())
}
async fn rows_in(pool: &SqlitePool, table: &str) -> i64 {
let counted: (i64,) =
sqlx::query_as(sqlx::AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}")))
.fetch_one(pool)
.await
.unwrap();
counted.0
}
async fn dimension_rows(pool: &SqlitePool) -> i64 {
let mut total = 0;
for table in EVERY_TABLE_THAT_HANGS_OFF_AN_ENTRY {
total += rows_in(pool, table).await;
}
total
}
#[tokio::test]
async fn foreign_keys_are_switched_on_for_every_connection_the_pool_hands_out() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
for _ in 0..5 {
let on: (i64,) = sqlx::query_as("PRAGMA foreign_keys")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
on.0, 1,
"sqlite ignores ON DELETE CASCADE silently when foreign keys are off"
);
}
remove(&path);
}
#[tokio::test]
async fn deleting_an_entry_really_does_remove_its_dimension_rows() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (_, entry_id) = an_entry_with_every_dimension(&pool).await;
assert!(
dimension_rows(&pool).await >= 3,
"the dimensions were not stored"
);
SqliteEntryCommandRepository::new(pool.clone())
.delete(&entry_id)
.await
.unwrap();
assert_eq!(
dimension_rows(&pool).await,
0,
"ON DELETE CASCADE did not fire"
);
remove(&path);
}
#[tokio::test]
async fn a_dimension_row_cannot_be_written_for_an_entry_that_does_not_exist() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let refused = SqliteContentDimensionRepository::new(pool.clone())
.save(
&MoodEntryId::generate(),
&[DimensionValue::Content(Content::new("orphan").unwrap())],
)
.await;
assert!(
refused.is_err(),
"a foreign key that is not enforced is not a foreign key"
);
remove(&path);
}
#[tokio::test]
async fn deleting_an_account_removes_everything_that_hangs_off_it() {
use domain::ports::CascadeDeletePort;
use sqlite::repositories::SqliteCascadeDeleteRepository;
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (user, _) = an_entry_with_every_dimension(&pool).await;
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
assert_eq!(rows_in(&pool, "users").await, 0);
assert_eq!(rows_in(&pool, "mood_entries").await, 0);
assert_eq!(
dimension_rows(&pool).await,
0,
"the cascade must reach through the entry to its dimensions"
);
remove(&path);
}
#[tokio::test]
async fn the_oldest_tables_do_not_cascade_from_users_which_is_why_they_are_deleted_by_hand() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let (user, _) = an_entry_with_every_dimension(&pool).await;
let refused = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user.id().value().to_string())
.execute(&pool)
.await;
assert!(
refused.is_err(),
"mood_entries references users without ON DELETE CASCADE, so the repository must \
delete the older tables itself. If this now succeeds, the schema gained a cascade \
and those manual deletes are redundant."
);
remove(&path);
}
#[tokio::test]
async fn every_table_added_since_does_cascade_from_users() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let cascading = [
"provider_connections",
"daily_metrics",
"api_tokens",
"metric_rejections",
"cycle_starts",
"user_preferences",
];
for table in cascading {
let sql: (String,) =
sqlx::query_as("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.fetch_one(&pool)
.await
.unwrap();
assert!(
sql.0.contains("REFERENCES users(id) ON DELETE CASCADE"),
"{table} should be removed by the database when its account goes"
);
}
remove(&path);
}
#[tokio::test]
async fn an_entry_cannot_belong_to_an_account_that_does_not_exist() {
let path = a_file().await;
let pool = sqlite::create_pool(&format!("sqlite://{path}"))
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let orphan = MoodEntry::new(
UserId::generate(),
Mood::Good,
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
);
let refused = SqliteEntryCommandRepository::new(pool.clone())
.save(&orphan)
.await;
assert!(refused.is_err(), "an entry with no owner should be refused");
remove(&path);
}
fn remove(path: &str) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}

View File

@@ -0,0 +1,179 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::Date;
use domain::ports::{
CascadeDeletePort, CycleStartCommandPort, CycleStartQueryPort, UserCommandPort,
UserPreferencesCommandPort, UserPreferencesQueryPort,
};
use domain::testing::test_user;
use domain::user::{User, UserId, UserPreferences};
use sqlite::repositories::{
SqliteCascadeDeleteRepository, SqliteCycleStartRepository, SqliteUserCommandRepository,
SqliteUserPreferencesRepository,
};
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
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)
}
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
#[tokio::test]
async fn recording_the_same_start_twice_leaves_one_row() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
assert_eq!(starts.find_by_user(user.id()).await.unwrap().len(), 1);
}
#[tokio::test]
async fn starts_come_back_oldest_first() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
for day in ["2026-02-26", "2026-01-01", "2026-01-29"] {
starts.record(user.id(), &on(day)).await.unwrap();
}
let found: Vec<String> = starts
.find_by_user(user.id())
.await
.unwrap()
.iter()
.map(|date| date.to_string())
.collect();
assert_eq!(found, ["2026-01-01", "2026-01-29", "2026-02-26"]);
}
#[tokio::test]
async fn forgetting_a_start_removes_only_that_one() {
let (pool, user) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(user.id(), &on("2026-01-01")).await.unwrap();
starts.record(user.id(), &on("2026-01-29")).await.unwrap();
starts.forget(user.id(), &on("2026-01-01")).await.unwrap();
let found: Vec<String> = starts
.find_by_user(user.id())
.await
.unwrap()
.iter()
.map(|date| date.to_string())
.collect();
assert_eq!(found, ["2026-01-29"]);
}
#[tokio::test]
async fn one_accounts_starts_are_not_anothers() {
let (pool, mine) = a_pool_with_a_user().await;
let starts = SqliteCycleStartRepository::new(pool.clone());
starts.record(mine.id(), &on("2026-01-01")).await.unwrap();
assert!(
starts
.find_by_user(&UserId::generate())
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn an_account_with_no_stored_preference_has_none_to_read() {
let (pool, user) = a_pool_with_a_user().await;
let found = SqliteUserPreferencesRepository::new(pool.clone())
.find_by_user(user.id())
.await
.unwrap();
assert!(
found.is_none(),
"the default belongs to the domain, not the row"
);
}
#[tokio::test]
async fn a_preference_survives_being_written_twice() {
let (pool, user) = a_pool_with_a_user().await;
let preferences = SqliteUserPreferencesRepository::new(pool.clone());
let mut held = UserPreferences::off_by_default(user.id().clone());
held.track_cycle(true);
preferences.save(&held).await.unwrap();
held.track_cycle(false);
preferences.save(&held).await.unwrap();
let found = preferences.find_by_user(user.id()).await.unwrap().unwrap();
assert!(!found.tracks_cycle());
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
}
#[tokio::test]
async fn deleting_an_account_takes_its_cycle_and_preferences_with_it() {
let (pool, user) = a_pool_with_a_user().await;
SqliteCycleStartRepository::new(pool.clone())
.record(user.id(), &on("2026-01-01"))
.await
.unwrap();
let mut held = UserPreferences::off_by_default(user.id().clone());
held.track_cycle(true);
SqliteUserPreferencesRepository::new(pool.clone())
.save(&held)
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
let starts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cycle_starts")
.fetch_one(&pool)
.await
.unwrap();
let preferences: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_preferences")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(starts.0, 0);
assert_eq!(preferences.0, 0);
}

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

View File

@@ -0,0 +1,213 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::MoodEntryId;
use domain::job::{JobKind, JobStatus, JobSubject};
use domain::ports::{JobQueueCommandPort, JobQueueQueryPort};
use sqlite::repositories::SqliteJobQueueRepository;
const KIND: JobKind = JobKind::BackfillRecordingIdentity;
async fn a_queue() -> (sqlx::SqlitePool, SqliteJobQueueRepository) {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
(pool.clone(), SqliteJobQueueRepository::new(pool))
}
fn about(entry_id: &MoodEntryId) -> JobSubject {
JobSubject::Entry(entry_id.clone())
}
async fn status_of(pool: &sqlx::SqlitePool) -> Vec<(String, i64, Option<String>)> {
sqlx::query_as("SELECT status, attempts, last_error FROM jobs ORDER BY enqueued_at")
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn the_same_work_cannot_be_queued_twice() {
let (pool, queue) = a_queue().await;
let subject = about(&MoodEntryId::generate());
assert!(queue.enqueue(KIND, &subject).await.unwrap());
assert!(!queue.enqueue(KIND, &subject).await.unwrap());
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobs")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
}
#[tokio::test]
async fn claiming_marks_a_job_as_running_so_another_worker_leaves_it_alone() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 10).await.unwrap();
let claimed_again = queue.claim(KIND, 10).await.unwrap();
assert_eq!(claimed.len(), 1);
assert!(
claimed_again.is_empty(),
"a running job is not claimed twice"
);
assert_eq!(status_of(&pool).await[0].0, JobStatus::Running.name());
}
#[tokio::test]
async fn claiming_is_bounded_and_takes_the_oldest_first() {
let (_, queue) = a_queue().await;
for _ in 0..5 {
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
}
let claimed = queue.claim(KIND, 2).await.unwrap();
assert_eq!(claimed.len(), 2);
}
#[tokio::test]
async fn finishing_a_job_removes_it() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue.finish(claimed[0].id()).await.unwrap();
assert!(status_of(&pool).await.is_empty());
}
#[tokio::test]
async fn releasing_a_job_counts_the_attempt_and_keeps_the_reason() {
let (pool, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue
.release(claimed[0].id(), "musicbrainz timed out")
.await
.unwrap();
let stored = status_of(&pool).await;
assert_eq!(stored[0].0, JobStatus::Pending.name());
assert_eq!(stored[0].1, 1);
assert_eq!(stored[0].2.as_deref(), Some("musicbrainz timed out"));
assert_eq!(
queue.claim(KIND, 1).await.unwrap().len(),
1,
"and it is claimable again"
);
}
#[tokio::test]
async fn an_exhausted_job_is_never_claimed_but_can_still_be_seen() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
let claimed = queue.claim(KIND, 1).await.unwrap();
queue.exhaust(claimed[0].id(), "gave up").await.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
let visible = queue.find_exhausted(10).await.unwrap();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].last_error(), Some("gave up"));
}
#[tokio::test]
async fn a_job_left_running_by_a_dead_worker_becomes_claimable_again() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
queue.claim(KIND, 1).await.unwrap();
let reclaimed = queue.reclaim_stalled(0).await.unwrap();
assert_eq!(reclaimed, 1);
assert_eq!(queue.claim(KIND, 1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_job_still_being_worked_on_is_not_reclaimed() {
let (_, queue) = a_queue().await;
queue
.enqueue(KIND, &about(&MoodEntryId::generate()))
.await
.unwrap();
queue.claim(KIND, 1).await.unwrap();
let reclaimed = queue.reclaim_stalled(300).await.unwrap();
assert_eq!(reclaimed, 0, "five minutes have not passed");
}
#[tokio::test]
async fn a_row_of_a_kind_this_build_does_not_know_is_never_claimed() {
let (pool, queue) = a_queue().await;
sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, 'summonRain', ?, 'pending', 0, NULL, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
}
#[tokio::test]
async fn a_job_this_build_cannot_read_is_given_up_on_rather_than_claimed_forever() {
let (pool, queue) = a_queue().await;
sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, 'backfillRecordingIdentity', ?, 'pending', 0, NULL, 'the day before yesterday', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
let stored = status_of(&pool).await;
assert_eq!(
stored[0].0,
JobStatus::Exhausted.name(),
"an unreadable job must stop churning through claim and reclaim"
);
assert!(stored[0].2.is_some(), "and must say why it was given up on");
assert_eq!(queue.reclaim_stalled(0).await.unwrap(), 0);
assert!(queue.claim(KIND, 10).await.unwrap().is_empty());
}

View File

@@ -0,0 +1,142 @@
use sqlx::sqlite::SqlitePoolOptions;
async fn fresh_pool() -> sqlx::SqlitePool {
SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
}
async fn column_names(pool: &sqlx::SqlitePool, table: &str) -> Vec<String> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM pragma_table_info(?) ORDER BY cid")
.bind(table)
.fetch_all(pool)
.await
.unwrap();
rows.into_iter().map(|row| row.0).collect()
}
async fn applied_count(pool: &sqlx::SqlitePool) -> i64 {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM schema_migrations")
.fetch_one(pool)
.await
.unwrap();
row.0
}
#[tokio::test]
async fn every_migration_applies_exactly_once_however_often_the_server_restarts() {
let pool = fresh_pool().await;
sqlite::run_migrations(&pool).await.unwrap();
let after_first_boot = applied_count(&pool).await;
sqlite::run_migrations(&pool).await.unwrap();
sqlite::run_migrations(&pool).await.unwrap();
let after_further_boots = applied_count(&pool).await;
assert!(after_first_boot > 0, "no migrations were applied at all");
assert_eq!(after_first_boot, after_further_boots);
}
#[tokio::test]
async fn mood_entries_no_longer_carries_content() {
let pool = fresh_pool().await;
sqlite::run_migrations(&pool).await.unwrap();
assert_eq!(
column_names(&pool, "mood_entries").await,
[
"id",
"user_id",
"mood",
"logged_at",
"created_at",
"updated_at"
]
);
assert_eq!(
column_names(&pool, "entry_content").await,
["entry_id", "content"]
);
}
fn a_shared_file() -> String {
let name = format!("k-mood-migrating-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
fn forget(path: &str) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}
#[tokio::test]
async fn two_processes_starting_together_both_migrate_successfully() {
let path = a_shared_file();
let url = format!("sqlite://{path}");
let server = sqlite::create_pool(&url).await.unwrap();
let worker = sqlite::create_pool(&url).await.unwrap();
let (migrating_server, migrating_worker) = tokio::join!(
tokio::spawn({
let pool = server.clone();
async move { sqlite::run_migrations(&pool).await }
}),
tokio::spawn({
let pool = worker.clone();
async move { sqlite::run_migrations(&pool).await }
}),
);
let by_the_server = migrating_server.unwrap();
let by_the_worker = migrating_worker.unwrap();
assert!(
by_the_server.is_ok(),
"the server could not start alongside the worker: {by_the_server:?}"
);
assert!(
by_the_worker.is_ok(),
"the worker could not start alongside the server: {by_the_worker:?}"
);
let applied = applied_count(&server).await;
let names: Vec<(String,)> = sqlx::query_as("SELECT name FROM schema_migrations")
.fetch_all(&server)
.await
.unwrap();
assert_eq!(
applied as usize,
names.len(),
"no migration is recorded twice"
);
assert!(applied > 0);
forget(&path);
}
#[tokio::test]
async fn a_process_joining_a_migrated_database_applies_nothing() {
let path = a_shared_file();
let url = format!("sqlite://{path}");
let first = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&first).await.unwrap();
let already_applied = applied_count(&first).await;
let second = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&second).await.unwrap();
assert_eq!(applied_count(&second).await, already_applied);
forget(&path);
}

View File

@@ -0,0 +1,217 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::entry::Date;
use domain::ports::{CascadeDeletePort, RejectionCommandPort, RejectionQueryPort, UserCommandPort};
use domain::provider::ProviderName;
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
use domain::testing::test_user;
use domain::user::{User, UserId};
use sqlite::repositories::{
SqliteCascadeDeleteRepository, SqliteRejectionRepository, SqliteUserCommandRepository,
};
const KEPT: usize = 5;
async fn a_pool_with_a_user() -> (sqlx::SqlitePool, User) {
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)
}
fn rejected(owner: &UserId, kind: &str, reason: &str) -> RejectedMetric {
RejectedMetric::new(
owner.clone(),
RejectionOrigin::Import,
RejectionDetail::new(
Some(ProviderName::new("healthkit").unwrap()),
Some(Date::from_persistence("2026-08-20".parse().unwrap())),
kind,
Some(9_999),
),
reason,
)
}
#[tokio::test]
async fn a_recorded_rejection_comes_back_with_everything_needed_to_diagnose_it() {
let (pool, user) = a_pool_with_a_user().await;
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
trace
.record(&[rejected(
user.id(),
"hrv",
"heart rate variability must be between 1 and 300",
)])
.await
.unwrap();
let found = trace.find_recent_by_user(user.id()).await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].origin(), RejectionOrigin::Import);
assert_eq!(found[0].detail().kind(), "hrv");
assert_eq!(found[0].detail().value(), Some(9_999));
assert_eq!(found[0].detail().date().unwrap().to_string(), "2026-08-20");
assert_eq!(found[0].detail().provider().unwrap().value(), "healthkit");
assert!(found[0].reason().contains("between 1 and 300"));
}
#[tokio::test]
async fn only_the_most_recent_rejections_are_kept() {
let (pool, user) = a_pool_with_a_user().await;
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
for number in 0..12 {
trace
.record(&[rejected(user.id(), "hrv", &format!("failure {number}"))])
.await
.unwrap();
}
let found = trace.find_recent_by_user(user.id()).await.unwrap();
assert_eq!(found.len(), KEPT);
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM metric_rejections")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(total.0, KEPT as i64, "older rows should have been pruned");
}
#[tokio::test]
async fn one_accounts_rejections_are_never_shown_to_another() {
let (pool, mine) = a_pool_with_a_user().await;
let theirs = test_user("bob");
SqliteUserCommandRepository::new(pool.clone())
.save(&theirs)
.await
.unwrap();
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
trace
.record(&[rejected(mine.id(), "hrv", "mine")])
.await
.unwrap();
trace
.record(&[rejected(theirs.id(), "steps", "theirs")])
.await
.unwrap();
let found = trace.find_recent_by_user(mine.id()).await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].reason(), "mine");
}
#[tokio::test]
async fn pruning_one_account_leaves_anothers_alone() {
let (pool, mine) = a_pool_with_a_user().await;
let theirs = test_user("bob");
SqliteUserCommandRepository::new(pool.clone())
.save(&theirs)
.await
.unwrap();
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
trace
.record(&[rejected(theirs.id(), "steps", "theirs")])
.await
.unwrap();
for number in 0..12 {
trace
.record(&[rejected(mine.id(), "hrv", &format!("failure {number}"))])
.await
.unwrap();
}
assert_eq!(
trace.find_recent_by_user(theirs.id()).await.unwrap().len(),
1
);
}
#[tokio::test]
async fn a_rejection_with_no_date_is_still_recorded() {
let (pool, user) = a_pool_with_a_user().await;
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
trace
.record(&[RejectedMetric::new(
user.id().clone(),
RejectionOrigin::Import,
RejectionDetail::new(None, None, "steps", None),
"yesterday is not a date",
)])
.await
.unwrap();
let found = trace.find_recent_by_user(user.id()).await.unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].detail().date().is_none());
assert!(found[0].detail().provider().is_none());
assert!(found[0].detail().value().is_none());
}
#[tokio::test]
async fn a_row_with_an_origin_this_build_does_not_know_is_skipped() {
let (pool, user) = a_pool_with_a_user().await;
let trace = SqliteRejectionRepository::new(pool.clone(), KEPT);
trace
.record(&[rejected(user.id(), "hrv", "readable")])
.await
.unwrap();
sqlx::query(
"INSERT INTO metric_rejections
(id, user_id, origin, provider, date, kind, value, reason, recorded_at)
VALUES (?, ?, 'telepathy', NULL, NULL, 'steps', NULL, 'from the future', ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user.id().value().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&pool)
.await
.unwrap();
let found = trace.find_recent_by_user(user.id()).await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].reason(), "readable");
}
#[tokio::test]
async fn deleting_an_account_takes_its_rejections_with_it() {
let (pool, user) = a_pool_with_a_user().await;
SqliteRejectionRepository::new(pool.clone(), KEPT)
.record(&[rejected(user.id(), "hrv", "whatever")])
.await
.unwrap();
SqliteCascadeDeleteRepository::new(pool.clone())
.delete_user_account(user.id())
.await
.unwrap();
let remaining: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM metric_rejections")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining.0, 0);
}

View File

@@ -0,0 +1,118 @@
use domain::entry::MoodEntryId;
use domain::job::{JobKind, JobSubject};
use domain::ports::JobQueueCommandPort;
use sqlite::repositories::SqliteJobQueueRepository;
const KIND: JobKind = JobKind::BackfillRecordingIdentity;
fn a_file() -> String {
let name = format!("k-mood-two-writers-{}.sqlite", uuid::Uuid::new_v4());
std::env::temp_dir()
.join(name)
.to_string_lossy()
.to_string()
}
#[tokio::test]
async fn two_processes_writing_one_file_both_succeed() {
let path = a_file();
let url = format!("sqlite://{path}");
let server = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&server).await.unwrap();
let worker = sqlite::create_pool(&url).await.unwrap();
let enqueuing = tokio::spawn({
let queue = SqliteJobQueueRepository::new(server.clone());
async move {
for _ in 0..80 {
queue
.enqueue(KIND, &JobSubject::Entry(MoodEntryId::generate()))
.await
.expect("the server's write should not be refused");
}
}
});
let working = tokio::spawn({
let queue = SqliteJobQueueRepository::new(worker.clone());
async move {
let mut claimed = 0;
for _ in 0..80 {
let batch = queue
.claim(KIND, 5)
.await
.expect("the worker's write should not be refused");
for job in &batch {
queue
.finish(job.id())
.await
.expect("finishing should not be refused");
}
claimed += batch.len();
}
claimed
}
});
enqueuing.await.unwrap();
let claimed = working.await.unwrap();
assert!(claimed > 0, "the worker never saw any of the server's work");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}
#[tokio::test]
async fn a_writer_waits_for_a_held_lock_rather_than_being_refused() {
let path = a_file();
let url = format!("sqlite://{path}");
let holder = sqlite::create_pool(&url).await.unwrap();
sqlite::run_migrations(&holder).await.unwrap();
let other = sqlite::create_pool(&url).await.unwrap();
let mut held = holder.begin().await.unwrap();
sqlx::query(
"INSERT INTO jobs (id, kind, subject, status, attempts, last_error, enqueued_at, updated_at)
VALUES (?, 'backfillRecordingIdentity', ?, 'pending', 0, NULL, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(uuid::Uuid::new_v4().to_string())
.bind(chrono::Utc::now().to_rfc3339())
.bind(chrono::Utc::now().to_rfc3339())
.execute(&mut *held)
.await
.unwrap();
let waiting = tokio::spawn({
let queue = SqliteJobQueueRepository::new(other);
async move {
queue
.enqueue(KIND, &JobSubject::Entry(MoodEntryId::generate()))
.await
}
});
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
held.commit().await.unwrap();
let written = waiting.await.unwrap();
assert!(
written.is_ok(),
"the second writer was refused instead of waiting: {written:?}"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{path}-wal"));
let _ = std::fs::remove_file(format!("{path}-shm"));
}

View File

@@ -0,0 +1,294 @@
use sqlx::sqlite::SqlitePoolOptions;
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::{Mood, MoodEntry, MoodEntryId};
use domain::location::Coordinates;
use domain::ports::{EntryDimensionPort, MoodEntryCommandPort, UserCommandPort};
use domain::provider::ProviderName;
use domain::testing::test_user;
use domain::weather::{Celsius, Condition, Weather};
use sqlite::repositories::{
SqliteEntryCommandRepository, SqliteLocationDimensionRepository, SqliteUserCommandRepository,
SqliteWeatherDimensionRepository,
};
async fn an_entry() -> (sqlx::SqlitePool, MoodEntryId) {
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();
let entry = MoodEntry::new(
user.id().clone(),
Mood::Good,
chrono::DateTime::parse_from_rfc3339("2026-08-20T14:00:00+02:00").unwrap(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
(pool, entry.id().clone())
}
fn observed(condition: Condition, degrees: f64) -> DimensionValue {
DimensionValue::Weather(Weather::new(
condition,
Celsius::new(degrees).unwrap(),
ProviderName::new("open-meteo").unwrap(),
))
}
async fn weather_of(pool: &sqlx::SqlitePool, entry_id: &MoodEntryId) -> Option<DimensionValue> {
SqliteWeatherDimensionRepository::new(pool.clone())
.load(std::slice::from_ref(entry_id))
.await
.unwrap()
.remove(entry_id)
}
#[tokio::test]
async fn observed_weather_is_stored_and_read_back_whole() {
let (pool, entry_id) = an_entry().await;
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
weather
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
.await
.unwrap();
let Some(DimensionValue::Weather(stored)) = weather_of(&pool, &entry_id).await else {
panic!("the weather was not stored");
};
assert_eq!(stored.condition(), Condition::Rain);
assert!((stored.temperature().value() - 11.5).abs() < f64::EPSILON);
assert_eq!(stored.observed_by().value(), "open-meteo");
}
#[tokio::test]
async fn editing_an_entry_cannot_erase_what_a_provider_observed() {
let (pool, entry_id) = an_entry().await;
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
weather
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
.await
.unwrap();
let an_edit_that_says_nothing_about_weather = vec![DimensionValue::Location(
Coordinates::new(52.2297, 21.0122).unwrap(),
)];
weather
.save(&entry_id, &an_edit_that_says_nothing_about_weather)
.await
.unwrap();
assert!(
weather_of(&pool, &entry_id).await.is_some(),
"weather is observed, not stated: an edit that omits it is not a request to delete it"
);
}
#[tokio::test]
async fn a_later_observation_replaces_an_earlier_one() {
let (pool, entry_id) = an_entry().await;
let weather = SqliteWeatherDimensionRepository::new(pool.clone());
weather
.save(&entry_id, &[observed(Condition::Clear, 20.0)])
.await
.unwrap();
weather
.save(&entry_id, &[observed(Condition::Snow, -2.0)])
.await
.unwrap();
let Some(DimensionValue::Weather(stored)) = weather_of(&pool, &entry_id).await else {
panic!("the weather was lost");
};
assert_eq!(stored.condition(), Condition::Snow);
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM entry_weather")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 1);
}
#[tokio::test]
async fn a_condition_this_build_does_not_know_is_skipped() {
let (pool, entry_id) = an_entry().await;
sqlx::query(
"INSERT INTO entry_weather (entry_id, condition, temperature, observed_by)
VALUES (?, 'raining frogs', 11.5, 'open-meteo')",
)
.bind(entry_id.value().to_string())
.execute(&pool)
.await
.unwrap();
assert!(weather_of(&pool, &entry_id).await.is_none());
}
#[tokio::test]
async fn deleting_an_entry_takes_its_weather_with_it() {
let (pool, entry_id) = an_entry().await;
SqliteWeatherDimensionRepository::new(pool.clone())
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
.await
.unwrap();
SqliteEntryCommandRepository::new(pool.clone())
.delete(&entry_id)
.await
.unwrap();
let rows: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM entry_weather")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows.0, 0);
}
#[tokio::test]
async fn weather_reports_its_own_kind() {
assert_eq!(observed(Condition::Fog, 3.0).kind(), DimensionKind::Weather);
let _ = SqliteLocationDimensionRepository::new;
}
#[tokio::test]
async fn a_place_with_no_weather_is_backlogged_and_then_is_not() {
use domain::ports::WeatherBacklogQueryPort;
use sqlite::repositories::SqliteWeatherBacklogRepository;
let (pool, entry_id) = an_entry().await;
SqliteLocationDimensionRepository::new(pool.clone())
.save(
&entry_id,
&[DimensionValue::Location(
Coordinates::new(52.2297, 21.0122).unwrap(),
)],
)
.await
.unwrap();
let backlog = SqliteWeatherBacklogRepository::new(pool.clone());
let waiting = backlog.find_places_without_weather(50).await.unwrap();
assert_eq!(waiting.len(), 1);
assert_eq!(waiting[0].entry_id, entry_id);
assert!((waiting[0].coordinates.latitude().value() - 52.2297).abs() < 1e-9);
SqliteWeatherDimensionRepository::new(pool.clone())
.save(&entry_id, &[observed(Condition::Rain, 11.5)])
.await
.unwrap();
assert!(
backlog
.find_places_without_weather(50)
.await
.unwrap()
.is_empty(),
"an entry that now has weather is no longer waiting for any"
);
}
#[tokio::test]
async fn only_entries_that_know_where_they_were_are_backlogged() {
use domain::ports::WeatherBacklogQueryPort;
use sqlite::repositories::SqliteWeatherBacklogRepository;
let (pool, somewhere_known) = an_entry().await;
SqliteLocationDimensionRepository::new(pool.clone())
.save(
&somewhere_known,
&[DimensionValue::Location(
Coordinates::new(52.2297, 21.0122).unwrap(),
)],
)
.await
.unwrap();
let owner: (String,) = sqlx::query_as("SELECT id FROM users LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
let nowhere = MoodEntry::new(
domain::user::UserId::from_uuid(owner.0.parse().unwrap()),
Mood::Meh,
chrono::DateTime::parse_from_rfc3339("2026-08-21T09:00:00+02:00").unwrap(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&nowhere)
.await
.unwrap();
let waiting = SqliteWeatherBacklogRepository::new(pool)
.find_places_without_weather(50)
.await
.unwrap();
let ids: Vec<&MoodEntryId> = waiting.iter().map(|place| &place.entry_id).collect();
assert_eq!(
ids,
[&somewhere_known],
"weather needs somewhere to have happened"
);
}
#[tokio::test]
async fn the_backlog_is_bounded_by_what_is_asked_for() {
use domain::ports::WeatherBacklogQueryPort;
use sqlite::repositories::SqliteWeatherBacklogRepository;
let (pool, first) = an_entry().await;
let locations = SqliteLocationDimensionRepository::new(pool.clone());
let somewhere = DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap());
locations
.save(&first, std::slice::from_ref(&somewhere))
.await
.unwrap();
let owner: (String,) = sqlx::query_as("SELECT id FROM users LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
for hour in 0..5 {
let entry = MoodEntry::new(
domain::user::UserId::from_uuid(owner.0.parse().unwrap()),
Mood::Good,
chrono::DateTime::parse_from_rfc3339(&format!("2026-08-2{hour}T09:00:00+02:00"))
.unwrap(),
);
SqliteEntryCommandRepository::new(pool.clone())
.save(&entry)
.await
.unwrap();
locations
.save(entry.id(), std::slice::from_ref(&somewhere))
.await
.unwrap();
}
let waiting = SqliteWeatherBacklogRepository::new(pool)
.find_places_without_weather(3)
.await
.unwrap();
assert_eq!(waiting.len(), 3);
}