spa hardening, offline logging, rate limit fixes

server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -152,6 +152,7 @@ async fn deletes_activity() {
store.save(&activity).await.unwrap();
let deps = delete_activity::Deps {
entries: store.clone(),
command: store.clone(),
query: store.clone(),
};
@@ -193,6 +194,7 @@ async fn delete_rejects_different_user() {
store.save(&activity).await.unwrap();
let deps = delete_activity::Deps {
entries: store.clone(),
command: store.clone(),
query: store.clone(),
};
@@ -218,3 +220,73 @@ async fn archive_rejects_different_user() {
let result = archive_activity::archive(activity.id().clone(), UserId::generate(), &deps).await;
assert!(result.is_err());
}
#[tokio::test]
async fn an_activity_entries_still_wear_cannot_be_deleted() {
let store = store_and_events();
let user = test_user("alice");
let activity = test_activity(user.id().clone(), "gaming");
store.save(&activity).await.unwrap();
let entry = domain::testing::test_entry(user.id().clone(), domain::entry::Mood::Good);
domain::ports::MoodEntryCommandPort::save(store.as_ref(), &entry)
.await
.unwrap();
store.assign_activities(entry.id(), vec![activity.id().clone()]);
let deps = delete_activity::Deps {
entries: store.clone(),
command: store.clone(),
query: store.clone(),
};
let refused = delete_activity::execute(activity.id().clone(), user.id().clone(), &deps).await;
assert!(
matches!(
refused,
Err(application::errors::ApplicationError::Validation(_))
),
"deleting a tagged activity would rewrite history, got {refused:?}"
);
assert_eq!(
store.activity_count(),
1,
"the activity must survive so it can be archived instead"
);
}
#[tokio::test]
async fn an_archived_activity_is_still_listable_so_it_can_be_unarchived() {
let store = store_and_events();
let user = test_user("alice");
let kept = test_activity(user.id().clone(), "reading");
store.save(&kept).await.unwrap();
let mut retired = test_activity(user.id().clone(), "fencing");
retired.archive().unwrap();
store.save(&retired).await.unwrap();
let deps = list_activities::Deps {
query: store.clone(),
};
let offered = list_activities::active_only(user.id().clone(), &deps)
.await
.unwrap();
assert_eq!(offered.len(), 1, "only the active one may be tagged");
let everything = list_activities::all(user.id().clone(), &deps)
.await
.unwrap();
assert_eq!(
everything.len(),
2,
"archiving must not put an activity beyond reach"
);
assert!(
everything.iter().any(|activity| activity.is_archived()),
"the archived one is the point"
);
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use domain::api_token::ApiToken;
use domain::api_token::{ApiToken, TokenScope, TokenScopes};
use domain::ports::UserCommandPort;
use domain::provider::ProviderName;
use domain::testing::{FakeApiTokenSecret, InMemoryStore, test_user};
@@ -31,6 +31,14 @@ async fn a_user_with_no_tokens() -> Fixture {
impl Fixture {
async fn mint(&self, name: &str) -> Result<String, application::errors::ApplicationError> {
self.mint_granting(name, [TokenScope::WriteMetrics]).await
}
async fn mint_granting(
&self,
name: &str,
scopes: impl IntoIterator<Item = TokenScope>,
) -> Result<String, application::errors::ApplicationError> {
let deps = mint_api_token::Deps {
command: self.store.clone(),
secrets: self.secrets.clone(),
@@ -40,6 +48,7 @@ impl Fixture {
MintApiTokenCommand {
user_id: self.user.id().clone(),
name: ProviderName::new(name)?,
scopes: TokenScopes::new(scopes)?,
},
&deps,
)
@@ -49,13 +58,20 @@ impl Fixture {
}
async fn authenticate(&self, secret: &str) -> Option<ApiToken> {
self.authenticate_for(secret, TokenScope::WriteMetrics)
.await
}
async fn authenticate_for(&self, secret: &str, needed: TokenScope) -> Option<ApiToken> {
let deps = authenticate_api_token::Deps {
query: self.store.clone(),
command: self.store.clone(),
secrets: self.secrets.clone(),
};
authenticate_api_token::execute(secret, &deps).await.ok()
authenticate_api_token::execute(secret, needed, &deps)
.await
.ok()
}
async fn list(&self) -> Vec<ApiToken> {
@@ -181,3 +197,83 @@ async fn every_minting_produces_a_different_secret() {
assert_ne!(first, second);
}
#[tokio::test]
async fn a_token_authenticates_only_for_a_scope_it_grants() {
let fixture = a_user_with_no_tokens().await;
let secret = fixture
.mint_granting("widget", [TokenScope::ReadJournal])
.await
.unwrap();
assert!(
fixture
.authenticate_for(&secret, TokenScope::ReadJournal)
.await
.is_some(),
"the scope it was minted for must work"
);
for refused in [
TokenScope::WriteJournal,
TokenScope::WriteMetrics,
TokenScope::ReadProfile,
] {
assert!(
fixture.authenticate_for(&secret, refused).await.is_none(),
"a read-only token must not pass for {refused}"
);
}
}
#[tokio::test]
async fn a_token_can_grant_several_scopes_at_once() {
let fixture = a_user_with_no_tokens().await;
let secret = fixture
.mint_granting(
"widget",
[TokenScope::ReadJournal, TokenScope::WriteJournal],
)
.await
.unwrap();
assert!(
fixture
.authenticate_for(&secret, TokenScope::ReadJournal)
.await
.is_some()
);
assert!(
fixture
.authenticate_for(&secret, TokenScope::WriteJournal)
.await
.is_some()
);
assert!(
fixture
.authenticate_for(&secret, TokenScope::ReadProfile)
.await
.is_none()
);
}
#[tokio::test]
async fn the_scopes_a_token_grants_are_listed_back() {
let fixture = a_user_with_no_tokens().await;
fixture
.mint_granting(
"widget",
[TokenScope::ReadJournal, TokenScope::WriteJournal],
)
.await
.unwrap();
let listed = fixture.list().await;
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].scopes().names(),
vec!["readJournal", "writeJournal"],
"a client needs to see what a token it holds can do"
);
}

View File

@@ -68,14 +68,11 @@ async fn a_populated_account() -> World {
.await
.unwrap();
let attached = vec![
DimensionValue::Content(Content::new("Long walk by the river").unwrap()),
DimensionValue::activities(vec![walking.id().clone()]),
DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap()),
DimensionValue::Song(
Song::new("Teardrop", "Massive Attack", Some("Mezzanine".into()), None).unwrap(),
),
];
let attached = [
attached_to_the_walk(),
vec![DimensionValue::activities(vec![walking.id().clone()])],
]
.concat();
for port in &dimensions {
port.save(entry.id(), &attached).await.unwrap();
}
@@ -123,6 +120,16 @@ async fn a_populated_account() -> World {
}
}
fn attached_to_the_walk() -> Vec<DimensionValue> {
vec![
DimensionValue::Content(Content::new("Long walk by the river").unwrap()),
DimensionValue::Location(Coordinates::new(52.2297, 21.0122).unwrap()),
DimensionValue::Song(
Song::new("Teardrop", "Massive Attack", Some("Mezzanine".into()), None).unwrap(),
),
]
}
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
@@ -147,6 +154,9 @@ async fn backup_of(world: &World) -> Vec<u8> {
async fn restore_into(world: &World, archive: Vec<u8>) -> RestoreOutcome {
let deps = restore_backup::Deps {
entry_query: world.store.clone(),
reminder_query: world.store.clone(),
media_ownership: world.store.clone(),
reader: Arc::new(importer::KmoodBackupAdapter),
entry_command: world.store.clone(),
dimensions: world.dimensions.clone(),
@@ -212,10 +222,9 @@ async fn every_dimension_survives_the_round_trip() {
let restored = an_empty_account().await;
restore_into(&restored, archive).await;
let entries =
MoodEntryQueryPort::find_by_user(restored.store.as_ref(), restored.user.id(), None, None)
.await
.unwrap();
let entries = MoodEntryQueryPort::find_all_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
let composed = application::entry::composition::EntryComposer::new(restored.dimensions.clone())
.compose(entries)
.await
@@ -248,10 +257,9 @@ async fn a_restored_activity_tag_points_at_the_restored_activity() {
let catalog = ActivityQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
let entries =
MoodEntryQueryPort::find_by_user(restored.store.as_ref(), restored.user.id(), None, None)
.await
.unwrap();
let entries = MoodEntryQueryPort::find_all_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
let composed = application::entry::composition::EntryComposer::new(restored.dimensions.clone())
.compose(entries)
.await
@@ -305,6 +313,9 @@ async fn an_archive_that_is_not_a_backup_is_refused() {
let restored = an_empty_account().await;
let deps = restore_backup::Deps {
entry_query: restored.store.clone(),
reminder_query: restored.store.clone(),
media_ownership: restored.store.clone(),
reader: Arc::new(importer::KmoodBackupAdapter),
entry_command: restored.store.clone(),
dimensions: restored.dimensions.clone(),
@@ -330,3 +341,155 @@ async fn an_archive_that_is_not_a_backup_is_refused() {
assert!(refused.to_string().contains("zip"), "got {refused}");
}
async fn a_world_holding_a_png() -> (
World,
Arc<domain::testing::FakeMediaStorage>,
domain::attachment::PhotoId,
) {
let world = a_populated_account().await;
let media = Arc::new(domain::testing::FakeMediaStorage::holding_nothing());
let photo_id = domain::attachment::PhotoId::generate();
media.put_photo_as(
&photo_id,
&[0x89, b'P', b'N', b'G'],
domain::attachment::ContentType::new("image/png").unwrap(),
);
let entries = MoodEntryQueryPort::find_all_by_user(world.store.as_ref(), world.user.id())
.await
.unwrap();
let everything = [
attached_to_the_walk(),
vec![DimensionValue::Photos(vec![photo_id.clone()])],
]
.concat();
for port in &world.dimensions {
port.save(entries[0].id(), &everything).await.unwrap();
}
(world, media, photo_id)
}
#[tokio::test]
async fn a_photos_own_type_survives_the_round_trip() {
let (world, media, photo_id) = a_world_holding_a_png().await;
let deps = write_backup::Deps {
entries: world.store.clone(),
dimensions: world.dimensions.clone(),
activities: world.store.clone(),
reminders: world.store.clone(),
metrics: world.store.clone(),
cycles: world.store.clone(),
preferences: world.store.clone(),
media_storage: media.clone(),
writer: Arc::new(exporter::ZipBackupWriter),
};
let archive = write_backup::execute(world.user.id().clone(), &deps)
.await
.unwrap();
let restored = an_empty_account().await;
let into = Arc::new(domain::testing::FakeMediaStorage::holding_nothing());
let deps = restore_backup::Deps {
entry_query: restored.store.clone(),
reminder_query: restored.store.clone(),
media_ownership: restored.store.clone(),
reader: Arc::new(importer::KmoodBackupAdapter),
entry_command: restored.store.clone(),
dimensions: restored.dimensions.clone(),
activity_command: restored.store.clone(),
activity_query: restored.store.clone(),
reminder_command: restored.store.clone(),
metrics: restored.store.clone(),
cycles: restored.store.clone(),
preferences_command: restored.store.clone(),
preferences_query: restored.store.clone(),
media_storage: into.clone(),
};
let outcome = restore_backup::execute(
RestoreBackupCommand {
user_id: restored.user.id().clone(),
data: archive,
},
&deps,
)
.await
.unwrap();
assert_eq!(outcome.media, 1, "the photo should have been restored");
assert!(
outcome.unreadable.is_empty(),
"nothing should have been dropped: {:?}",
outcome.unreadable
);
let restored_photo = only_photo_of(&restored).await;
assert_ne!(
restored_photo, photo_id,
"a restored blob is a new blob with its own id"
);
assert_eq!(
into.type_of_photo(&restored_photo).unwrap().value(),
"image/png",
"a PNG must not come back labelled a JPEG"
);
}
async fn only_photo_of(world: &World) -> domain::attachment::PhotoId {
let entries = MoodEntryQueryPort::find_all_by_user(world.store.as_ref(), world.user.id())
.await
.unwrap();
let composed = application::entry::composition::EntryComposer::new(world.dimensions.clone())
.compose(entries)
.await
.unwrap();
composed
.iter()
.flat_map(|entry| entry.photos())
.next()
.expect("the restored entry should carry a photo")
.clone()
}
#[tokio::test]
async fn restoring_the_same_backup_twice_does_not_double_the_account() {
let world = a_populated_account().await;
let archive = backup_of(&world).await;
let restored = an_empty_account().await;
let first = restore_into(&restored, archive.clone()).await;
assert_eq!(first.entries, 1);
assert_eq!(first.reminders, 1);
let again = restore_into(&restored, archive).await;
assert_eq!(again.entries, 0, "the entry was already here");
assert_eq!(again.reminders, 0, "the reminder was already here");
assert_eq!(again.activities, 0, "the activity was already here");
assert!(again.skipped >= 2, "the duplicates should be reported");
let entries = MoodEntryQueryPort::find_all_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
assert_eq!(
entries.len(),
1,
"restoring twice must not duplicate history"
);
let reminders = ReminderQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
assert_eq!(reminders.len(), 1);
}

View File

@@ -78,6 +78,9 @@ async fn correlations(store: &Arc<InMemoryStore>, user: &User) -> Vec<Correlatio
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
domain::dimension::DimensionKind::Weather,
)),
activity_store: Arc::new(domain::testing::InMemoryActivityDimension::sharing(
store.clone(),
)),
preferences: store.clone(),
users: store.clone(),
};
@@ -281,6 +284,9 @@ async fn correlations_with_floor(
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
domain::dimension::DimensionKind::Weather,
)),
activity_store: Arc::new(domain::testing::InMemoryActivityDimension::sharing(
store.clone(),
)),
preferences: store.clone(),
users: store.clone(),
};
@@ -483,7 +489,7 @@ async fn an_activity_never_logged_in_the_span_is_not_a_row_at_all() {
async fn deps_free_entries(store: &Arc<InMemoryStore>, user: &User) -> Vec<MoodEntry> {
use domain::ports::MoodEntryQueryPort;
MoodEntryQueryPort::find_by_user(store.as_ref(), user.id(), None, None)
MoodEntryQueryPort::find_all_by_user(store.as_ref(), user.id())
.await
.unwrap()
}
@@ -502,6 +508,9 @@ async fn correlations_with_rate(
weather_store: Arc::new(domain::testing::InMemoryDimensionStore::new(
domain::dimension::DimensionKind::Weather,
)),
activity_store: Arc::new(domain::testing::InMemoryActivityDimension::sharing(
store.clone(),
)),
preferences: store.clone(),
users: store.clone(),
};
@@ -737,3 +746,137 @@ async fn a_tracked_cycle_is_scored_like_anything_else_continuous() {
["pearson", "spearman", "kendall"]
);
}
async fn an_entry_at(
store: &Arc<InMemoryStore>,
user: &User,
mood: Mood,
at: DateTime<FixedOffset>,
) -> MoodEntry {
let entry = MoodEntry::new(user.id().clone(), mood, at);
MoodEntryCommandPort::save(store.as_ref(), &entry)
.await
.unwrap();
entry
}
fn weather_at(celsius: f64) -> domain::dimension::DimensionValue {
domain::dimension::DimensionValue::Weather(domain::weather::Weather::new(
domain::weather::Condition::Clear,
domain::weather::Celsius::new(celsius).unwrap(),
domain::provider::ProviderName::new("a-provider").unwrap(),
))
}
async fn temperature_row_over(
readings: &[(usize, f64)],
order: &[usize],
) -> Option<CorrelationRow> {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in_warsaw();
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
let weather_store = Arc::new(domain::testing::InMemoryDimensionStore::new(
domain::dimension::DimensionKind::Weather,
));
for day in 0..40 {
let mood = a_rising_mood_for(day);
let mut entries = Vec::new();
for (offset, _) in readings {
entries.push(
an_entry_at(
&store,
&user,
mood,
noon_on(day) + Duration::hours(*offset as i64),
)
.await,
);
}
for position in order {
let (_, celsius) = readings[*position];
weather_store.put(entries[*position].id(), weather_at(celsius));
}
}
let deps = get_correlations::Deps {
entries: store.clone(),
metrics: store.clone(),
activities: store.clone(),
cycles: store.clone(),
weather_store: weather_store.clone(),
activity_store: Arc::new(domain::testing::InMemoryActivityDimension::sharing(
store.clone(),
)),
preferences: store.clone(),
users: store.clone(),
};
let query = CorrelationQuery {
user_id: user.id().clone(),
span: DateSpan::new(date_of(0), date_of(120)).unwrap(),
minimum_sample_size: MINIMUM,
false_discovery_rate: 0.10,
};
get_correlations::execute(query, &deps)
.await
.unwrap()
.into_iter()
.find(|row| row.input == CorrelationInput::Temperature)
}
#[tokio::test]
async fn a_days_temperature_does_not_depend_on_the_order_its_entries_were_written() {
let readings = [(1usize, 4.0f64), (5, 27.0), (9, 15.0)];
let one_way = temperature_row_over(&readings, &[0, 1, 2]).await.unwrap();
let another = temperature_row_over(&readings, &[2, 0, 1]).await.unwrap();
let coefficients = |row: &CorrelationRow| {
row.scores
.iter()
.map(|score| score.coefficient.value())
.collect::<Vec<_>>()
};
assert_eq!(
coefficients(&one_way),
coefficients(&another),
"the day's reading is a property of the day, not of write order"
);
assert_eq!(one_way.sample_size, another.sample_size);
}
#[tokio::test]
async fn a_day_is_represented_by_its_warmest_reading() {
let cold_first = [(1usize, 4.0f64), (5, 27.0)];
let warm_first = [(1usize, 27.0f64), (5, 4.0)];
let only_the_warmest = [(1usize, 27.0f64)];
let over_both_cold_first = temperature_row_over(&cold_first, &[0, 1]).await.unwrap();
let over_both_warm_first = temperature_row_over(&warm_first, &[0, 1]).await.unwrap();
let over_the_warmest = temperature_row_over(&only_the_warmest, &[0]).await.unwrap();
let coefficients = |row: &CorrelationRow| {
row.scores
.iter()
.map(|score| score.coefficient.value())
.collect::<Vec<_>>()
};
assert_eq!(
coefficients(&over_both_cold_first),
coefficients(&over_the_warmest),
"27C is the day's reading whether it was written first or last"
);
assert_eq!(
coefficients(&over_both_warm_first),
coefficients(&over_the_warmest)
);
}

View File

@@ -44,6 +44,7 @@ async fn a_blob_that_refuses_to_go_does_not_stop_the_entry_being_edited() {
.unwrap();
let deps = update_entry::Deps {
activities: store.clone(),
command: store.clone(),
dimensions: vec![photos.clone() as Arc<dyn EntryDimensionPort>],
query: store.clone(),
@@ -54,11 +55,11 @@ async fn a_blob_that_refuses_to_go_does_not_stop_the_entry_being_edited() {
let edited = update_entry::execute(
UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Mood::Rad,
mood: Some(Mood::Rad),
logged_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-08-20T12:00:00+02:00").unwrap(),
),
dimensions: Vec::new(),
dimensions: Some(Vec::new()),
},
owner,
&deps,
@@ -134,6 +135,7 @@ async fn a_token_store_that_cannot_record_a_use_still_authenticates() {
UserId::generate(),
ProviderName::new("iphone-shortcuts").unwrap(),
secrets.digest(&secret),
domain::api_token::TokenScopes::new([domain::api_token::TokenScope::WriteMetrics]).unwrap(),
);
let deps = authenticate_api_token::Deps {
@@ -142,7 +144,12 @@ async fn a_token_store_that_cannot_record_a_use_still_authenticates() {
secrets,
};
let authenticated = authenticate_api_token::execute(&secret, &deps).await;
let authenticated = authenticate_api_token::execute(
&secret,
domain::api_token::TokenScope::WriteMetrics,
&deps,
)
.await;
assert!(
authenticated.is_ok(),
@@ -160,6 +167,7 @@ async fn a_digest_that_matches_nothing_is_still_refused() {
UserId::generate(),
ProviderName::new("iphone-shortcuts").unwrap(),
TokenDigest::from_persistence("some other digest".into()),
domain::api_token::TokenScopes::new([domain::api_token::TokenScope::WriteMetrics]).unwrap(),
);
let deps = authenticate_api_token::Deps {
@@ -169,7 +177,9 @@ async fn a_digest_that_matches_nothing_is_still_refused() {
};
assert!(
authenticate_api_token::execute(&real, &deps).await.is_err(),
authenticate_api_token::execute(&real, domain::api_token::TokenScope::WriteMetrics, &deps)
.await
.is_err(),
"degrading on a write failure must not degrade into accepting anything"
);
}

View File

@@ -4,8 +4,8 @@ use domain::attachment::{PhotoId, VoiceMemoId};
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::{DateRange, Mood, MoodEntry};
use domain::ports::{
ActivityQueryPort, CascadeDeletePort, EntryDimensionPort, MoodEntryCommandPort,
ReminderQueryPort, UserCommandPort,
ActivityQueryPort, CascadeDeletePort, EntryDimensionPort, MediaOwnershipPort,
MoodEntryCommandPort, ReminderQueryPort, UserCommandPort,
};
use domain::testing::{FakeMediaStorage, InMemoryDimensionStore, InMemoryStore, test_user};
use domain::user::{Timezone, User};
@@ -48,6 +48,13 @@ async fn an_entry_with_media(media: &Arc<FakeMediaStorage>) -> Doomed {
media.put_photo(&photo_id, b"a photograph");
media.put_voice_memo(&memo_id, b"a recording");
MediaOwnershipPort::remember(store.as_ref(), user.id(), (&photo_id).into())
.await
.unwrap();
MediaOwnershipPort::remember(store.as_ref(), user.id(), (&memo_id).into())
.await
.unwrap();
photos
.save(
entry.id(),
@@ -150,10 +157,9 @@ async fn clearing_a_users_data_takes_the_blobs_with_it() {
let doomed = an_entry_with_media(&media).await;
let deps = clear_data::Deps {
entry_query: doomed.store.clone(),
dimensions: doomed.dimensions(),
cascade: doomed.store.clone(),
media_storage: media.clone(),
media_ownership: doomed.store.clone(),
};
clear_data::execute(doomed.user.id().clone(), &deps)
@@ -173,10 +179,9 @@ async fn deleting_an_account_takes_the_blobs_with_it() {
let doomed = an_entry_with_media(&media).await;
let deps = delete_user::Deps {
entry_query: doomed.store.clone(),
dimensions: doomed.dimensions(),
cascade: doomed.store.clone(),
media_storage: media.clone(),
media_ownership: doomed.store.clone(),
user_query: doomed.store.clone(),
events: doomed.store.clone(),
};

View File

@@ -13,9 +13,6 @@ mod get_entry_test;
#[path = "entry/list_entries_test.rs"]
mod list_entries_test;
#[path = "entry/filter_test.rs"]
mod filter_test;
#[path = "entry/mood_stats_test.rs"]
mod mood_stats_test;

View File

@@ -16,6 +16,7 @@ fn deps() -> (Arc<InMemoryStore>, create_entry::Deps) {
Arc::new(InMemoryDimensionStore::new(DimensionKind::Activities));
let deps = create_entry::Deps {
activities: store.clone(),
entries: store.clone(),
dimensions: vec![content, activities],
events: store.clone(),
@@ -47,6 +48,9 @@ async fn creates_entry_with_optional_dimensions() {
let (store, deps) = deps();
let user = test_user("bob");
let activity = domain::testing::test_activity(user.id().clone(), "gaming");
domain::ports::ActivityCommandPort::save(store.as_ref(), &activity)
.await
.unwrap();
let cmd = CreateEntryCommand {
user_id: user.id().clone(),
@@ -63,3 +67,190 @@ async fn creates_entry_with_optional_dimensions() {
assert_eq!(entry.mood(), Mood::Rad);
assert_eq!(store.entry_count(), 1);
}
#[tokio::test]
async fn an_entry_cannot_wear_another_accounts_activity() {
let (store, deps) = deps();
let user = test_user("alice");
let stranger = test_user("mallory");
let theirs = domain::testing::test_activity(stranger.id().clone(), "fencing");
domain::ports::ActivityCommandPort::save(store.as_ref(), &theirs)
.await
.unwrap();
let cmd = CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Good,
logged_at: test_logged_at(),
dimensions: vec![DimensionValue::activities(vec![theirs.id().clone()])],
};
let refused = create_entry::execute(cmd, &deps).await;
assert!(
matches!(
refused,
Err(application::errors::ApplicationError::Domain(
domain::errors::DomainError::Forbidden(_)
))
),
"tagging a stranger's activity must be refused, got {refused:?}"
);
assert_eq!(store.entry_count(), 0, "nothing should have been written");
}
#[tokio::test]
async fn an_entry_cannot_wear_an_activity_that_does_not_exist() {
let (store, deps) = deps();
let user = test_user("alice");
let cmd = CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Good,
logged_at: test_logged_at(),
dimensions: vec![DimensionValue::activities(vec![
domain::activity::ActivityId::generate(),
])],
};
assert!(create_entry::execute(cmd, &deps).await.is_err());
assert_eq!(store.entry_count(), 0);
}
#[tokio::test]
async fn an_archived_activity_cannot_be_tagged_onto_a_new_entry() {
let (store, deps) = deps();
let user = test_user("alice");
let mut retired = domain::testing::test_activity(user.id().clone(), "fencing");
retired.archive().unwrap();
domain::ports::ActivityCommandPort::save(store.as_ref(), &retired)
.await
.unwrap();
let cmd = CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Good,
logged_at: test_logged_at(),
dimensions: vec![DimensionValue::activities(vec![retired.id().clone()])],
};
let refused = create_entry::execute(cmd, &deps).await;
assert!(
matches!(
refused,
Err(application::errors::ApplicationError::Validation(_))
),
"an archived activity is retired, not forbidden, got {refused:?}"
);
assert_eq!(store.entry_count(), 0);
}
#[tokio::test]
async fn unarchiving_makes_an_activity_taggable_again() {
let (store, deps) = deps();
let user = test_user("alice");
let mut activity = domain::testing::test_activity(user.id().clone(), "fencing");
activity.archive().unwrap();
activity.unarchive().unwrap();
domain::ports::ActivityCommandPort::save(store.as_ref(), &activity)
.await
.unwrap();
let cmd = CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Good,
logged_at: test_logged_at(),
dimensions: vec![DimensionValue::activities(vec![activity.id().clone()])],
};
create_entry::execute(cmd, &deps).await.unwrap();
assert_eq!(store.entry_count(), 1);
}
#[tokio::test]
async fn many_entries_land_in_one_request() {
let store = Arc::new(InMemoryStore::new());
let content: Arc<dyn EntryDimensionPort> =
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content));
let user = test_user("alice");
let deps = application::entry::use_cases::create_entries::Deps {
entries: store.clone(),
dimensions: vec![content],
activities: store.clone(),
events: store.clone(),
};
let wanted: Vec<CreateEntryCommand> = [Mood::Good, Mood::Bad, Mood::Rad]
.into_iter()
.map(|mood| CreateEntryCommand {
user_id: user.id().clone(),
mood,
logged_at: test_logged_at(),
dimensions: vec![],
})
.collect();
let created =
application::entry::use_cases::create_entries::execute(user.id().clone(), wanted, &deps)
.await
.unwrap();
assert_eq!(created.len(), 3);
assert_eq!(store.entry_count(), 3);
assert_eq!(
store.published_events().len(),
3,
"each entry is its own event"
);
}
#[tokio::test]
async fn one_bad_activity_refuses_the_whole_batch() {
let store = Arc::new(InMemoryStore::new());
let user = test_user("alice");
let stranger = test_user("mallory");
let theirs = domain::testing::test_activity(stranger.id().clone(), "fencing");
domain::ports::ActivityCommandPort::save(store.as_ref(), &theirs)
.await
.unwrap();
let deps = application::entry::use_cases::create_entries::Deps {
entries: store.clone(),
dimensions: vec![],
activities: store.clone(),
events: store.clone(),
};
let wanted = vec![
CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Good,
logged_at: test_logged_at(),
dimensions: vec![],
},
CreateEntryCommand {
user_id: user.id().clone(),
mood: Mood::Bad,
logged_at: test_logged_at(),
dimensions: vec![DimensionValue::activities(vec![theirs.id().clone()])],
},
];
let refused =
application::entry::use_cases::create_entries::execute(user.id().clone(), wanted, &deps)
.await;
assert!(refused.is_err());
assert_eq!(
store.entry_count(),
0,
"the first entry must not survive a batch that was refused"
);
}

View File

@@ -1,6 +1,5 @@
use std::sync::Arc;
use domain::activity::ActivityId;
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::dimension::{DimensionKind, DimensionValue};
use domain::entry::{Mood, MoodEntry};
@@ -105,11 +104,16 @@ async fn an_entry_composes_every_kind_of_dimension_it_has() {
let store = Arc::new(domain::testing::InMemoryStore::new());
let user = test_user("erin");
let activity_id = ActivityId::generate();
let activity = domain::testing::test_activity(user.id().clone(), "reading");
domain::ports::ActivityCommandPort::save(store.as_ref(), &activity)
.await
.unwrap();
let activity_id = activity.id().clone();
let photo_id = PhotoId::generate();
let voice_memo_id = VoiceMemoId::generate();
let deps = create_entry::Deps {
activities: store.clone(),
entries: store.clone(),
dimensions: dimensions.ports(),
events: store.clone(),

View File

@@ -1,67 +0,0 @@
use std::sync::Arc;
use domain::entry::Mood;
use domain::ports::MoodEntryCommandPort;
use domain::testing::{InMemoryStore, test_activity, test_entry, test_user};
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery};
use application::entry::use_cases::{filter_by_activity, filter_by_mood};
#[tokio::test]
async fn filters_by_mood() {
let store = Arc::new(InMemoryStore::new());
let user = test_user("alice");
store
.save(&test_entry(user.id().clone(), Mood::Good))
.await
.unwrap();
store
.save(&test_entry(user.id().clone(), Mood::Good))
.await
.unwrap();
store
.save(&test_entry(user.id().clone(), Mood::Bad))
.await
.unwrap();
let deps = filter_by_mood::Deps {
query: store.clone(),
};
let query = FilterByMoodQuery {
user_id: user.id().clone(),
mood: Mood::Good,
};
let entries = filter_by_mood::execute(query, &deps).await.unwrap();
assert_eq!(entries.len(), 2);
}
#[tokio::test]
async fn filters_by_activity() {
let store = Arc::new(InMemoryStore::new());
let user = test_user("alice");
let activity = test_activity(user.id().clone(), "exercise");
let tagged = test_entry(user.id().clone(), Mood::Good);
store.save(&tagged).await.unwrap();
store.assign_activities(tagged.id(), vec![activity.id().clone()]);
store
.save(&test_entry(user.id().clone(), Mood::Meh))
.await
.unwrap();
let deps = filter_by_activity::Deps {
query: store.clone(),
};
let query = FilterByActivityQuery {
user_id: user.id().clone(),
activity_id: activity.id().clone(),
};
let entries = filter_by_activity::execute(query, &deps).await.unwrap();
assert_eq!(entries.len(), 1);
}

View File

@@ -1,60 +1,165 @@
use std::sync::Arc;
use domain::entry::Mood;
use domain::entry::{EntrySelection, Mood, Pagination};
use domain::ports::MoodEntryCommandPort;
use domain::testing::{InMemoryStore, test_entry, test_user};
use domain::testing::{InMemoryStore, test_activity, test_entry, test_user};
use domain::user::User;
use application::entry::queries::ListEntriesQuery;
use application::entry::use_cases::list_entries;
const A_GENEROUS_PAGE: i64 = 500;
fn everything_of(user: &User) -> ListEntriesQuery {
ListEntriesQuery {
selection: EntrySelection::everything_of(user.id().clone()),
page: Pagination::new(A_GENEROUS_PAGE, 0, A_GENEROUS_PAGE).unwrap(),
}
}
async fn logged(store: &Arc<InMemoryStore>, user: &User, mood: Mood) -> domain::entry::MoodEntry {
let entry = test_entry(user.id().clone(), mood);
store.save(&entry).await.unwrap();
entry
}
#[tokio::test]
async fn lists_entries_for_user() {
async fn lists_only_the_asking_account() {
let store = Arc::new(InMemoryStore::new());
let alice = test_user("alice");
let bob = test_user("bob");
store
.save(&test_entry(alice.id().clone(), Mood::Good))
.await
.unwrap();
store
.save(&test_entry(alice.id().clone(), Mood::Rad))
.await
.unwrap();
store
.save(&test_entry(bob.id().clone(), Mood::Meh))
.await
.unwrap();
logged(&store, &alice, Mood::Good).await;
logged(&store, &alice, Mood::Rad).await;
logged(&store, &bob, Mood::Meh).await;
let deps = list_entries::Deps {
query: store.clone(),
};
let page = list_entries::execute(everything_of(&alice), &deps)
.await
.unwrap();
let query = ListEntriesQuery {
limit: None,
offset: None,
user_id: alice.id().clone(),
range: None,
};
let entries = list_entries::execute(query, &deps).await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(page.items().len(), 2);
assert_eq!(page.total(), 2);
}
#[tokio::test]
async fn returns_empty_for_user_with_no_entries() {
async fn an_account_with_nothing_logged_gets_an_empty_page() {
let store = Arc::new(InMemoryStore::new());
let deps = list_entries::Deps {
query: store.clone(),
};
let page = list_entries::execute(everything_of(&test_user("nobody")), &deps)
.await
.unwrap();
assert!(page.items().is_empty());
assert_eq!(page.total(), 0);
assert!(!page.more_after_this());
}
#[tokio::test]
async fn the_total_counts_the_selection_not_the_page() {
let store = Arc::new(InMemoryStore::new());
let alice = test_user("alice");
for _ in 0..7 {
logged(&store, &alice, Mood::Good).await;
}
let deps = list_entries::Deps {
query: store.clone(),
};
let query = ListEntriesQuery {
limit: None,
offset: None,
user_id: test_user("nobody").id().clone(),
range: None,
selection: EntrySelection::everything_of(alice.id().clone()),
page: Pagination::new(3, 0, A_GENEROUS_PAGE).unwrap(),
};
let entries = list_entries::execute(query, &deps).await.unwrap();
assert!(entries.is_empty());
let page = list_entries::execute(query, &deps).await.unwrap();
assert_eq!(page.items().len(), 3, "the page holds what was asked for");
assert_eq!(page.total(), 7, "the total is what a client pages through");
assert!(page.more_after_this());
}
#[tokio::test]
async fn the_last_page_says_there_is_nothing_after_it() {
let store = Arc::new(InMemoryStore::new());
let alice = test_user("alice");
for _ in 0..5 {
logged(&store, &alice, Mood::Good).await;
}
let deps = list_entries::Deps {
query: store.clone(),
};
let query = ListEntriesQuery {
selection: EntrySelection::everything_of(alice.id().clone()),
page: Pagination::new(3, 3, A_GENEROUS_PAGE).unwrap(),
};
let page = list_entries::execute(query, &deps).await.unwrap();
assert_eq!(page.items().len(), 2);
assert_eq!(page.total(), 5);
assert!(!page.more_after_this());
}
#[tokio::test]
async fn a_selection_can_narrow_to_one_mood() {
let store = Arc::new(InMemoryStore::new());
let alice = test_user("alice");
logged(&store, &alice, Mood::Good).await;
logged(&store, &alice, Mood::Good).await;
logged(&store, &alice, Mood::Bad).await;
let deps = list_entries::Deps {
query: store.clone(),
};
let query = ListEntriesQuery {
selection: EntrySelection::everything_of(alice.id().clone()).of_mood(Some(Mood::Good)),
page: Pagination::new(A_GENEROUS_PAGE, 0, A_GENEROUS_PAGE).unwrap(),
};
let page = list_entries::execute(query, &deps).await.unwrap();
assert_eq!(page.items().len(), 2);
assert_eq!(page.total(), 2);
}
#[tokio::test]
async fn a_selection_can_narrow_to_one_activity() {
let store = Arc::new(InMemoryStore::new());
let alice = test_user("alice");
let exercise = test_activity(alice.id().clone(), "exercise");
let tagged = logged(&store, &alice, Mood::Good).await;
store.assign_activities(tagged.id(), vec![exercise.id().clone()]);
logged(&store, &alice, Mood::Meh).await;
let deps = list_entries::Deps {
query: store.clone(),
};
let query = ListEntriesQuery {
selection: EntrySelection::everything_of(alice.id().clone())
.tagged_with(Some(exercise.id().clone())),
page: Pagination::new(A_GENEROUS_PAGE, 0, A_GENEROUS_PAGE).unwrap(),
};
let page = list_entries::execute(query, &deps).await.unwrap();
assert_eq!(page.items().len(), 1);
assert_eq!(page.items()[0].id(), tagged.id());
}
#[tokio::test]
async fn a_page_bigger_than_the_server_serves_is_refused() {
assert!(Pagination::new(1_000, 0, 200).is_err());
assert!(Pagination::new(0, 0, 200).is_err());
assert!(Pagination::new(10, -1, 200).is_err());
}

View File

@@ -80,3 +80,72 @@ fn user_in(name: &str, timezone: &str) -> domain::user::User {
user.update_timezone(Some(Timezone::new(timezone).unwrap()));
user
}
fn a_user_in_warsaw() -> domain::user::User {
user_in("alice", "Europe/Warsaw")
}
async fn stats_of(
store: &Arc<InMemoryStore>,
user: &domain::user::User,
range: Option<domain::entry::DateRange>,
) -> get_mood_stats::MoodStats {
let deps = get_mood_stats::Deps {
query: store.clone(),
users: store.clone(),
};
get_mood_stats::execute(
MoodStatsQuery {
user_id: user.id().clone(),
range,
},
&deps,
)
.await
.unwrap()
}
#[tokio::test]
async fn the_bounds_are_the_days_the_journal_actually_spans() {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in_warsaw();
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
for days_ago in [0, 5, 40] {
let entry = domain::testing::test_entry_days_ago(user.id().clone(), Mood::Good, days_ago);
MoodEntryCommandPort::save(store.as_ref(), &entry)
.await
.unwrap();
}
let stats = stats_of(&store, &user, None).await;
let today = domain::entry::Date::from_instant(
&chrono::Utc::now().fixed_offset(),
&domain::user::Timezone::new("Europe/Warsaw").unwrap(),
);
assert_eq!(
stats.last_logged_on,
Some(today),
"the newest entry was logged today"
);
assert!(
stats.first_logged_on.unwrap() < today,
"the oldest was forty days back"
);
}
#[tokio::test]
async fn an_empty_journal_has_no_bounds() {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in_warsaw();
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
let stats = stats_of(&store, &user, None).await;
assert_eq!(stats.total_entries, 0);
assert_eq!(stats.first_logged_on, None);
assert_eq!(stats.last_logged_on, None);
}

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::activity::ActivityId;
use domain::entry::Mood;
use domain::ports::{ActivityCommandPort, MoodEntryCommandPort, MoodEntryQueryPort};
use domain::ports::{ActivityCommandPort, MoodEntryCommandPort};
use domain::testing::{InMemoryStore, test_activity, test_entry, test_user};
use application::entry::use_cases::replace_activity;
@@ -29,8 +29,8 @@ async fn replaces_activity_on_entries() {
.await
.unwrap();
let now_tagged_new = store.find_by_activity(user.id(), new.id()).await.unwrap();
let still_tagged_old = store.find_by_activity(user.id(), old.id()).await.unwrap();
let now_tagged_new = tagged_with(&store, &user, new.id()).await;
let still_tagged_old = tagged_with(&store, &user, old.id()).await;
assert_eq!(now_tagged_new.len(), 1);
assert!(still_tagged_old.is_empty());
@@ -80,3 +80,17 @@ async fn rejects_target_owned_by_different_user() {
.await;
assert!(result.is_err());
}
async fn tagged_with(
store: &Arc<InMemoryStore>,
user: &domain::user::User,
activity_id: &domain::activity::ActivityId,
) -> Vec<domain::entry::MoodEntry> {
let selection = domain::entry::EntrySelection::everything_of(user.id().clone())
.tagged_with(Some(activity_id.clone()));
let page = domain::entry::Pagination::new(500, 0, 500).unwrap();
domain::ports::MoodEntryQueryPort::select(store.as_ref(), &selection, page)
.await
.unwrap()
}

View File

@@ -7,7 +7,8 @@ use domain::entry::Mood;
use domain::ports::EntryDimensionPort;
use domain::ports::MoodEntryCommandPort;
use domain::testing::{
InMemoryDimensionStore, InMemoryStore, test_entry, test_logged_at, test_user,
InMemoryActivityDimension, InMemoryDimensionStore, InMemoryStore, test_entry, test_logged_at,
test_user,
};
use domain::user::UserId;
@@ -26,6 +27,7 @@ async fn setup() -> (
store.save(&entry).await.unwrap();
let deps = update_entry::Deps {
activities: store.clone(),
command: store.clone(),
dimensions: vec![
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content))
@@ -45,9 +47,9 @@ async fn updates_mood() {
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Mood::Rad,
mood: Some(Mood::Rad),
logged_at: Some(test_logged_at()),
dimensions: vec![],
dimensions: Some(vec![]),
};
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
@@ -60,6 +62,7 @@ async fn updates_mood() {
async fn updating_nonexistent_entry_fails() {
let store = Arc::new(InMemoryStore::new());
let deps = update_entry::Deps {
activities: store.clone(),
command: store.clone(),
dimensions: vec![
Arc::new(InMemoryDimensionStore::new(DimensionKind::Content))
@@ -72,9 +75,9 @@ async fn updating_nonexistent_entry_fails() {
let cmd = UpdateEntryCommand {
entry_id: domain::entry::MoodEntryId::generate(),
mood: Mood::Good,
mood: Some(Mood::Good),
logged_at: Some(test_logged_at()),
dimensions: vec![],
dimensions: Some(vec![]),
};
let result = update_entry::execute(cmd, UserId::generate(), &deps).await;
@@ -87,9 +90,9 @@ async fn rejects_update_by_different_user() {
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Mood::Rad,
mood: Some(Mood::Rad),
logged_at: Some(test_logged_at()),
dimensions: vec![],
dimensions: Some(vec![]),
};
let other_user = UserId::generate();
@@ -103,9 +106,9 @@ async fn an_update_carrying_no_date_leaves_the_entry_where_it_was() {
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Mood::Rad,
mood: Some(Mood::Rad),
logged_at: None,
dimensions: vec![],
dimensions: Some(vec![]),
};
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
@@ -124,12 +127,131 @@ async fn an_update_carrying_a_date_moves_the_entry_to_it() {
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Mood::Rad,
mood: Some(Mood::Rad),
logged_at: Some(moved_to),
dimensions: vec![],
dimensions: Some(vec![]),
};
let updated = update_entry::execute(cmd, user_id, &deps).await.unwrap();
assert_eq!(updated.logged_at(), &moved_to);
}
async fn an_entry_already_wearing(
user: &domain::user::User,
activity: &domain::activity::Activity,
) -> (
Arc<InMemoryStore>,
update_entry::Deps,
domain::entry::MoodEntry,
) {
let store = Arc::new(InMemoryStore::new());
let entry = test_entry(user.id().clone(), Mood::Meh);
MoodEntryCommandPort::save(store.as_ref(), &entry)
.await
.unwrap();
domain::ports::ActivityCommandPort::save(store.as_ref(), activity)
.await
.unwrap();
let tags: Arc<InMemoryActivityDimension> =
Arc::new(InMemoryActivityDimension::sharing(store.clone()));
tags.save(
entry.id(),
&[domain::dimension::DimensionValue::activities(vec![
activity.id().clone(),
])],
)
.await
.unwrap();
let deps = update_entry::Deps {
activities: store.clone(),
command: store.clone(),
dimensions: vec![tags as Arc<dyn EntryDimensionPort>],
query: store.clone(),
media_storage: store.clone(),
events: store.clone(),
};
(store, deps, entry)
}
#[tokio::test]
async fn an_entry_that_already_wears_an_archived_activity_can_still_be_edited() {
let user = test_user("alice");
let mut retired = domain::testing::test_activity(user.id().clone(), "fencing");
retired.archive().unwrap();
let (_store, deps, entry) = an_entry_already_wearing(&user, &retired).await;
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Some(Mood::Rad),
logged_at: None,
dimensions: Some(vec![domain::dimension::DimensionValue::activities(vec![
retired.id().clone(),
])]),
};
let updated = update_entry::execute(cmd, entry.user_id().clone(), &deps)
.await
.expect("archived activities remain on the entries that already wear them");
assert_eq!(updated.mood(), Mood::Rad);
}
#[tokio::test]
async fn an_archived_activity_cannot_be_added_to_an_entry_that_lacks_it() {
let user = test_user("alice");
let mut retired = domain::testing::test_activity(user.id().clone(), "fencing");
retired.archive().unwrap();
let worn = domain::testing::test_activity(user.id().clone(), "reading");
let (store, deps, entry) = an_entry_already_wearing(&user, &worn).await;
domain::ports::ActivityCommandPort::save(store.as_ref(), &retired)
.await
.unwrap();
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Some(Mood::Rad),
logged_at: None,
dimensions: Some(vec![domain::dimension::DimensionValue::activities(vec![
worn.id().clone(),
retired.id().clone(),
])]),
};
let refused = update_entry::execute(cmd, entry.user_id().clone(), &deps).await;
assert!(
matches!(
refused,
Err(application::errors::ApplicationError::Validation(_))
),
"editing an entry is not a way past the archive, got {refused:?}"
);
}
#[tokio::test]
async fn an_archived_activity_can_be_taken_off_an_entry() {
let user = test_user("alice");
let mut retired = domain::testing::test_activity(user.id().clone(), "fencing");
retired.archive().unwrap();
let (_store, deps, entry) = an_entry_already_wearing(&user, &retired).await;
let cmd = UpdateEntryCommand {
entry_id: entry.id().clone(),
mood: Some(Mood::Rad),
logged_at: None,
dimensions: Some(vec![]),
};
update_entry::execute(cmd, entry.user_id().clone(), &deps)
.await
.expect("dropping a retired tag is always allowed");
}

View File

@@ -67,13 +67,12 @@ async fn import(
}
async fn instants_of(store: &Arc<InMemoryStore>, user: &User) -> Vec<String> {
let mut held: Vec<String> =
MoodEntryQueryPort::find_by_user(store.as_ref(), user.id(), None, None)
.await
.unwrap()
.iter()
.map(|entry| entry.logged_at().to_rfc3339())
.collect();
let mut held: Vec<String> = MoodEntryQueryPort::find_all_by_user(store.as_ref(), user.id())
.await
.unwrap()
.iter()
.map(|entry| entry.logged_at().to_rfc3339())
.collect();
held.sort();
held
@@ -194,3 +193,73 @@ async fn importing_the_same_export_twice_does_not_duplicate_anything() {
assert_eq!(again.skipped, 1);
assert_eq!(instants_of(&store, &user).await.len(), 1);
}
#[tokio::test]
async fn re_importing_the_same_file_adds_nothing() {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
let rows = vec![
("2026-08-25".to_string(), "20:00".to_string(), 4),
("2026-08-26".to_string(), "09:30".to_string(), 2),
];
let first = import(&store, &user, rows.clone()).await.unwrap();
assert_eq!(first.imported, 2);
let again = import(&store, &user, rows).await.unwrap();
assert_eq!(again.imported, 0, "every row was already here");
assert_eq!(again.skipped, 2);
assert_eq!(
store.entry_count(),
2,
"re-importing must not double the history"
);
}
#[tokio::test]
async fn an_entry_held_in_utc_still_matches_a_row_written_in_local_time() {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
let eight_in_the_evening_in_warsaw =
chrono::DateTime::parse_from_rfc3339("2026-08-25T18:00:00+00:00").unwrap();
let held = domain::entry::MoodEntry::new(
user.id().clone(),
domain::entry::Mood::Good,
eight_in_the_evening_in_warsaw,
);
domain::ports::MoodEntryCommandPort::save(store.as_ref(), &held)
.await
.unwrap();
let outcome = import(
&store,
&user,
vec![("2026-08-25".to_string(), "20:00".to_string(), 4)],
)
.await
.unwrap();
assert_eq!(
outcome.imported, 0,
"the same instant is the same entry whether it is held as +00:00 or +02:00"
);
assert_eq!(store.entry_count(), 1);
}
#[tokio::test]
async fn a_row_repeated_inside_one_file_lands_once() {
let store = Arc::new(InMemoryStore::new());
let user = a_user_in(&store, Some("Europe/Warsaw")).await;
let repeated = ("2026-08-25".to_string(), "20:00".to_string(), 4);
let outcome = import(&store, &user, vec![repeated.clone(), repeated])
.await
.unwrap();
assert_eq!(outcome.imported, 1);
assert_eq!(outcome.skipped, 1);
assert_eq!(store.entry_count(), 1);
}

View File

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

View File

@@ -0,0 +1,138 @@
use std::sync::Arc;
use domain::attachment::{ContentType, MediaUpload};
use domain::errors::DomainError;
use domain::ports::MediaStoragePort;
use domain::testing::{FakeMediaStorage, InMemoryStore, test_user};
use application::errors::ApplicationError;
use application::media::use_cases::{delete_photo, delete_voice_memo, upload_photo};
fn an_upload() -> MediaUpload {
MediaUpload::new(vec![1, 2, 3], ContentType::new("image/png").unwrap()).unwrap()
}
fn upload_deps(storage: Arc<FakeMediaStorage>, store: Arc<InMemoryStore>) -> upload_photo::Deps {
upload_photo::Deps {
storage,
ownership: store,
}
}
#[tokio::test]
async fn uploading_claims_the_photo_for_the_uploader() {
let storage = Arc::new(FakeMediaStorage::holding_nothing());
let store = Arc::new(InMemoryStore::new());
let owner = test_user("alice");
let photo_id = upload_photo::execute(
owner.id().clone(),
an_upload(),
&upload_deps(storage.clone(), store.clone()),
)
.await
.unwrap();
let deps = delete_photo::Deps {
storage: storage.clone(),
ownership: store.clone(),
};
delete_photo::execute(photo_id.clone(), owner.id().clone(), &deps)
.await
.expect("the owner may delete their own photo");
assert!(!storage.holds_photo(&photo_id));
}
#[tokio::test]
async fn a_stranger_cannot_delete_someone_elses_photo() {
let storage = Arc::new(FakeMediaStorage::holding_nothing());
let store = Arc::new(InMemoryStore::new());
let owner = test_user("alice");
let stranger = test_user("mallory");
let photo_id = upload_photo::execute(
owner.id().clone(),
an_upload(),
&upload_deps(storage.clone(), store.clone()),
)
.await
.unwrap();
let deps = delete_photo::Deps {
storage: storage.clone(),
ownership: store.clone(),
};
let refused = delete_photo::execute(photo_id.clone(), stranger.id().clone(), &deps).await;
assert!(
matches!(
refused,
Err(ApplicationError::Domain(DomainError::Forbidden(_)))
),
"a stranger must be refused, got {refused:?}"
);
assert!(
storage.holds_photo(&photo_id),
"the blob must survive a refused delete"
);
}
#[tokio::test]
async fn media_nobody_claimed_cannot_be_deleted_by_anyone() {
let storage = Arc::new(FakeMediaStorage::holding_nothing());
let store = Arc::new(InMemoryStore::new());
let caller = test_user("alice");
let orphan = storage.store_photo(an_upload()).await.unwrap();
let deps = delete_photo::Deps {
storage: storage.clone(),
ownership: store.clone(),
};
let refused = delete_photo::execute(orphan.clone(), caller.id().clone(), &deps).await;
assert!(
matches!(
refused,
Err(ApplicationError::Domain(DomainError::NotFound(_)))
),
"an unclaimed blob has no owner to check against, got {refused:?}"
);
assert!(storage.holds_photo(&orphan));
}
#[tokio::test]
async fn a_voice_memo_id_is_not_a_photo_id() {
let storage = Arc::new(FakeMediaStorage::holding_nothing());
let store = Arc::new(InMemoryStore::new());
let owner = test_user("alice");
let photo_id = upload_photo::execute(
owner.id().clone(),
an_upload(),
&upload_deps(storage.clone(), store.clone()),
)
.await
.unwrap();
let memo_id = domain::attachment::VoiceMemoId::from_uuid(photo_id.value());
let deps = delete_voice_memo::Deps {
storage: storage.clone(),
ownership: store.clone(),
};
let refused = delete_voice_memo::execute(memo_id, owner.id().clone(), &deps).await;
assert!(
matches!(
refused,
Err(ApplicationError::Domain(DomainError::NotFound(_)))
),
"claiming a photo must not claim a voice memo that shares its uuid, got {refused:?}"
);
}

View File

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

View File

@@ -0,0 +1,112 @@
use std::sync::Arc;
use domain::ports::PushSubscriptionQueryPort;
use domain::testing::{InMemoryStore, test_user};
use application::push::commands::{SubscribePushCommand, UnsubscribePushCommand};
use application::push::use_cases::{subscribe, unsubscribe};
const ENDPOINT: &str = "https://push.example.com/a-device";
fn a_subscription(user_id: domain::user::UserId) -> SubscribePushCommand {
SubscribePushCommand {
user_id,
endpoint: ENDPOINT.into(),
p256dh: "a-key".into(),
auth: "a-secret".into(),
}
}
#[tokio::test]
async fn subscribing_twice_keeps_one_subscription() {
let store = Arc::new(InMemoryStore::new());
let user = test_user("alice");
let deps = subscribe::Deps {
push_command: store.clone(),
push_query: store.clone(),
};
subscribe::execute(a_subscription(user.id().clone()), &deps)
.await
.unwrap();
let first = store.find_by_endpoint(ENDPOINT).await.unwrap().unwrap();
subscribe::execute(a_subscription(user.id().clone()), &deps)
.await
.unwrap();
let held = store.find_by_user(user.id()).await.unwrap();
assert_eq!(held.len(), 1, "one device is one subscription");
assert_eq!(
held[0].id(),
first.id(),
"re-subscribing renews the subscription rather than minting a new one"
);
}
#[tokio::test]
async fn a_stranger_cannot_unsubscribe_someone_elses_device() {
let store = Arc::new(InMemoryStore::new());
let owner = test_user("alice");
let stranger = test_user("mallory");
let deps = subscribe::Deps {
push_command: store.clone(),
push_query: store.clone(),
};
subscribe::execute(a_subscription(owner.id().clone()), &deps)
.await
.unwrap();
let deps = unsubscribe::Deps {
push_command: store.clone(),
};
unsubscribe::execute(
UnsubscribePushCommand {
user_id: stranger.id().clone(),
endpoint: ENDPOINT.into(),
},
&deps,
)
.await
.unwrap();
assert_eq!(
store.find_by_user(owner.id()).await.unwrap().len(),
1,
"the owner's device must still be subscribed"
);
}
#[tokio::test]
async fn the_owner_can_unsubscribe_their_own_device() {
let store = Arc::new(InMemoryStore::new());
let owner = test_user("alice");
let deps = subscribe::Deps {
push_command: store.clone(),
push_query: store.clone(),
};
subscribe::execute(a_subscription(owner.id().clone()), &deps)
.await
.unwrap();
let deps = unsubscribe::Deps {
push_command: store.clone(),
};
unsubscribe::execute(
UnsubscribePushCommand {
user_id: owner.id().clone(),
endpoint: ENDPOINT.into(),
},
&deps,
)
.await
.unwrap();
assert!(store.find_by_user(owner.id()).await.unwrap().is_empty());
}

View File

@@ -1,6 +1,6 @@
use std::sync::{Arc, Mutex};
use chrono::Utc;
use chrono::{Duration, Utc};
use domain::errors::DomainError;
use domain::ports::{ReminderCommandPort, ReminderSenderPort, UserCommandPort};
@@ -71,8 +71,10 @@ async fn a_failing_send_does_not_abort_the_sweep() {
let sender = Arc::new(FlakySender::new(broken.id().clone()));
let deps = process_due_reminders::Deps {
reminder_query: store.clone(),
reminder_command: store.clone(),
user_query: store.clone(),
sender: sender.clone(),
grace: Duration::minutes(30),
};
let sent = process_due_reminders::execute(&deps).await.unwrap();
@@ -85,3 +87,89 @@ async fn a_failing_send_does_not_abort_the_sweep() {
"both users should have been attempted regardless of order, got {attempts:?}"
);
}
struct CountingSender {
attempts: Mutex<Vec<UserId>>,
}
impl CountingSender {
fn new() -> Self {
Self {
attempts: Mutex::new(Vec::new()),
}
}
fn count(&self) -> usize {
self.attempts.lock().unwrap().len()
}
}
#[async_trait::async_trait]
impl ReminderSenderPort for CountingSender {
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
self.attempts.lock().unwrap().push(user_id.clone());
Ok(())
}
}
#[tokio::test]
async fn a_reminder_is_sent_once_however_often_the_sweep_runs() {
let store = Arc::new(InMemoryStore::new());
let mut user = test_user("nadia");
user.update_timezone(Some(Timezone::new("UTC").unwrap()));
UserCommandPort::save(&*store, &user).await.unwrap();
ReminderCommandPort::save(&*store, &due_now(user.id().clone()))
.await
.unwrap();
let sender = Arc::new(CountingSender::new());
let deps = process_due_reminders::Deps {
reminder_query: store.clone(),
reminder_command: store.clone(),
user_query: store.clone(),
sender: sender.clone(),
grace: Duration::minutes(30),
};
for _ in 0..5 {
process_due_reminders::execute(&deps).await.unwrap();
}
assert_eq!(
sender.count(),
1,
"five sweeps inside one grace window must still send one reminder"
);
}
#[tokio::test]
async fn a_send_that_fails_is_retried_by_the_next_sweep() {
let store = Arc::new(InMemoryStore::new());
let mut user = test_user("broken");
user.update_timezone(Some(Timezone::new("UTC").unwrap()));
UserCommandPort::save(&*store, &user).await.unwrap();
ReminderCommandPort::save(&*store, &due_now(user.id().clone()))
.await
.unwrap();
let sender = Arc::new(FlakySender::new(user.id().clone()));
let deps = process_due_reminders::Deps {
reminder_query: store.clone(),
reminder_command: store.clone(),
user_query: store.clone(),
sender: sender.clone(),
grace: Duration::minutes(30),
};
process_due_reminders::execute(&deps).await.unwrap();
process_due_reminders::execute(&deps).await.unwrap();
assert_eq!(
sender.attempts().len(),
2,
"nothing was delivered, so the occurrence is still owed"
);
}

View File

@@ -17,6 +17,7 @@ async fn setup() -> (Arc<InMemoryStore>, change_password::Deps, User) {
store.save(&user).await.unwrap();
let deps = change_password::Deps {
refresh_sessions: store.clone(),
user_command: store.clone(),
user_query: store.clone(),
password_hasher: Arc::new(FakePasswordHasher),
@@ -65,3 +66,64 @@ async fn rejects_nonexistent_user() {
let result = change_password::execute(cmd, &deps).await;
assert!(result.is_err());
}
#[tokio::test]
async fn changing_a_password_revokes_every_live_session() {
let (store, deps, user) = setup().await;
for _ in 0..3 {
domain::ports::RefreshSessionCommandPort::create(
store.as_ref(),
&domain::auth::RefreshSession::new(user.id().clone(), 3_600),
)
.await
.unwrap();
}
assert_eq!(store.refresh_sessions.read().unwrap().len(), 3);
change_password::execute(
ChangePasswordCommand {
user_id: user.id().clone(),
current_password: "secret".into(),
new_password: "new-secret".into(),
},
&deps,
)
.await
.unwrap();
assert!(
store.refresh_sessions.read().unwrap().is_empty(),
"a stolen refresh token must not survive a password change"
);
}
#[tokio::test]
async fn a_refused_password_change_leaves_sessions_alone() {
let (store, deps, user) = setup().await;
domain::ports::RefreshSessionCommandPort::create(
store.as_ref(),
&domain::auth::RefreshSession::new(user.id().clone(), 3_600),
)
.await
.unwrap();
let refused = change_password::execute(
ChangePasswordCommand {
user_id: user.id().clone(),
current_password: "not-the-password".into(),
new_password: "new-secret".into(),
},
&deps,
)
.await;
assert!(refused.is_err());
assert_eq!(
store.refresh_sessions.read().unwrap().len(),
1,
"a failed attempt must not log the account out"
);
}

View File

@@ -1,33 +1,160 @@
use std::sync::Arc;
use domain::entry::Mood;
use domain::ports::{ActivityCommandPort, MoodEntryCommandPort, ReminderCommandPort};
use domain::entry::{Date, DateSpan, Mood};
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
use domain::ports::{
ActivityCommandPort, CycleStartCommandPort, CycleStartQueryPort, DailyMetricCommandPort,
DailyMetricQueryPort, MediaOwnershipPort, MoodEntryCommandPort, RejectionCommandPort,
RejectionQueryPort, ReminderCommandPort, UserQueryPort,
};
use domain::rejection::{RejectedMetric, RejectionDetail, RejectionOrigin};
use domain::testing::{InMemoryStore, test_activity, test_entry, test_reminder, test_user};
use domain::user::User;
use application::user::use_cases::clear_data;
#[tokio::test]
async fn clears_all_user_data() {
let store = Arc::new(InMemoryStore::new());
fn on(day: &str) -> Date {
Date::from_persistence(day.parse().unwrap())
}
fn everything() -> DateSpan {
DateSpan::new(on("1970-01-01"), on("9999-12-31")).unwrap()
}
async fn an_account_with_everything_in_it(store: &Arc<InMemoryStore>) -> User {
let user = test_user("alice");
let entry = test_entry(user.id().clone(), Mood::Good);
let activity = test_activity(user.id().clone(), "gaming");
let reminder = test_reminder(user.id().clone());
domain::ports::UserCommandPort::save(store.as_ref(), &user)
.await
.unwrap();
MoodEntryCommandPort::save(&*store, &entry).await.unwrap();
ActivityCommandPort::save(&*store, &activity).await.unwrap();
ReminderCommandPort::save(&*store, &reminder).await.unwrap();
MoodEntryCommandPort::save(store.as_ref(), &test_entry(user.id().clone(), Mood::Good))
.await
.unwrap();
ActivityCommandPort::save(store.as_ref(), &test_activity(user.id().clone(), "gaming"))
.await
.unwrap();
ReminderCommandPort::save(store.as_ref(), &test_reminder(user.id().clone()))
.await
.unwrap();
DailyMetricCommandPort::save(
store.as_ref(),
&[DailyMetric::new(
user.id().clone(),
on("2026-08-20"),
MetricValue::Steps(Steps::new(8_412).unwrap()),
Source::Manual,
)],
)
.await
.unwrap();
CycleStartCommandPort::record(store.as_ref(), user.id(), &on("2026-08-01"))
.await
.unwrap();
RejectionCommandPort::record(
store.as_ref(),
&[RejectedMetric::new(
user.id().clone(),
RejectionOrigin::Import,
RejectionDetail::new(None, Some(on("2026-08-19")), "steps", Some(-1)),
String::from("steps cannot be negative"),
)],
)
.await
.unwrap();
MediaOwnershipPort::remember(
store.as_ref(),
user.id(),
(&domain::attachment::PhotoId::generate()).into(),
)
.await
.unwrap();
let deps = clear_data::Deps {
dimensions: vec![],
entry_query: store.clone(),
user
}
fn deps(store: &Arc<InMemoryStore>) -> clear_data::Deps {
clear_data::Deps {
cascade: store.clone(),
media_storage: store.clone(),
};
clear_data::execute(user.id().clone(), &deps).await.unwrap();
assert_eq!(store.entry_count(), 0);
assert_eq!(store.activity_count(), 0);
assert_eq!(store.reminder_count(), 0);
media_ownership: store.clone(),
}
}
#[tokio::test]
async fn clearing_leaves_nothing_the_user_logged() {
let store = Arc::new(InMemoryStore::new());
let user = an_account_with_everything_in_it(&store).await;
clear_data::execute(user.id().clone(), &deps(&store))
.await
.unwrap();
assert_eq!(store.entry_count(), 0, "entries");
assert_eq!(store.activity_count(), 0, "activities");
assert_eq!(store.reminder_count(), 0, "reminders");
let metrics = DailyMetricQueryPort::find_by_span(store.as_ref(), user.id(), &everything())
.await
.unwrap();
assert!(metrics.is_empty(), "daily metrics");
let starts = CycleStartQueryPort::find_by_user(store.as_ref(), user.id())
.await
.unwrap();
assert!(starts.is_empty(), "cycle starts");
let rejections = RejectionQueryPort::find_recent_by_user(store.as_ref(), user.id())
.await
.unwrap();
assert!(rejections.is_empty(), "rejected metrics");
let media = MediaOwnershipPort::owned_by(store.as_ref(), user.id())
.await
.unwrap();
assert!(media.is_empty(), "media ownership");
}
#[tokio::test]
async fn clearing_keeps_the_account_itself() {
let store = Arc::new(InMemoryStore::new());
let user = an_account_with_everything_in_it(&store).await;
clear_data::execute(user.id().clone(), &deps(&store))
.await
.unwrap();
assert!(
UserQueryPort::find_by_id(store.as_ref(), user.id())
.await
.unwrap()
.is_some(),
"clearing data is not deleting the account"
);
}
#[tokio::test]
async fn clearing_one_account_leaves_another_alone() {
let store = Arc::new(InMemoryStore::new());
let alice = an_account_with_everything_in_it(&store).await;
let bob = test_user("bob");
domain::ports::UserCommandPort::save(store.as_ref(), &bob)
.await
.unwrap();
MoodEntryCommandPort::save(store.as_ref(), &test_entry(bob.id().clone(), Mood::Rad))
.await
.unwrap();
CycleStartCommandPort::record(store.as_ref(), bob.id(), &on("2026-07-01"))
.await
.unwrap();
clear_data::execute(alice.id().clone(), &deps(&store))
.await
.unwrap();
assert_eq!(store.entry_count(), 1, "bob's entry must survive");
let starts = CycleStartQueryPort::find_by_user(store.as_ref(), bob.id())
.await
.unwrap();
assert_eq!(starts.len(), 1, "bob's cycle start must survive");
}

View File

@@ -11,11 +11,10 @@ use application::user::use_cases::delete_user;
fn deps(store: &Arc<InMemoryStore>) -> delete_user::Deps {
delete_user::Deps {
dimensions: vec![],
user_query: store.clone(),
entry_query: store.clone(),
cascade: store.clone(),
media_storage: store.clone(),
media_ownership: store.clone(),
events: store.clone(),
}
}