Files
k-mood/crates/application/tests/backup/round_trip_test.rs
Gabriel Kaszewski bf148902ab 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)
2026-08-28 15:00:30 +02:00

496 lines
15 KiB
Rust

use std::sync::Arc;
use domain::activity::{Activity, ActivityName, CategoryName};
use domain::dimension::DimensionValue;
use domain::entry::{Content, Date, DateSpan, Mood, MoodEntry};
use domain::location::Coordinates;
use domain::metric::{DailyMetric, MetricValue, Source, Steps};
use domain::ports::{
ActivityCommandPort, ActivityQueryPort, CycleStartCommandPort, CycleStartQueryPort,
DailyMetricCommandPort, DailyMetricQueryPort, EntryDimensionPort, MoodEntryCommandPort,
MoodEntryQueryPort, ReminderCommandPort, ReminderQueryPort, UserCommandPort,
UserPreferencesQueryPort,
};
use domain::reminder::{DaySchedule, Reminder};
use domain::song::Song;
use domain::testing::{InMemoryDimensionStore, InMemoryStore, test_user};
use domain::user::{Timezone, User};
use application::export::use_cases::write_backup;
use application::restore::commands::RestoreBackupCommand;
use application::restore::use_cases::restore_backup::{self, RestoreOutcome};
fn dimension_stores() -> Vec<Arc<dyn EntryDimensionPort>> {
use domain::dimension::DimensionKind;
[
DimensionKind::Content,
DimensionKind::Activities,
DimensionKind::Location,
DimensionKind::Song,
DimensionKind::Photos,
DimensionKind::VoiceMemos,
]
.into_iter()
.map(|kind| Arc::new(InMemoryDimensionStore::new(kind)) as Arc<dyn EntryDimensionPort>)
.collect()
}
struct World {
store: Arc<InMemoryStore>,
dimensions: Vec<Arc<dyn EntryDimensionPort>>,
user: User,
}
async fn a_populated_account() -> World {
let store = Arc::new(InMemoryStore::new());
let dimensions = dimension_stores();
let mut user = test_user("alice");
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
let walking = Activity::new(
user.id().clone(),
ActivityName::new("long walk").unwrap(),
Some(CategoryName::new("health").unwrap()),
);
ActivityCommandPort::save(store.as_ref(), &walking)
.await
.unwrap();
let entry = MoodEntry::new(
user.id().clone(),
Mood::Rad,
chrono::DateTime::parse_from_rfc3339("2026-08-20T21:30:00+02:00").unwrap(),
);
MoodEntryCommandPort::save(store.as_ref(), &entry)
.await
.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();
}
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();
let reminder = Reminder::new(
user.id().clone(),
DaySchedule::every_day_at(chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
);
ReminderCommandPort::save(store.as_ref(), &reminder)
.await
.unwrap();
let preference_deps = application::user::use_cases::set_preferences::Deps {
command: store.clone(),
query: store.clone(),
};
application::user::use_cases::set_preferences::execute(
user.id().clone(),
true,
&preference_deps,
)
.await
.unwrap();
World {
store,
dimensions,
user,
}
}
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())
}
async fn backup_of(world: &World) -> Vec<u8> {
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: world.store.clone(),
writer: Arc::new(exporter::ZipBackupWriter),
};
write_backup::execute(world.user.id().clone(), &deps)
.await
.unwrap()
}
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(),
activity_command: world.store.clone(),
activity_query: world.store.clone(),
reminder_command: world.store.clone(),
metrics: world.store.clone(),
cycles: world.store.clone(),
preferences_command: world.store.clone(),
preferences_query: world.store.clone(),
media_storage: world.store.clone(),
};
restore_backup::execute(
RestoreBackupCommand {
user_id: world.user.id().clone(),
data: archive,
},
&deps,
)
.await
.unwrap()
}
async fn an_empty_account() -> World {
let store = Arc::new(InMemoryStore::new());
let mut user = test_user("bob");
user.update_timezone(Some(Timezone::new("Europe/Warsaw").unwrap()));
UserCommandPort::save(store.as_ref(), &user).await.unwrap();
World {
store,
dimensions: dimension_stores(),
user,
}
}
#[tokio::test]
async fn a_backup_restores_into_an_empty_account_without_losing_anything() {
let source = a_populated_account().await;
let archive = backup_of(&source).await;
let restored = an_empty_account().await;
let outcome = restore_into(&restored, archive).await;
assert!(
outcome.unreadable.is_empty(),
"the restore could not read: {:?}",
outcome.unreadable
);
assert_eq!(outcome.entries, 1);
assert_eq!(outcome.metrics, 1);
assert_eq!(outcome.cycle_starts, 1);
assert_eq!(outcome.activities, 1);
assert_eq!(outcome.reminders, 1);
}
#[tokio::test]
async fn every_dimension_survives_the_round_trip() {
let source = a_populated_account().await;
let archive = backup_of(&source).await;
let restored = an_empty_account().await;
restore_into(&restored, archive).await;
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
.unwrap();
assert_eq!(composed.len(), 1);
let entry = &composed[0];
assert_eq!(entry.entry.mood(), Mood::Rad);
assert_eq!(
entry.content().map(|content| content.value().to_string()),
Some("Long walk by the river".to_string())
);
assert!(entry.location().is_some(), "the location was lost");
assert_eq!(
entry.song().map(|song| song.artist().value().to_string()),
Some("Massive Attack".to_string())
);
assert_eq!(entry.activities().len(), 1, "the activity tag was lost");
}
#[tokio::test]
async fn a_restored_activity_tag_points_at_the_restored_activity() {
let source = a_populated_account().await;
let archive = backup_of(&source).await;
let restored = an_empty_account().await;
restore_into(&restored, archive).await;
let catalog = ActivityQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.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
.unwrap();
assert_eq!(catalog.len(), 1);
assert_eq!(
composed[0].activities(),
[catalog[0].id().clone()],
"the tag must reference the activity as it now exists, not as it was"
);
assert_eq!(catalog[0].name().value(), "long walk");
}
#[tokio::test]
async fn metrics_cycle_starts_and_preferences_all_come_back() {
let source = a_populated_account().await;
let archive = backup_of(&source).await;
let restored = an_empty_account().await;
restore_into(&restored, archive).await;
let span = DateSpan::new(on("2026-01-01"), on("2026-12-31")).unwrap();
let metrics =
DailyMetricQueryPort::find_by_span(restored.store.as_ref(), restored.user.id(), &span)
.await
.unwrap();
let starts = CycleStartQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
let preferences =
UserPreferencesQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap()
.unwrap();
let reminders = ReminderQueryPort::find_by_user(restored.store.as_ref(), restored.user.id())
.await
.unwrap();
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].value().count(), 8_412);
assert_eq!(starts.len(), 1);
assert_eq!(starts[0].to_string(), "2026-08-01");
assert!(preferences.tracks_cycle(), "the preference was lost");
assert_eq!(reminders.len(), 1);
assert!(reminders[0].is_enabled());
}
#[tokio::test]
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(),
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: restored.store.clone(),
};
let refused = restore_backup::execute(
RestoreBackupCommand {
user_id: restored.user.id().clone(),
data: b"this is not a zip file at all".to_vec(),
},
&deps,
)
.await
.unwrap_err();
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);
}