198
crates/adapters/sqlite/tests/api_token_test.rs
Normal file
198
crates/adapters/sqlite/tests/api_token_test.rs
Normal 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);
|
||||
}
|
||||
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
285
crates/adapters/sqlite/tests/cascade_test.rs
Normal 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"));
|
||||
}
|
||||
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal file
179
crates/adapters/sqlite/tests/cycle_test.rs
Normal 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);
|
||||
}
|
||||
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal file
445
crates/adapters/sqlite/tests/daily_metric_test.rs
Normal 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"]);
|
||||
}
|
||||
213
crates/adapters/sqlite/tests/job_test.rs
Normal file
213
crates/adapters/sqlite/tests/job_test.rs
Normal 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());
|
||||
}
|
||||
142
crates/adapters/sqlite/tests/migration_check.rs
Normal file
142
crates/adapters/sqlite/tests/migration_check.rs
Normal 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);
|
||||
}
|
||||
217
crates/adapters/sqlite/tests/rejection_test.rs
Normal file
217
crates/adapters/sqlite/tests/rejection_test.rs
Normal 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);
|
||||
}
|
||||
118
crates/adapters/sqlite/tests/two_writers_test.rs
Normal file
118
crates/adapters/sqlite/tests/two_writers_test.rs
Normal 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"));
|
||||
}
|
||||
294
crates/adapters/sqlite/tests/weather_dimension_test.rs
Normal file
294
crates/adapters/sqlite/tests/weather_dimension_test.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user